Files
accounted/extensions/general/whatsapp-inbox/lib/company-question.ts
T
273af39994 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>
2026-08-27 22:26:38 +02:00

403 lines
15 KiB
TypeScript

/**
* The company question (M6): asked when a multi-company sender has no live
* pin and no default, answered with a reply button, a list row, or a typed
* digit. The answer pins the conversation's company for 8 sliding hours.
*
* While the question is open, the receipt rows are PARKED as
* whatsapp_messages with processing_status='skipped' and
* error_message='staged_awaiting_company' (no upload happens: a document
* cannot enter the company-scoped WORM archive before a company exists).
* The rows themselves are the staged refs; media stays re-downloadable from
* Meta for days, so no bytes are stored anywhere else.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import type { WhatsAppConversation, WhatsAppPhoneLink } from '@/types'
import {
sendReplyButtons,
sendList,
sendText,
MAX_REPLY_BUTTONS,
MAX_LIST_ROWS,
type SendMessageBase,
type SendTextResult,
} from './graph-api'
import { botCopy, TEMPLATE } from './messages'
import {
COMPANY_PIN_TTL_MS,
NO_COMPANY_OPTIONS,
STAGED_AWAITING_COMPANY,
getContext,
updateConversation,
type ConversationContext,
} from './conversation'
const log = createLogger('whatsapp-inbox/company-question')
/** Defensive ceiling on the numbered text list (>10 companies). */
const MAX_NUMBERED_OPTIONS = 30
/** M19 (no company to file into) is sent at most once per this window per
* conversation: every parked row of a burst walks through the no-options
* branch, and five photos must not earn five identical replies. */
const NO_OPTIONS_NOTICE_WINDOW_MS = 10 * 60 * 1000
export type CompanyChoiceVia = 'button' | 'list' | 'numbered'
export interface CompanyOption {
id: string
name: string
}
type ReplyBase = Omit<SendMessageBase, 'to' | 'template'>
/** 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,
): Promise<CompanyOption[] | null> {
const { data: memberships, error: membershipsError } = await supabase
.from('company_members')
.select('company_id')
.eq('user_id', userId)
if (membershipsError) {
log.warn('company options membership query failed', { error: membershipsError.message })
return null
}
const companyIds = [...new Set((memberships ?? []).map((m) => m.company_id as string))]
if (companyIds.length === 0) return []
const { data: companies, error: companiesError } = await supabase
.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
}
const options = ((companies ?? []) as { id: string; name: string | null }[]).map((c) => ({
id: c.id,
name: c.name ?? 'Företag',
}))
options.sort((a, b) => a.name.localeCompare(b.name, 'sv'))
return options.slice(0, MAX_NUMBERED_OPTIONS)
}
/** How an ask attempt ended.
* - asked: this caller sent the question; rows stay staged for the answer.
* - not_asked: lost the guarded transition or the send failed; the episode
* is owned elsewhere (or re-triggers on the next receipt). Rows stay staged.
* - no_options: fewer than 2 companies genuinely exist, so no question can
* help; the staged rows were re-marked no_company_options and M19 told the
* sender to fix the linking in the app. Terminal by design.
* - transient_error: the options could not be LOADED (DB error): the caller
* must release its row back to 'received' so the sweep retries it. */
export type AskCompanyQuestionOutcome = 'asked' | 'not_asked' | 'no_options' | 'transient_error'
/**
* Ask the company question ONCE per open episode. The guarded state
* transition (WHERE state <> 'awaiting_company') makes concurrent workers of
* one burst produce exactly one M6; losers stage silently.
*
* The state is committed BEFORE the send (a fast tap must find it) but rolled
* back when the send fails: sends are best-effort and never throw, so without
* 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,
args: {
conversation: WhatsAppConversation
link: WhatsAppPhoneLink
to: string
replyBase: ReplyBase
/** How many receipts wait on the answer (question body wording). */
stagedCount: number
},
): Promise<AskCompanyQuestionOutcome> {
const options = await loadCompanyOptions(supabase, args.link.user_id)
if (options === null) return 'transient_error'
if (options.length < 2) {
// Genuinely degenerate (a real empty/1-length result, not a query error):
// the sender has nothing to choose between, so no question can help and
// nothing will change until they act in the app. The old behavior parked
// the rows silently forever; instead mark them with a terminal, greppable
// marker and say so in the chat.
log.warn('company question requested with fewer than 2 options', {
conversationId: args.conversation.id,
})
await supabase
.from('whatsapp_messages')
.update({ error_message: NO_COMPANY_OPTIONS })
.eq('conversation_id', args.conversation.id)
.eq('processing_status', 'skipped')
.eq('error_message', STAGED_AWAITING_COMPANY)
// One M19 per burst window (same idiom as the M17 rate-limit notice).
const since = new Date(Date.now() - NO_OPTIONS_NOTICE_WINDOW_MS).toISOString()
const { data: noticeSent } = await supabase
.from('whatsapp_messages')
.select('id')
.eq('direction', 'outbound')
.eq('conversation_id', args.conversation.id)
.eq('raw_payload->>template', TEMPLATE.m19NoCompany)
.gte('created_at', since)
.limit(1)
.maybeSingle()
if (!noticeSent) {
await sendText(supabase, {
to: args.to,
body: botCopy('sv').m19NoCompany(),
template: TEMPLATE.m19NoCompany,
...args.replyBase,
})
}
return 'no_options'
}
const now = new Date()
const askedAt = now.toISOString()
const previousState = args.conversation.state
const context = getContext(args.conversation)
const nextContext: ConversationContext = {
...context,
company_options: options,
pending_question: { type: 'company', inbox_item_id: null, asked_at: askedAt },
}
const { data: won } = await supabase
.from('whatsapp_conversations')
.update({ state: 'awaiting_company', context: nextContext as Record<string, unknown> })
.eq('id', args.conversation.id)
.neq('state', 'awaiting_company')
.select('*')
if (!Array.isArray(won) || won.length === 0) return 'not_asked'
// Take updated_at (the revision guard) from the echoed row, but state and
// context from what we just wrote: that is what a rollback must match.
const committed: WhatsAppConversation = {
...args.conversation,
...((won[0] as WhatsAppConversation | undefined) ?? {}),
state: 'awaiting_company',
context: nextContext as Record<string, unknown>,
}
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 }
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 (interactive) {
sent = await sendList(supabase, {
...base,
body,
buttonLabel: 'Välj företag',
rows: options.map((o) => ({ id: o.id, title: o.name })),
})
} else {
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) {
// Undo the ask so the next receipt re-triggers it. Only our own question
// is rolled back: a newer one (different asked_at) is left alone.
await updateConversation(supabase, committed, (current, currentContext) => {
if (
current.state !== 'awaiting_company' ||
currentContext.pending_question?.asked_at !== askedAt
) {
return null
}
const rolledBack: ConversationContext = { ...currentContext }
delete rolledBack.company_options
delete rolledBack.pending_question
return { state: previousState === 'awaiting_company' ? 'idle' : previousState, context: rolledBack }
})
log.warn('company question send failed; question rolled back', {
conversationId: args.conversation.id,
})
return 'not_asked'
}
return 'asked'
}
export interface AppliedCompanyChoice {
ok: true
companyId: string
companyName: string
/** Parked message rows re-opened for processing (run through the kick). */
stagedMessageIds: string[]
}
export interface RejectedCompanyChoice {
ok: false
/**
* - invalid_option: a typed digit outside the numbered range. Ordinary
* user input (a typo), so the caller re-prompts instead of going silent.
* - not_member / lookup_failed / already_applied: stale or forged payloads
* and races. Silence.
*/
reason: 'invalid_option' | 'not_member' | 'lookup_failed' | 'already_applied'
}
export type CompanyChoiceResult = AppliedCompanyChoice | RejectedCompanyChoice
/**
* Apply a company answer (button/list id or typed digit). Validates
* membership, pins the company for 8h, confirms with M6-confirm, re-opens
* the parked rows and arms the combined ack.
*
* The open question, not the conversation state, is the thing being claimed:
* company_options are deleted by the winning write, so a double tap delivered
* into parallel invocations confirms exactly once, and a LATE answer (the 48h
* TTL reset the state to idle but kept the options) still lands.
*/
export async function applyCompanyChoice(
supabase: SupabaseClient,
args: {
conversation: WhatsAppConversation
link: WhatsAppPhoneLink
choice: { companyId: string } | { digit: number }
via: CompanyChoiceVia
to: string
replyBase: ReplyBase
},
): Promise<CompanyChoiceResult> {
const context = getContext(args.conversation)
const options = context.company_options ?? []
if (options.length === 0) return { ok: false, reason: 'already_applied' }
let companyId: string | null = null
if ('companyId' in args.choice) {
companyId = args.choice.companyId
} else {
const option = options[args.choice.digit - 1]
companyId = option?.id ?? null
if (!companyId) return { ok: false, reason: 'invalid_option' }
}
if (!companyId) return { ok: false, reason: 'invalid_option' }
// 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, 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) {
log.error('company choice membership lookup failed', membershipError, {
conversationId: args.conversation.id,
})
return { ok: false, reason: 'lookup_failed' }
}
if (!membership) {
log.warn('company choice rejected: sender is not a member', {
conversationId: args.conversation.id,
})
return { ok: false, reason: 'not_member' }
}
const known = options.find((o) => o.id === companyId)
let companyName = known?.name ?? null
if (!companyName) {
const { data: company } = await supabase
.from('companies')
.select('name')
.eq('id', companyId)
.maybeSingle()
companyName = (company as { name?: string } | null)?.name ?? 'Företag'
}
const now = new Date()
// Pin + arm the combined ack: the staged receipts process right after,
// and the finalize step claims `pending_ack AND debounce_until <= now()`.
// Guarded: whoever deletes company_options first owns the answer.
const applied = await updateConversation(supabase, args.conversation, (_current, currentContext) => {
if ((currentContext.company_options?.length ?? 0) === 0) return null
const nextContext: ConversationContext = { ...currentContext }
delete nextContext.company_options
delete nextContext.pending_question
nextContext.pin_expires_at = new Date(now.getTime() + COMPANY_PIN_TTL_MS).toISOString()
nextContext.pin_source = args.via
return {
state: 'idle',
company_id: companyId,
context: nextContext,
pending_ack: true,
debounce_until: now.toISOString(),
}
})
if (!applied) return { ok: false, reason: 'already_applied' }
await supabase
.from('whatsapp_phone_links')
.update({ last_company_id: companyId })
.eq('id', args.link.id)
await sendText(supabase, {
to: args.to,
body: botCopy('sv').m6CompanyConfirm({ companyName }),
template: TEMPLATE.m6CompanyConfirm,
...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')
return {
ok: true,
companyId,
companyName,
stagedMessageIds: ((reopened ?? []) as { id: string }[]).map((r) => r.id),
}
}