* 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>
468 lines
18 KiB
TypeScript
468 lines
18 KiB
TypeScript
/**
|
|
* Deterministic conversation state helpers (LLM-last: everything here is
|
|
* plain code over whatsapp_conversations).
|
|
*
|
|
* The `context` jsonb column carries, per conversation:
|
|
* - pin_expires_at 8h sliding company-pin TTL (companion of company_id)
|
|
* - pin_source how the live pin was chosen (button/list/numbered)
|
|
* - company_options ordered options behind an open company question,
|
|
* so a digit reply maps to a company id
|
|
* - pending_question the ONE in-flight question (type + item + asked_at)
|
|
* - question_queue questions admitted for later (burst budget of 2)
|
|
* - recent_questions asked-question log for late answers (<= 7 days)
|
|
* - budget daily content-question counter (Europe/Stockholm)
|
|
*
|
|
* Concurrency note: context is read-modify-write via PostgREST, and the
|
|
* writers (burst-ack winner, answer worker, company-choice handler, sweep)
|
|
* hold DIFFERENT claims (pending_ack vs processing_status), so they are not
|
|
* serialized against each other. Every context write therefore goes through
|
|
* updateConversation(), which guards on the updated_at it read and re-applies
|
|
* the mutation against fresh state when it loses. Media staging deliberately
|
|
* does NOT go through context (staged refs live as whatsapp_messages rows) so
|
|
* parallel webhook invocations never race on this column at all.
|
|
*/
|
|
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createLogger } from '@/lib/logger'
|
|
import type { WhatsAppConversation, WhatsAppMessage } from '@/types'
|
|
import { decryptPhone } from './phone-crypto'
|
|
import { TEMPLATE } from './messages'
|
|
|
|
const log = createLogger('whatsapp-inbox/conversation')
|
|
|
|
// The M1 "not linked" greeting is hard throttled per phone hash: text gets
|
|
// 1/hour, media rides a 10-minute burst window instead (see below); both
|
|
// share the 3/day cap, then silence. Shared by the unknown-sender path
|
|
// (webhook) and the revoked-mid-processing path (process-inbound), so it
|
|
// lives here.
|
|
const GREETING_HOUR_MS = 60 * 60 * 1000
|
|
const GREETING_BURST_MS = 10 * 60 * 1000
|
|
const GREETING_DAY_MS = 24 * 60 * 60 * 1000
|
|
const GREETING_DAY_MAX = 3
|
|
|
|
/** True when another M1 greeting to this phone hash would exceed the cap.
|
|
* Fails CLOSED: if the throttle window cannot be read, no greeting goes
|
|
* out, matching the unknown-sender quota's stance.
|
|
*
|
|
* `media: true` marks a message that carried an image or document: the
|
|
* sender is handing over a receipt they expect to be handled, so repeats are
|
|
* suppressed only inside a 10-minute burst window (one burst of photos earns
|
|
* exactly one M1) instead of the full hour. The daily cap applies to both. */
|
|
export async function greetingThrottled(
|
|
supabase: SupabaseClient,
|
|
phoneHash: string,
|
|
opts: { media?: boolean } = {},
|
|
): Promise<boolean> {
|
|
const since = new Date(Date.now() - GREETING_DAY_MS).toISOString()
|
|
const { data, error } = await supabase
|
|
.from('whatsapp_messages')
|
|
.select('created_at')
|
|
.eq('direction', 'outbound')
|
|
.eq('sender_phone_hash', phoneHash)
|
|
.eq('raw_payload->>template', TEMPLATE.m1Unlinked)
|
|
.gte('created_at', since)
|
|
.order('created_at', { ascending: false })
|
|
.limit(GREETING_DAY_MAX)
|
|
if (error) {
|
|
log.warn('greeting throttle window unreadable; staying silent', { error: error.message })
|
|
return true
|
|
}
|
|
const rows = (data ?? []) as Array<{ created_at: string }>
|
|
if (rows.length >= GREETING_DAY_MAX) return true
|
|
const windowStart = Date.now() - (opts.media ? GREETING_BURST_MS : GREETING_HOUR_MS)
|
|
return rows.some((r) => new Date(r.created_at).getTime() > windowStart)
|
|
}
|
|
|
|
export const COMPANY_PIN_TTL_MS = 8 * 60 * 60 * 1000
|
|
export const QUESTION_TTL_MS = 48 * 60 * 60 * 1000
|
|
export const LATE_ANSWER_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
export const DEBOUNCE_WINDOW_MS = 12 * 1000
|
|
export const MAX_QUESTIONS_PER_BURST = 2
|
|
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. */
|
|
export const COMPANY_CHOICE_EXPIRED = 'company_choice_expired'
|
|
/** 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;
|
|
* M19 tells the sender why. */
|
|
export const NO_COMPANY_OPTIONS = 'no_company_options'
|
|
|
|
export type QuestionType = 'representation' | 'context' | 'resend'
|
|
export type ConversationQuestionType = QuestionType | 'company'
|
|
|
|
export interface PendingQuestion {
|
|
type: ConversationQuestionType
|
|
/** Null for company questions (no item exists before the answer). */
|
|
inbox_item_id: string | null
|
|
asked_at: string
|
|
}
|
|
|
|
export interface QueuedQuestion {
|
|
type: QuestionType
|
|
inbox_item_id: string
|
|
}
|
|
|
|
export interface RecentQuestion {
|
|
type: QuestionType
|
|
inbox_item_id: string
|
|
asked_at: string
|
|
status: 'open' | 'answered' | 'moved_to_app'
|
|
}
|
|
|
|
export interface ConversationContext {
|
|
pin_expires_at?: string
|
|
pin_source?: 'button' | 'list' | 'numbered'
|
|
company_options?: { id: string; name: string }[]
|
|
pending_question?: PendingQuestion
|
|
question_queue?: QueuedQuestion[]
|
|
recent_questions?: RecentQuestion[]
|
|
budget?: { day_key: string; count: number }
|
|
}
|
|
|
|
export function getContext(conversation: WhatsAppConversation): ConversationContext {
|
|
return (conversation.context ?? {}) as ConversationContext
|
|
}
|
|
|
|
/** Load (or lazily create) the conversation row for a phone link. */
|
|
export async function getOrCreateConversation(
|
|
supabase: SupabaseClient,
|
|
phoneLinkId: string,
|
|
): Promise<WhatsAppConversation | null> {
|
|
const { data: existing } = await supabase
|
|
.from('whatsapp_conversations')
|
|
.select('*')
|
|
.eq('phone_link_id', phoneLinkId)
|
|
.maybeSingle()
|
|
if (existing) return existing as WhatsAppConversation
|
|
const { data: created } = await supabase
|
|
.from('whatsapp_conversations')
|
|
.insert({ phone_link_id: phoneLinkId })
|
|
.select('*')
|
|
.maybeSingle()
|
|
return (created as WhatsAppConversation | null) ?? null
|
|
}
|
|
|
|
export async function loadConversation(
|
|
supabase: SupabaseClient,
|
|
conversationId: string,
|
|
): Promise<WhatsAppConversation | null> {
|
|
const { data } = await supabase
|
|
.from('whatsapp_conversations')
|
|
.select('*')
|
|
.eq('id', conversationId)
|
|
.maybeSingle()
|
|
return (data as WhatsAppConversation | null) ?? null
|
|
}
|
|
|
|
/** Fields a conversation write may touch. Anything omitted stays as it is. */
|
|
export interface ConversationPatch {
|
|
state?: WhatsAppConversation['state']
|
|
context?: ConversationContext
|
|
company_id?: string | null
|
|
last_outbound_at?: string
|
|
pending_ack?: boolean
|
|
debounce_until?: string
|
|
}
|
|
|
|
/**
|
|
* Optimistic-concurrency write of a conversation row.
|
|
*
|
|
* `context` is a whole-jsonb column written read-modify-write, and its writers
|
|
* hold different claims (the ack winner holds pending_ack, an answer worker
|
|
* holds its own row's processing_status, the sweep holds nothing), so a blind
|
|
* `.eq('id')` update silently clobbers whatever landed in between: resurrected
|
|
* questions, wiped pending_question, dropped queue entries.
|
|
*
|
|
* The updated_at trigger makes that column a revision counter, so guarding on
|
|
* the value the mutation was derived from turns every write into a
|
|
* compare-and-set. On a lost race the row is reloaded and `mutate` runs again
|
|
* against fresh state; returning null from it aborts (the work is already
|
|
* done, or no longer applies).
|
|
*/
|
|
export async function updateConversation(
|
|
supabase: SupabaseClient,
|
|
conversation: WhatsAppConversation,
|
|
mutate: (
|
|
current: WhatsAppConversation,
|
|
context: ConversationContext,
|
|
) => ConversationPatch | null,
|
|
maxAttempts = 4,
|
|
): Promise<WhatsAppConversation | null> {
|
|
let current: WhatsAppConversation | null = conversation
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
if (!current) return null
|
|
const patch = mutate(current, getContext(current))
|
|
if (!patch) return null
|
|
// Literal payload so the phantom-column scanner can verify the column
|
|
// names; undefined values drop out of the JSON body PostgREST receives.
|
|
const { data } = await supabase
|
|
.from('whatsapp_conversations')
|
|
.update({
|
|
state: patch.state,
|
|
context: patch.context as Record<string, unknown> | undefined,
|
|
company_id: patch.company_id,
|
|
last_outbound_at: patch.last_outbound_at,
|
|
pending_ack: patch.pending_ack,
|
|
debounce_until: patch.debounce_until,
|
|
})
|
|
.eq('id', current.id)
|
|
.eq('updated_at', current.updated_at)
|
|
.select('*')
|
|
// PostgREST returns [] only when the revision guard did not match.
|
|
if (!Array.isArray(data) || data.length > 0) {
|
|
const written = Array.isArray(data) ? (data[0] as WhatsAppConversation) : null
|
|
return written ?? ({ ...current, ...patch } as WhatsAppConversation)
|
|
}
|
|
current = await loadConversation(supabase, current.id)
|
|
}
|
|
log.warn('conversation write gave up after concurrent modifications', {
|
|
conversationId: conversation.id,
|
|
})
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Atomic burst-ack claim. Exactly one caller per debounce window gets a row
|
|
* back; everyone else stays silent. `debounce_until <= now` uses the caller's
|
|
* clock: harmless skew only shifts WHEN the winner fires, never how many win
|
|
* (pending_ack is the exclusivity bit).
|
|
*/
|
|
export async function claimAck(
|
|
supabase: SupabaseClient,
|
|
conversationId: string,
|
|
): Promise<boolean> {
|
|
const { data } = await supabase
|
|
.from('whatsapp_conversations')
|
|
.update({ pending_ack: false })
|
|
.eq('id', conversationId)
|
|
.eq('pending_ack', true)
|
|
.lte('debounce_until', new Date().toISOString())
|
|
.select('id')
|
|
return Array.isArray(data) && data.length > 0
|
|
}
|
|
|
|
/** Day key for the daily question budget, in the sender's civil day. */
|
|
export function stockholmDayKey(date: Date = new Date()): string {
|
|
return new Intl.DateTimeFormat('sv-SE', {
|
|
timeZone: 'Europe/Stockholm',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).format(date)
|
|
}
|
|
|
|
/** Content questions already asked today (rolls over at Stockholm midnight). */
|
|
export function questionsAskedToday(context: ConversationContext, now: Date = new Date()): number {
|
|
const budget = context.budget
|
|
if (!budget || budget.day_key !== stockholmDayKey(now)) return 0
|
|
return budget.count
|
|
}
|
|
|
|
export function bumpBudget(
|
|
context: ConversationContext,
|
|
asked: number,
|
|
now: Date = new Date(),
|
|
): ConversationContext['budget'] {
|
|
const dayKey = stockholmDayKey(now)
|
|
const current = context.budget?.day_key === dayKey ? context.budget.count : 0
|
|
// Negative `asked` refunds a question whose send failed; never below zero.
|
|
return { day_key: dayKey, count: Math.max(0, current + asked) }
|
|
}
|
|
|
|
/** True when the company pin on the conversation is present and unexpired. */
|
|
export function hasLivePin(
|
|
conversation: WhatsAppConversation,
|
|
now: Date = new Date(),
|
|
): boolean {
|
|
if (!conversation.company_id) return false
|
|
const expiresAt = getContext(conversation).pin_expires_at
|
|
if (!expiresAt) return false
|
|
return new Date(expiresAt).getTime() > now.getTime()
|
|
}
|
|
|
|
/** True when the 24h service window is open (we may send free-form replies). */
|
|
export function serviceWindowOpen(
|
|
conversation: WhatsAppConversation,
|
|
now: Date = new Date(),
|
|
): boolean {
|
|
const expiresAt = conversation.service_window_expires_at
|
|
return expiresAt != null && new Date(expiresAt).getTime() > now.getTime()
|
|
}
|
|
|
|
/** Question types a free-text reply can answer. Resend needs media. */
|
|
export type TextAnswerableQuestionType = Extract<QuestionType, 'representation' | 'context'>
|
|
|
|
export interface AnswerTarget {
|
|
type: TextAnswerableQuestionType
|
|
inboxItemId: string
|
|
/** True when matched outside an awaiting_* state (quoted or recent). */
|
|
late: boolean
|
|
/** The quoted receipt's question was already answered: this is a follow-up
|
|
* correction, appended to the note instead of overwriting the answer. */
|
|
followUp?: boolean
|
|
}
|
|
|
|
const TEXT_ANSWERABLE: ReadonlySet<string> = new Set(['representation', 'context'])
|
|
|
|
function isTextAnswerable(
|
|
question: RecentQuestion,
|
|
): question is RecentQuestion & { type: TextAnswerableQuestionType } {
|
|
return TEXT_ANSWERABLE.has(question.type)
|
|
}
|
|
|
|
/**
|
|
* Resolve which question a free-text message answers.
|
|
*
|
|
* 1. A quoted reply (context.message_id -> whatsapp_messages.wamid, either
|
|
* direction) names its receipt, so it wins over everything else, including
|
|
* an unrelated pending question and including a question that receipt has
|
|
* already answered (the reply is then a follow-up correction). Binding a
|
|
* quoted correction to some OTHER receipt because the quoted one was
|
|
* settled is how the wrong item gets the note.
|
|
* 2. Otherwise an awaiting_representation/awaiting_context state answers its
|
|
* own pending question.
|
|
* 3. Otherwise the single most recent question asked within 7 days that has
|
|
* not been answered yet (moved_to_app still accepts a late answer).
|
|
*/
|
|
export async function resolveAnswerTarget(
|
|
supabase: SupabaseClient,
|
|
conversation: WhatsAppConversation,
|
|
quotedWamid: string | null,
|
|
now: Date = new Date(),
|
|
): Promise<AnswerTarget | null> {
|
|
const context = getContext(conversation)
|
|
|
|
const pending =
|
|
(conversation.state === 'awaiting_representation' ||
|
|
conversation.state === 'awaiting_context') &&
|
|
context.pending_question &&
|
|
TEXT_ANSWERABLE.has(context.pending_question.type) &&
|
|
context.pending_question.inbox_item_id
|
|
? {
|
|
type: context.pending_question.type as TextAnswerableQuestionType,
|
|
inboxItemId: context.pending_question.inbox_item_id,
|
|
}
|
|
: null
|
|
|
|
const withinWindow = (question: RecentQuestion): boolean =>
|
|
now.getTime() - new Date(question.asked_at).getTime() <= LATE_ANSWER_MAX_AGE_MS
|
|
|
|
if (quotedWamid) {
|
|
const { data: quotedRow } = await supabase
|
|
.from('whatsapp_messages')
|
|
.select('inbox_item_id')
|
|
.eq('wamid', quotedWamid)
|
|
.not('inbox_item_id', 'is', null)
|
|
.limit(1)
|
|
.maybeSingle()
|
|
const quotedItemId = (quotedRow as { inbox_item_id: string | null } | null)?.inbox_item_id
|
|
if (quotedItemId) {
|
|
// Quoting the very question that is open: an ordinary answer, not late.
|
|
if (pending && pending.inboxItemId === quotedItemId) {
|
|
return { ...pending, late: false }
|
|
}
|
|
const quoted = [...(context.recent_questions ?? [])]
|
|
.filter((q) => isTextAnswerable(q) && withinWindow(q) && q.inbox_item_id === quotedItemId)
|
|
.sort((a, b) => new Date(b.asked_at).getTime() - new Date(a.asked_at).getTime())[0]
|
|
if (quoted && isTextAnswerable(quoted)) {
|
|
return {
|
|
type: quoted.type,
|
|
inboxItemId: quoted.inbox_item_id,
|
|
late: true,
|
|
followUp: quoted.status === 'answered',
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pending) return { ...pending, late: false }
|
|
|
|
const recent = (context.recent_questions ?? []).filter(
|
|
(q): q is RecentQuestion & { type: TextAnswerableQuestionType } =>
|
|
isTextAnswerable(q) && q.status !== 'answered' && withinWindow(q),
|
|
)
|
|
|
|
if (recent.length > 0) {
|
|
const latest = [...recent].sort(
|
|
(a, b) => new Date(b.asked_at).getTime() - new Date(a.asked_at).getTime(),
|
|
)[0]
|
|
return { type: latest.type, inboxItemId: latest.inbox_item_id, late: true }
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/** Mark one recent-question entry with a new status (pure helper). */
|
|
export function markRecentQuestion(
|
|
context: ConversationContext,
|
|
inboxItemId: string,
|
|
status: RecentQuestion['status'],
|
|
): RecentQuestion[] {
|
|
return (context.recent_questions ?? []).map((q) =>
|
|
q.inbox_item_id === inboxItemId ? { ...q, status } : q,
|
|
)
|
|
}
|
|
|
|
/** Append an asked question to the recent log, pruning entries beyond 7 days
|
|
* and capping the log at 10 entries. */
|
|
export function appendRecentQuestion(
|
|
context: ConversationContext,
|
|
entry: RecentQuestion,
|
|
now: Date = new Date(),
|
|
): RecentQuestion[] {
|
|
const kept = (context.recent_questions ?? []).filter(
|
|
(q) => now.getTime() - new Date(q.asked_at).getTime() <= LATE_ANSWER_MAX_AGE_MS,
|
|
)
|
|
return [...kept, entry].slice(-10)
|
|
}
|
|
|
|
/**
|
|
* Legacy recipient read: rows persisted before the raw payload was redacted
|
|
* still carry the sender's plaintext number under `from`. New rows do not,
|
|
* so this returns null for them and the caller decrypts the phone link.
|
|
*/
|
|
export function extractRecipient(row: WhatsAppMessage): string | null {
|
|
const raw = row.raw_payload as { from?: unknown } | null
|
|
return raw && typeof raw.from === 'string' && raw.from.length > 0 ? raw.from : null
|
|
}
|
|
|
|
/**
|
|
* The recipient phone (E.164 digits) for replies.
|
|
*
|
|
* The authoritative copy is whatsapp_phone_links.phone_enc (AES-256-GCM):
|
|
* persisting the plaintext number in every message's raw_payload defeated the
|
|
* whole point of encrypting it once on the link. Falls back to the legacy
|
|
* raw_payload copy for rows written before the redaction.
|
|
*/
|
|
export function resolveRecipient(
|
|
row: WhatsAppMessage,
|
|
link: { phone_enc?: string | null } | null,
|
|
): string | null {
|
|
const legacy = extractRecipient(row)
|
|
if (legacy) return legacy
|
|
if (!link?.phone_enc) return null
|
|
try {
|
|
const phone = decryptPhone(link.phone_enc)
|
|
return phone.length > 0 ? phone : null
|
|
} catch (err) {
|
|
// Shredded (retention/erasure sets phone_enc = '') or a key rotation:
|
|
// there is no one left to reply to, and that must not throw here.
|
|
log.warn('could not decrypt phone link for reply', {
|
|
error: err instanceof Error ? err.message : String(err),
|
|
})
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Strip the sender's plaintext phone number out of the payload we persist.
|
|
* Everything else (type, timestamp, quoted context.id) is kept verbatim. */
|
|
export function redactRawPayload(raw: unknown): Record<string, unknown> | null {
|
|
if (!raw || typeof raw !== 'object') return null
|
|
const { from: _from, ...rest } = raw as Record<string, unknown>
|
|
return rest
|
|
}
|