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:
@@ -772,3 +772,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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).
|
||||
|
||||
@@ -978,6 +978,8 @@ export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
|
||||
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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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<string> {
|
||||
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/)
|
||||
})
|
||||
})
|
||||
+105
-1
@@ -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<string, unknown> | 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<string, unknown>
|
||||
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<string, unknown> | 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
|
||||
|
||||
Reference in New Issue
Block a user