Files
accounted/supabase/migrations/20260802091000_whatsapp_messages.sql
T
bd3592cb99 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>
2026-08-05 14:36:00 +02:00

192 lines
8.5 KiB
PL/PgSQL

-- 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';