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>
192 lines
8.5 KiB
PL/PgSQL
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';
|