Files
accounted/extensions/general/whatsapp-inbox/lib/company-question.ts
T
MattssonandClaude Fable 5 4a9fa5e6c5 feat(inbox): staged upload ack, HEIC/HEIF validation, WhatsApp silence fixes (#1605)
* fix(whatsapp): app-side unmute, close silent intake paths, health visibility

- add POST /link/unmute and a Reactivate control on the Pausad state
- company resolution: transient query errors release the row for sweep
  retry; genuine zero-options sends M19 instead of parking silently
- media from unlinked senders bypasses the hourly greeting throttle
  (10 min burst window, daily cap kept)
- GET /link returns 7-day failed-delivery and parked-inbound counts;
  sweep summary logs outboundFailed24h

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(documents): real HEIC/HEIF magic-byte validation, bilingual upload errors

- detect ISO-BMFF ftyp brands (heic/heix/heim/heis/hevc/hevx/hevm/hevs,
  mif1/msf1) instead of exempting image/heic from validation; declared
  heic/heif accepts either family member (iOS labels vary)
- new INBOX_UPLOAD_* structured error codes replace raw English strings
  on the inbox upload and attach-document routes
- registry doc corrected to the real 10 MB cap

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(inbox): staged upload with instant ack and deferred AI extraction

- web uploads insert the inbox item as status processing and respond
  immediately; Bedrock extraction and supplier match run via after()
  with a CAS flip to received (email and WhatsApp channels keep the
  synchronous path)
- widen invoice_inbox_items.status CHECK to include processing
  (migration 20260813180000, pg-real test included)
- crash-recovery sweep cron (*/2) flips stale processing rows;
  bulk-book skips extraction_in_progress items
- workspace: processing chip, in-flight rows disable actions, realtime
  flip, retry-extraction button for empty extractions
- picker accept list drops HEIC/HEIF so iOS transcodes library photos
  to JPEG; server allowlists unchanged (supersedes 2026-08-01 HEIC
  decision, see DECISIONS.md)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): bump inbox processing-status migration past main's latest

Main merged 20260813210000 while this PR was in flight; an inserted
version older than the latest applied aborts the prod db push at merge.
Renamed 20260813180000 to 20260813213000 and updated references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(decisions): log preview-tracker orphan repair after migration rename

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:57:53 +02:00

376 lines
14 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,
} 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 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. */
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)
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.
*/
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 base = { to: args.to, template: TEMPLATE.m6CompanyQuestion, ...args.replyBase }
let sent: { ok: boolean }
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) {
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: copy.m6CompanyQuestionNumbered({
count: args.stagedCount,
options: options.map((o) => o.name),
}),
})
}
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,
// whatever the payload claimed. 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')
.eq('user_id', args.link.user_id)
.eq('company_id', companyId)
.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),
}
}