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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
02e48efe11
commit
bd3592cb99
@@ -0,0 +1,90 @@
|
||||
-- WhatsApp channel, part 1/3: phone identity.
|
||||
--
|
||||
-- One shared Accounted WhatsApp number serves every tenant, so an inbound
|
||||
-- message identifies its sender only by phone number. These tables bind a
|
||||
-- verified phone to an auth user (whatsapp_phone_links) via a one-time code
|
||||
-- the user sends TO the number (whatsapp_link_codes, invite-token pattern:
|
||||
-- raw code exists only in the user's chat, sha256 hex stored).
|
||||
--
|
||||
-- whatsapp_phone_links is USER-scoped, not company-scoped (like
|
||||
-- user_preferences): a link belongs to a person; which company a receipt
|
||||
-- lands in is resolved per message. Phone PII: phone_hash is an
|
||||
-- HMAC-SHA256 with a server-side pepper (plain sha256 is brute-forceable
|
||||
-- over the ~10^9 phone number space), phone_enc is AES-256-GCM
|
||||
-- (lib/auth/bankid.ts codec), phone_masked is display-only.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.whatsapp_phone_links (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
|
||||
phone_hash text NOT NULL,
|
||||
phone_enc text NOT NULL,
|
||||
phone_masked text NOT NULL,
|
||||
wa_profile_name text,
|
||||
|
||||
-- Which company receipts land in when the sender belongs to several and
|
||||
-- the conversation has no fresher pin. Both nullable on purpose.
|
||||
default_company_id uuid REFERENCES public.companies(id) ON DELETE SET NULL,
|
||||
last_company_id uuid REFERENCES public.companies(id) ON DELETE SET NULL,
|
||||
|
||||
verified_at timestamptz NOT NULL DEFAULT now(),
|
||||
revoked_at timestamptz,
|
||||
-- STOP keyword: bot goes silent but the binding survives (start re-opens).
|
||||
muted_at timestamptz,
|
||||
last_message_at timestamptz,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- One ACTIVE link per phone and per user; revoked rows stay as history.
|
||||
CREATE UNIQUE INDEX whatsapp_phone_links_phone_active
|
||||
ON public.whatsapp_phone_links (phone_hash) WHERE revoked_at IS NULL;
|
||||
CREATE UNIQUE INDEX whatsapp_phone_links_user_active
|
||||
ON public.whatsapp_phone_links (user_id) WHERE revoked_at IS NULL;
|
||||
|
||||
ALTER TABLE public.whatsapp_phone_links ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- The user sees and manages their own binding (settings panel: status,
|
||||
-- default company, revoke). INSERT is service-role only: a binding is
|
||||
-- created exclusively by the webhook after code verification. No DELETE:
|
||||
-- revocation (revoked_at) keeps the trail auditable.
|
||||
CREATE POLICY "whatsapp_phone_links_select_own"
|
||||
ON public.whatsapp_phone_links
|
||||
FOR SELECT
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
CREATE POLICY "whatsapp_phone_links_update_own"
|
||||
ON public.whatsapp_phone_links
|
||||
FOR UPDATE
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
CREATE TRIGGER whatsapp_phone_links_updated_at
|
||||
BEFORE UPDATE ON public.whatsapp_phone_links
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
-- One-time link codes (10-minute TTL, single use). Raw code never stored.
|
||||
CREATE TABLE IF NOT EXISTS public.whatsapp_link_codes (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
code_hash text NOT NULL UNIQUE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
used_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_whatsapp_link_codes_user
|
||||
ON public.whatsapp_link_codes (user_id);
|
||||
|
||||
ALTER TABLE public.whatsapp_link_codes ENABLE ROW LEVEL SECURITY;
|
||||
-- Deliberately NO policies: minting happens in the authenticated route via
|
||||
-- the service client, resolution happens in the webhook via the service
|
||||
-- client. Nothing user-facing ever reads a code hash.
|
||||
|
||||
CREATE TRIGGER whatsapp_link_codes_updated_at
|
||||
BEFORE UPDATE ON public.whatsapp_link_codes
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,191 @@
|
||||
-- WhatsApp channel, part 2/3: conversations, message log, sender quota.
|
||||
--
|
||||
-- whatsapp_messages doubles as the durable job record for inbound
|
||||
-- processing (persist-first webhook: insert row, ack 200, process via
|
||||
-- after(), per-minute sweep cron re-claims stuck rows). The partial unique
|
||||
-- index on inbound wamid is THE idempotency key: Meta redelivers with
|
||||
-- backoff for up to 7 days, so duplicates are normal operation.
|
||||
--
|
||||
-- All three tables are service-role only (RLS enabled, no policies):
|
||||
-- rows contain third-party PII (phone hashes, chat text) with no company
|
||||
-- scope, and no UI reads them directly in v1. A retention cron purges
|
||||
-- body_text/raw_payload after 90 days and unknown-sender rows after 30.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.whatsapp_conversations (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
phone_link_id uuid NOT NULL REFERENCES public.whatsapp_phone_links(id) ON DELETE CASCADE,
|
||||
|
||||
-- Deterministic state machine (LLM-last). unlinked/muted are NOT states:
|
||||
-- unlinked = no whatsapp_phone_links row, muted = phone_links.muted_at.
|
||||
state text NOT NULL DEFAULT 'idle'
|
||||
CHECK (state IN ('idle','awaiting_company','awaiting_representation','awaiting_context','awaiting_resend')),
|
||||
-- Pending question payload, staged media refs, question budget counters.
|
||||
context jsonb NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Company pinned by an in-chat choice (8h sliding TTL enforced in code).
|
||||
company_id uuid REFERENCES public.companies(id) ON DELETE SET NULL,
|
||||
|
||||
-- last inbound + 24h: outside it the bot must not send (no templates in v1).
|
||||
service_window_expires_at timestamptz,
|
||||
-- Image-burst debounce: each media message pushes this forward; the
|
||||
-- invocation whose deadline survives claims the combined ack via
|
||||
-- UPDATE ... WHERE pending_ack AND debounce_until <= now().
|
||||
debounce_until timestamptz,
|
||||
pending_ack boolean NOT NULL DEFAULT false,
|
||||
|
||||
last_inbound_at timestamptz,
|
||||
last_outbound_at timestamptz,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX whatsapp_conversations_link
|
||||
ON public.whatsapp_conversations (phone_link_id);
|
||||
|
||||
ALTER TABLE public.whatsapp_conversations ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE TRIGGER whatsapp_conversations_updated_at
|
||||
BEFORE UPDATE ON public.whatsapp_conversations
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.whatsapp_messages (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
direction text NOT NULL CHECK (direction IN ('inbound','outbound')),
|
||||
-- Meta's message id. Outbound rows get theirs from the send response.
|
||||
wamid text,
|
||||
-- HMAC phone hash, set even for unknown senders (pre-binding rate limit
|
||||
-- + greeting throttle key). Never the raw number.
|
||||
sender_phone_hash text,
|
||||
phone_link_id uuid REFERENCES public.whatsapp_phone_links(id) ON DELETE SET NULL,
|
||||
conversation_id uuid REFERENCES public.whatsapp_conversations(id) ON DELETE SET NULL,
|
||||
|
||||
message_type text NOT NULL,
|
||||
body_text text,
|
||||
media_id text,
|
||||
media_mime text,
|
||||
media_sha256 text,
|
||||
media_filename text,
|
||||
-- value.messages[i] verbatim for known senders; NULL for unknown senders
|
||||
-- (no content retention pre-binding). Purged by the retention cron.
|
||||
raw_payload jsonb,
|
||||
|
||||
-- The durable-job half: received -> processing -> done|skipped|error.
|
||||
processing_status text NOT NULL DEFAULT 'received'
|
||||
CHECK (processing_status IN ('received','processing','done','skipped','error')),
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
error_message text,
|
||||
|
||||
-- Set once intake created the Underlag row (quoted-reply resolution:
|
||||
-- a reply quoting an ack maps back to its receipt through this).
|
||||
inbox_item_id uuid REFERENCES public.invoice_inbox_items(id) ON DELETE SET NULL,
|
||||
-- Outbound delivery lifecycle from statuses[] webhooks.
|
||||
delivery_status text,
|
||||
correlation_id uuid,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Idempotency: at-least-once delivery dedupes here (inbound only; outbound
|
||||
-- wamids are ours and unique by construction, but stay unconstrained since
|
||||
-- a failed send may never get one).
|
||||
CREATE UNIQUE INDEX whatsapp_messages_inbound_wamid
|
||||
ON public.whatsapp_messages (wamid)
|
||||
WHERE wamid IS NOT NULL AND direction = 'inbound';
|
||||
|
||||
-- Sweep cron: claim received rows and processing rows stuck >90s.
|
||||
CREATE INDEX whatsapp_messages_sweep
|
||||
ON public.whatsapp_messages (processing_status, created_at)
|
||||
WHERE processing_status IN ('received','processing');
|
||||
|
||||
CREATE INDEX whatsapp_messages_sender
|
||||
ON public.whatsapp_messages (sender_phone_hash, created_at);
|
||||
|
||||
CREATE INDEX whatsapp_messages_conversation
|
||||
ON public.whatsapp_messages (conversation_id, created_at);
|
||||
|
||||
ALTER TABLE public.whatsapp_messages ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE TRIGGER whatsapp_messages_updated_at
|
||||
BEFORE UPDATE ON public.whatsapp_messages
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
-- Pre-binding sender quota: the missing limiter keyed by phone hash (the
|
||||
-- per-company inbox quota can't apply before a sender resolves to a
|
||||
-- company). Clone of check_and_increment_inbox_quota (20260512083712).
|
||||
CREATE TABLE IF NOT EXISTS public.whatsapp_sender_rate_counters (
|
||||
phone_hash text NOT NULL,
|
||||
window_kind text NOT NULL CHECK (window_kind IN ('minute','day')),
|
||||
window_key text NOT NULL,
|
||||
count integer NOT NULL DEFAULT 0,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (phone_hash, window_kind, window_key)
|
||||
);
|
||||
|
||||
ALTER TABLE public.whatsapp_sender_rate_counters ENABLE ROW LEVEL SECURITY;
|
||||
-- No user-facing policies: only the SECURITY DEFINER fn below writes this.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.check_and_increment_whatsapp_sender_quota(
|
||||
p_phone_hash text,
|
||||
p_minute_max integer,
|
||||
p_day_max integer
|
||||
) RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_minute_key text := to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI');
|
||||
v_day_key text := to_char(now() AT TIME ZONE 'Europe/Stockholm', 'YYYY-MM-DD');
|
||||
v_minute_count integer;
|
||||
v_day_count integer;
|
||||
BEGIN
|
||||
INSERT INTO public.whatsapp_sender_rate_counters (phone_hash, window_kind, window_key, count)
|
||||
VALUES (p_phone_hash, 'minute', v_minute_key, 1)
|
||||
ON CONFLICT (phone_hash, window_kind, window_key)
|
||||
DO UPDATE SET count = whatsapp_sender_rate_counters.count + 1, updated_at = now()
|
||||
RETURNING count INTO v_minute_count;
|
||||
|
||||
IF v_minute_count > p_minute_max THEN
|
||||
UPDATE public.whatsapp_sender_rate_counters
|
||||
SET count = count - 1
|
||||
WHERE phone_hash = p_phone_hash
|
||||
AND window_kind = 'minute'
|
||||
AND window_key = v_minute_key;
|
||||
RETURN jsonb_build_object('ok', false, 'scope', 'minute', 'retry_after_sec', 60);
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.whatsapp_sender_rate_counters (phone_hash, window_kind, window_key, count)
|
||||
VALUES (p_phone_hash, 'day', v_day_key, 1)
|
||||
ON CONFLICT (phone_hash, window_kind, window_key)
|
||||
DO UPDATE SET count = whatsapp_sender_rate_counters.count + 1, updated_at = now()
|
||||
RETURNING count INTO v_day_count;
|
||||
|
||||
IF v_day_count > p_day_max THEN
|
||||
UPDATE public.whatsapp_sender_rate_counters
|
||||
SET count = count - 1
|
||||
WHERE phone_hash = p_phone_hash
|
||||
AND window_kind = 'day'
|
||||
AND window_key = v_day_key;
|
||||
UPDATE public.whatsapp_sender_rate_counters
|
||||
SET count = count - 1
|
||||
WHERE phone_hash = p_phone_hash
|
||||
AND window_kind = 'minute'
|
||||
AND window_key = v_minute_key;
|
||||
RETURN jsonb_build_object('ok', false, 'scope', 'day', 'retry_after_sec', 3600);
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('ok', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Only the webhook path (service role) calls this. A SECURITY DEFINER fn
|
||||
-- reachable from anon/authenticated would be a quota-drain primitive
|
||||
-- (lesson from check_and_increment_agent_quota, migration 20260726090000).
|
||||
REVOKE ALL ON FUNCTION public.check_and_increment_whatsapp_sender_quota(text, integer, integer) FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION public.check_and_increment_whatsapp_sender_quota(text, integer, integer) FROM anon;
|
||||
REVOKE ALL ON FUNCTION public.check_and_increment_whatsapp_sender_quota(text, integer, integer) FROM authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.check_and_increment_whatsapp_sender_quota(text, integer, integer) TO service_role;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,43 @@
|
||||
-- WhatsApp channel, part 3/3: widen the inbox to the new source.
|
||||
--
|
||||
-- invoice_inbox_items.source and document_attachments.upload_source were
|
||||
-- created as inline column CHECKs, so their constraint names are the
|
||||
-- Postgres auto-generated <table>_<column>_check (verified: no later
|
||||
-- migration re-created either). NOT VALID -> VALIDATE keeps the ADD cheap
|
||||
-- on the large tables; the pg-real suite (whatsapp-tables.pg.test.ts)
|
||||
-- asserts a 'whatsapp' insert succeeds, which fails loudly if the drop
|
||||
-- missed a differently-named constraint and the old CHECK survived.
|
||||
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
DROP CONSTRAINT IF EXISTS invoice_inbox_items_source_check;
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
ADD CONSTRAINT invoice_inbox_items_source_check
|
||||
CHECK (source IN ('email','upload','whatsapp')) NOT VALID;
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
VALIDATE CONSTRAINT invoice_inbox_items_source_check;
|
||||
|
||||
-- Provenance link back to the chat message that delivered the file
|
||||
-- (quoted-reply resolution + idempotency at the item level), and the
|
||||
-- chat-context container for clarifying-question answers. channel_context
|
||||
-- is deliberately SEPARATE from extracted_data: retry-extraction
|
||||
-- overwrites extracted_data wholesale, and verified human answers must
|
||||
-- never share a container with untrusted OCR output.
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
ADD COLUMN IF NOT EXISTS whatsapp_message_id uuid REFERENCES public.whatsapp_messages(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS channel_context jsonb;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS invoice_inbox_items_whatsapp_msg
|
||||
ON public.invoice_inbox_items (whatsapp_message_id)
|
||||
WHERE whatsapp_message_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE public.document_attachments
|
||||
DROP CONSTRAINT IF EXISTS document_attachments_upload_source_check;
|
||||
ALTER TABLE public.document_attachments
|
||||
ADD CONSTRAINT document_attachments_upload_source_check
|
||||
CHECK (upload_source IN (
|
||||
'camera', 'file_upload', 'email', 'e_invoice', 'scan', 'api', 'system', 'whatsapp'
|
||||
)) NOT VALID;
|
||||
ALTER TABLE public.document_attachments
|
||||
VALIDATE CONSTRAINT document_attachments_upload_source_check;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user