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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
02e48efe11
commit
bd3592cb99
@@ -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/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user