feat(db): WhatsApp channel schema (phone links, messages, inbox source) (#1337)

Foundation for WhatsApp receipt intake (plan 2026-08-01), no runtime
callers yet:

- whatsapp_phone_links + whatsapp_link_codes: verified phone -> user
  binding via single-use 10-min codes (invite-token pattern). Peppered
  HMAC lookup hash + AES-GCM encrypted number; one ACTIVE link per
  phone and per user (partial unique, revocation preserves history).
  User-scoped RLS (a binding belongs to a person, not a company).
- whatsapp_conversations + whatsapp_messages: deterministic state
  machine state and the message log, which doubles as the durable job
  record for persist-first webhook processing. Partial unique index on
  inbound wamid = the at-least-once dedupe key. Service-role only.
- whatsapp_sender_rate_counters + check_and_increment_whatsapp_sender_quota:
  the pre-binding limiter keyed by phone hash; EXECUTE granted to
  service_role only.
- invoice_inbox_items: source CHECK widened to 'whatsapp', plus
  whatsapp_message_id (one item per delivering message) and
  channel_context jsonb, kept separate from extracted_data so verified
  human answers never share a container with untrusted OCR output.
  document_attachments.upload_source CHECK gains 'whatsapp'.
- whatsapp_conversations triaged into ARCHIVE_EXCLUDED_TABLES
  (full-archive coverage contract).

pg-real on a fresh DB: 975/975 incl. the new whatsapp-channel suite
(RLS visibility, unique/rebinding semantics, wamid dedupe, CHECK
widenings, quota RPC caps + grant lockdown).

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-05 14:36:00 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 02e48efe11
commit bd3592cb99
7 changed files with 721 additions and 1 deletions
+105 -1
View File
@@ -2652,7 +2652,7 @@ export interface SIEAccountMapping {
// ============================================================
export type InboxItemStatus = 'received' | 'error'
export type InboxItemSource = 'email' | 'upload'
export type InboxItemSource = 'email' | 'upload' | 'whatsapp'
export type CompanyInboxStatus = 'active' | 'deprecated' | 'blocked'
@@ -2715,6 +2715,13 @@ export interface InvoiceInboxItem {
error_message: string | null
raw_email_payload: Record<string, unknown> | null
// WhatsApp channel (migration 20260802092000). whatsapp_message_id links
// back to the delivering chat message; channel_context holds verified
// human answers from the chat (kept OUT of extracted_data on purpose:
// retry-extraction overwrites that container wholesale).
whatsapp_message_id?: string | null
channel_context?: InboxChannelContext | null
// Audit chain (processing_history correlation)
correlation_id: string | null
@@ -2727,6 +2734,102 @@ export interface InvoiceInboxItem {
supplier_invoice?: SupplierInvoice
}
// Chat-sourced context attached to an inbox item. `raw_answer` + timestamps
// double as the Skatteverket representation documentation trail.
export interface InboxChannelContext {
channel: 'whatsapp'
caption?: string | null
company_selected_via?: 'button' | 'pin' | 'default' | 'single'
representation?: {
participants: { name: string; company: string | null }[]
purpose: string | null
event_date: string | null
raw_answer: string
answered_at: string
}
user_note?: string | null
quality?: { resend_requested_at: string; resent: boolean }
pending_question?: {
type: 'representation' | 'context' | 'resend'
asked_at: string
status: 'open' | 'answered' | 'moved_to_app'
}
}
// ============================================================
// WhatsApp Channel Types (migrations 20260802090000/091000)
// ============================================================
export interface WhatsAppPhoneLink {
id: string
user_id: string
phone_hash: string
phone_enc: string
phone_masked: string
wa_profile_name: string | null
default_company_id: string | null
last_company_id: string | null
verified_at: string
revoked_at: string | null
muted_at: string | null
last_message_at: string | null
created_at: string
updated_at: string
}
export type WhatsAppConversationState =
| 'idle'
| 'awaiting_company'
| 'awaiting_representation'
| 'awaiting_context'
| 'awaiting_resend'
export interface WhatsAppConversation {
id: string
phone_link_id: string
state: WhatsAppConversationState
context: Record<string, unknown>
company_id: string | null
service_window_expires_at: string | null
debounce_until: string | null
pending_ack: boolean
last_inbound_at: string | null
last_outbound_at: string | null
created_at: string
updated_at: string
}
export type WhatsAppMessageProcessingStatus =
| 'received'
| 'processing'
| 'done'
| 'skipped'
| 'error'
export interface WhatsAppMessage {
id: string
direction: 'inbound' | 'outbound'
wamid: string | null
sender_phone_hash: string | null
phone_link_id: string | null
conversation_id: string | null
message_type: string
body_text: string | null
media_id: string | null
media_mime: string | null
media_sha256: string | null
media_filename: string | null
raw_payload: Record<string, unknown> | null
processing_status: WhatsAppMessageProcessingStatus
attempts: number
error_message: string | null
inbox_item_id: string | null
delivery_status: string | null
correlation_id: string | null
created_at: string
updated_at: string
}
// ============================================================
// Receipt Types
// ============================================================
@@ -3140,6 +3243,7 @@ export type DocumentUploadSource =
| 'scan'
| 'api'
| 'system'
| 'whatsapp'
export interface DocumentAttachment {
id: string