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>
91 lines
3.8 KiB
SQL
91 lines
3.8 KiB
SQL
-- 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';
|