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:
Jakob Wennberg
2026-08-05 14:36:00 +02:00
committed by GitHub
parent 02e48efe11
commit bd3592cb99
7 changed files with 721 additions and 1 deletions
+1
View File
@@ -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).
+2
View File
@@ -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';
+289
View File
@@ -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 users 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
View File
@@ -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