From bd3592cb992f347fb0ff8057d6216840c0f052db Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 5 Aug 2026 14:36:00 +0200 Subject: [PATCH] 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 Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + lib/reports/full-archive-export.ts | 2 + .../20260802090000_whatsapp_identity.sql | 90 ++++++ .../20260802091000_whatsapp_messages.sql | 191 ++++++++++++ .../20260802092000_inbox_whatsapp_source.sql | 43 +++ tests/pg/whatsapp-channel.pg.test.ts | 289 ++++++++++++++++++ types/index.ts | 106 ++++++- 7 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/20260802090000_whatsapp_identity.sql create mode 100644 supabase/migrations/20260802091000_whatsapp_messages.sql create mode 100644 supabase/migrations/20260802092000_inbox_whatsapp_source.sql create mode 100644 tests/pg/whatsapp-channel.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index a0520975..ce942305 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -772,3 +772,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-01] OCR quick-fix "bounded retry on transient Bedrock errors" dropped: @anthropic-ai/bedrock-sdk already retries 429/5xx twice by default (maxRetries=2, not overridden in extract-invoice-fields.ts); duplicating it would triple worst-case latency for no reliability gain. [2026-08-01] Page-count gate (issue #553) changed from skip-extraction to slice-first-3-pages via pdf-lib: invoice data sits on page 1, and a >3-page supplier invoice getting ZERO fields was the worse failure mode. too_many_pages skip remains only for unsliceable (encrypted/malformed) PDFs; truncation recorded in extracted_data.pages. Side effect: client_opt_out now outranks too_many_pages in skip-reason priority (an opted-out caller never extracts regardless of length). [2026-08-01] Extraction classification fields (documentKind/payment/merchantCategory/legibility) validate with .catch(null) instead of strict enums: a hallucinated label must degrade to unknown, not sink the whole document parse; amounts keep strict parsing on purpose. HEIC handling = attempt sharp transcode at runtime, fall through to today's empty-extraction when libvips lacks HEIF (prebuilt binaries exclude it for patent reasons), with a UI hint replacing the silence. +[2026-08-02] WhatsApp channel tables: whatsapp_messages/conversations/link_codes are RLS-enabled with NO policies (service-role only): rows hold third-party PII (phone hashes, chat text) with no company scope and no v1 UI reader; whatsapp_phone_links is USER-scoped (auth.uid()), not company-scoped, because a phone binding belongs to a person. whatsapp_conversations triaged into ARCHIVE_EXCLUDED_TABLES (bot state, company_id is only a pin; receipts live in document_attachments). check_and_increment_whatsapp_sender_quota is EXECUTE-granted to service_role only (quota-drain lesson from 20260726090000). diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 70ca2015..01ef03d8 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -978,6 +978,8 @@ export const ARCHIVE_EXCLUDED_TABLES: Record = { stripe_payment_events: 'mirror of Stripe data, re-fetchable at source', stripe_payouts: 'mirror of Stripe data, re-fetchable at source', webhook_deliveries: 'automation delivery log', + whatsapp_conversations: + 'WhatsApp bot conversation state (company_id is only a which-company pin); receipts live in document_attachments', webhooks: 'automation config with signing secrets', } diff --git a/supabase/migrations/20260802090000_whatsapp_identity.sql b/supabase/migrations/20260802090000_whatsapp_identity.sql new file mode 100644 index 00000000..90012516 --- /dev/null +++ b/supabase/migrations/20260802090000_whatsapp_identity.sql @@ -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'; diff --git a/supabase/migrations/20260802091000_whatsapp_messages.sql b/supabase/migrations/20260802091000_whatsapp_messages.sql new file mode 100644 index 00000000..a800a0e3 --- /dev/null +++ b/supabase/migrations/20260802091000_whatsapp_messages.sql @@ -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'; diff --git a/supabase/migrations/20260802092000_inbox_whatsapp_source.sql b/supabase/migrations/20260802092000_inbox_whatsapp_source.sql new file mode 100644 index 00000000..63aba4dc --- /dev/null +++ b/supabase/migrations/20260802092000_inbox_whatsapp_source.sql @@ -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 __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'; diff --git a/tests/pg/whatsapp-channel.pg.test.ts b/tests/pg/whatsapp-channel.pg.test.ts new file mode 100644 index 00000000..b8aa2a81 --- /dev/null +++ b/tests/pg/whatsapp-channel.pg.test.ts @@ -0,0 +1,289 @@ +import { randomUUID, createHash } from 'node:crypto' +import { describe, it, expect, beforeAll } from 'vitest' +import { getPool, withUserContext } from './setup' +import { seedCompany, insertAuthUser } from './fixtures' + +// Migrations under test: 20260802090000 (phone links + link codes), +// 20260802091000 (conversations + messages + sender quota RPC), +// 20260802092000 (inbox source CHECK widening + channel_context). + +function hash(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + +async function insertPhoneLink(params: { + userId: string + phoneHash?: string + revokedAt?: string | null +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.whatsapp_phone_links + (id, user_id, phone_hash, phone_enc, phone_masked, revoked_at) + VALUES ($1, $2, $3, '\\x00', '+46 70 *** ** 00', $4)`, + [id, params.userId, params.phoneHash ?? hash(randomUUID()), params.revokedAt ?? null], + ) + return id +} + +describe('whatsapp_phone_links', () => { + let userA: string + let userB: string + let linkA: string + + beforeAll(async () => { + userA = await insertAuthUser() + userB = await insertAuthUser() + linkA = await insertPhoneLink({ userId: userA }) + await insertPhoneLink({ userId: userB }) + }) + + it('lets a user read only their own link', async () => { + const rows = await withUserContext(userA, async (client) => { + const res = await client.query(`SELECT id, user_id FROM public.whatsapp_phone_links`) + return res.rows + }) + expect(rows).toHaveLength(1) + expect(rows[0].id).toBe(linkA) + }) + + it('lets a user update their own link but blocks INSERT (service-role only)', async () => { + await withUserContext(userA, async (client) => { + const upd = await client.query( + `UPDATE public.whatsapp_phone_links SET muted_at = now() WHERE id = $1 RETURNING id`, + [linkA], + ) + expect(upd.rows).toHaveLength(1) + }) + + await expect( + withUserContext(userA, async (client) => { + await client.query( + `INSERT INTO public.whatsapp_phone_links + (user_id, phone_hash, phone_enc, phone_masked) + VALUES ($1, $2, '\\x00', '+46 70 *** ** 11')`, + [userA, hash('self-insert')], + ) + }), + ).rejects.toThrow(/row-level security/) + }) + + it('cannot update another user’s link', async () => { + const updated = await withUserContext(userB, async (client) => { + const res = await client.query( + `UPDATE public.whatsapp_phone_links SET muted_at = now() WHERE id = $1 RETURNING id`, + [linkA], + ) + return res.rows + }) + expect(updated).toHaveLength(0) + }) + + it('enforces one ACTIVE link per phone, allowing rebinding after revocation', async () => { + // Unique per run: pool inserts commit, so a fixed hash would collide + // with rows left behind by a previous suite run against the same DB. + const phoneHash = hash(`shared-phone-${randomUUID()}`) + const owner1 = await insertAuthUser() + const owner2 = await insertAuthUser() + await insertPhoneLink({ userId: owner1, phoneHash }) + + await expect(insertPhoneLink({ userId: owner2, phoneHash })).rejects.toThrow( + /whatsapp_phone_links_phone_active/, + ) + + await getPool().query( + `UPDATE public.whatsapp_phone_links SET revoked_at = now() WHERE phone_hash = $1`, + [phoneHash], + ) + await expect(insertPhoneLink({ userId: owner2, phoneHash })).resolves.toBeTruthy() + }) + + it('enforces one ACTIVE link per user', async () => { + const owner = await insertAuthUser() + await insertPhoneLink({ userId: owner }) + await expect(insertPhoneLink({ userId: owner })).rejects.toThrow( + /whatsapp_phone_links_user_active/, + ) + }) +}) + +describe('whatsapp_link_codes / conversations / messages are service-role only', () => { + it('hides link codes, conversations and messages from authenticated users', async () => { + const { userId } = await seedCompany() + const linkId = await insertPhoneLink({ userId }) + await getPool().query( + `INSERT INTO public.whatsapp_link_codes (user_id, code_hash, expires_at) + VALUES ($1, $2, now() + interval '10 minutes')`, + [userId, hash(randomUUID())], + ) + await getPool().query( + `INSERT INTO public.whatsapp_conversations (phone_link_id) VALUES ($1)`, + [linkId], + ) + await getPool().query( + `INSERT INTO public.whatsapp_messages (direction, message_type, phone_link_id) + VALUES ('inbound', 'text', $1)`, + [linkId], + ) + + await withUserContext(userId, async (client) => { + for (const table of [ + 'whatsapp_link_codes', + 'whatsapp_conversations', + 'whatsapp_messages', + ]) { + const res = await client.query(`SELECT count(*)::int AS n FROM public.${table}`) + expect(res.rows[0].n).toBe(0) + } + }) + }) +}) + +describe('whatsapp_messages wamid idempotency', () => { + it('rejects duplicate INBOUND wamids but allows outbound reuse', async () => { + const wamid = `wamid.${randomUUID()}` + await getPool().query( + `INSERT INTO public.whatsapp_messages (direction, message_type, wamid) + VALUES ('inbound', 'image', $1)`, + [wamid], + ) + + await expect( + getPool().query( + `INSERT INTO public.whatsapp_messages (direction, message_type, wamid) + VALUES ('inbound', 'image', $1)`, + [wamid], + ), + ).rejects.toThrow(/whatsapp_messages_inbound_wamid/) + + // ON CONFLICT DO NOTHING over the partial index is the webhook's dedupe. + const dedupe = await getPool().query( + `INSERT INTO public.whatsapp_messages (direction, message_type, wamid) + VALUES ('inbound', 'image', $1) + ON CONFLICT (wamid) WHERE wamid IS NOT NULL AND direction = 'inbound' + DO NOTHING + RETURNING id`, + [wamid], + ) + expect(dedupe.rows).toHaveLength(0) + + // The partial index does not constrain outbound rows. + await expect( + getPool().query( + `INSERT INTO public.whatsapp_messages (direction, message_type, wamid) + VALUES ('outbound', 'text', $1)`, + [wamid], + ), + ).resolves.toBeTruthy() + }) +}) + +describe('inbox source widening (20260802092000)', () => { + it('accepts source=whatsapp with channel_context and links the message', async () => { + const { userId, companyId } = await seedCompany() + const linkId = await insertPhoneLink({ userId }) + const msg = await getPool().query( + `INSERT INTO public.whatsapp_messages (direction, message_type, phone_link_id) + VALUES ('inbound', 'image', $1) RETURNING id`, + [linkId], + ) + const messageId = msg.rows[0].id + + const item = await getPool().query( + `INSERT INTO public.invoice_inbox_items + (company_id, user_id, status, source, whatsapp_message_id, channel_context, extracted_data) + VALUES ($1, $2, 'received', 'whatsapp', $3, $4, '{}') + RETURNING id, source, channel_context`, + [ + companyId, + userId, + messageId, + JSON.stringify({ channel: 'whatsapp', caption: 'lunch med kund' }), + ], + ) + expect(item.rows[0].source).toBe('whatsapp') + expect(item.rows[0].channel_context.caption).toBe('lunch med kund') + + // One inbox item per delivering chat message. + await expect( + getPool().query( + `INSERT INTO public.invoice_inbox_items + (company_id, user_id, status, source, whatsapp_message_id, extracted_data) + VALUES ($1, $2, 'received', 'whatsapp', $3, '{}')`, + [companyId, userId, messageId], + ), + ).rejects.toThrow(/invoice_inbox_items_whatsapp_msg/) + }) + + it('still rejects unknown sources (the widened CHECK actually replaced the old one)', async () => { + const { userId, companyId } = await seedCompany() + await expect( + getPool().query( + `INSERT INTO public.invoice_inbox_items + (company_id, user_id, status, source, extracted_data) + VALUES ($1, $2, 'received', 'carrier_pigeon', '{}')`, + [companyId, userId], + ), + ).rejects.toThrow(/invoice_inbox_items_source_check/) + }) + + it('accepts upload_source=whatsapp on document_attachments and rejects unknown values', async () => { + const { userId, companyId } = await seedCompany() + await expect( + getPool().query( + `INSERT INTO public.document_attachments + (user_id, company_id, file_name, mime_type, file_size_bytes, + storage_path, sha256_hash, upload_source) + VALUES ($1, $2, 'kvitto.jpg', 'image/jpeg', 1024, + $3, $4, 'whatsapp')`, + [userId, companyId, `documents/${companyId}/${userId}/t_kvitto.jpg`, hash(randomUUID())], + ), + ).resolves.toBeTruthy() + + await expect( + getPool().query( + `INSERT INTO public.document_attachments + (user_id, company_id, file_name, mime_type, file_size_bytes, + storage_path, sha256_hash, upload_source) + VALUES ($1, $2, 'kvitto.jpg', 'image/jpeg', 1024, + $3, $4, 'telegram')`, + [userId, companyId, `documents/${companyId}/${userId}/t2_kvitto.jpg`, hash(randomUUID())], + ), + ).rejects.toThrow(/document_attachments_upload_source_check/) + }) +}) + +describe('check_and_increment_whatsapp_sender_quota', () => { + it('counts per phone hash and trips the minute cap with rollback semantics', async () => { + const phoneHash = hash(`quota-${randomUUID()}`) + const call = () => + getPool().query(`SELECT public.check_and_increment_whatsapp_sender_quota($1, 2, 100) AS r`, [ + phoneHash, + ]) + + expect((await call()).rows[0].r.ok).toBe(true) + expect((await call()).rows[0].r.ok).toBe(true) + const third = (await call()).rows[0].r + expect(third.ok).toBe(false) + expect(third.scope).toBe('minute') + + // A different sender is unaffected: the key is the phone hash. + const other = await getPool().query( + `SELECT public.check_and_increment_whatsapp_sender_quota($1, 2, 100) AS r`, + [hash(`other-${randomUUID()}`)], + ) + expect(other.rows[0].r.ok).toBe(true) + }) + + it('is not executable by authenticated users (service-role only)', async () => { + const userId = await insertAuthUser() + await expect( + withUserContext(userId, async (client) => { + await client.query( + `SELECT public.check_and_increment_whatsapp_sender_quota($1, 2, 100)`, + [hash('nope')], + ) + }), + ).rejects.toThrow(/permission denied/) + }) +}) diff --git a/types/index.ts b/types/index.ts index 92f2f3ce..a83da61e 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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 | 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 + 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 | 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