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
co-authored by Jakob Wennberg Claude Fable 5
parent 02e48efe11
commit bd3592cb99
7 changed files with 721 additions and 1 deletions
+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 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/)
})
})