629069e281
PR4 of the WhatsApp intake track: turns the per-message PR3 pipeline into a
conversation. Media replies are burst-debounced into ONE combined ack (M4
single / M5 numbered list) sent by the single winner of the atomic
pending_ack claim; losers stay silent. Multi-company senders get the company
question (reply buttons <=3, list 4-10, numbered text >10) with an 8h
sliding pin ('byt' clears it); their receipts park as staged message rows
until the answer and then run through the normal intake path.
Clarifying questions are evaluated per receipt after extraction, max one per
receipt, priority unreadable > representation > partial, keyed on the
Phase-0 classification (legibility/documentKind/merchantCategory) with
heuristic fallbacks (compressed-chat-photo signal, extended meal regex).
Budgets: <=2 content questions per burst, <=6 per sender per Stockholm day;
over budget acks only and flags the item moved_to_app. Questions expire
after 48h (sweep, silent hand-off) and are asked exactly once.
Free-text answers route through the ONE new LLM call
(lib/interpret-answer.ts): Sonnet via Bedrock, max_tokens 600, no thinking,
forced tool call validated by Zod with hard caps, gated by
checkAgentRateLimit, reply framed as untrusted data. Any failure degrades to
storing the raw text as a note; exact 'nej' short-circuits without the LLM.
Answers land in invoice_inbox_items.channel_context
(representation/user_note/quality) with ChannelQuestionAsked/Answered
processing-history events. Late answers match by quoted wamid or the most
recent open question within 7 days.
New per-minute sweep cron (registry-gated physical route, 503
EXTENSION_DISABLED when off) re-claims stuck rows (max 3 attempts), rescues
crashed burst acks, expires questions and pins. One new migration
(20260802210000) adds whatsapp_messages.acked_at, the relational burst-
membership marker, with pg-real coverage for the single-winner claim.
Verified: full vitest suite (12270), pg-real against a migrated
supabase/postgres 15 (977), lint 0 errors, tsc at the 405 baseline,
check:guards green, crontabs regenerated. Mutation-checked the debounce
claim and the daily budget gate.
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
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),
|
||
// 20260802210000 (acked_at combined-ack marker).
|
||
|
||
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('burst debounce claim + acked_at marker (20260802210000)', () => {
|
||
it('lets exactly one claimant win a due pending_ack, and none before the deadline', async () => {
|
||
const userId = await insertAuthUser()
|
||
const linkId = await insertPhoneLink({ userId })
|
||
const conv = await getPool().query(
|
||
`INSERT INTO public.whatsapp_conversations (phone_link_id, pending_ack, debounce_until)
|
||
VALUES ($1, true, now() + interval '1 hour') RETURNING id`,
|
||
[linkId],
|
||
)
|
||
const conversationId = conv.rows[0].id
|
||
|
||
// Deadline not reached: the claim must not fire.
|
||
const early = await getPool().query(
|
||
`UPDATE public.whatsapp_conversations SET pending_ack = false
|
||
WHERE id = $1 AND pending_ack AND debounce_until <= now() RETURNING id`,
|
||
[conversationId],
|
||
)
|
||
expect(early.rows).toHaveLength(0)
|
||
|
||
await getPool().query(
|
||
`UPDATE public.whatsapp_conversations SET debounce_until = now() - interval '1 second'
|
||
WHERE id = $1`,
|
||
[conversationId],
|
||
)
|
||
|
||
const claim = () =>
|
||
getPool().query(
|
||
`UPDATE public.whatsapp_conversations SET pending_ack = false
|
||
WHERE id = $1 AND pending_ack AND debounce_until <= now() RETURNING id`,
|
||
[conversationId],
|
||
)
|
||
const first = await claim()
|
||
expect(first.rows).toHaveLength(1)
|
||
const second = await claim()
|
||
expect(second.rows).toHaveLength(0)
|
||
})
|
||
|
||
it('exposes acked_at on whatsapp_messages, null by default', async () => {
|
||
const res = await getPool().query(
|
||
`INSERT INTO public.whatsapp_messages (direction, message_type, processing_status)
|
||
VALUES ('inbound', 'image', 'done') RETURNING acked_at`,
|
||
)
|
||
expect(res.rows[0].acked_at).toBeNull()
|
||
|
||
const stamped = await getPool().query(
|
||
`UPDATE public.whatsapp_messages SET acked_at = now()
|
||
WHERE acked_at IS NULL AND direction = 'inbound' AND processing_status = 'done'
|
||
AND conversation_id IS NULL
|
||
RETURNING acked_at`,
|
||
)
|
||
expect(stamped.rows.length).toBeGreaterThan(0)
|
||
expect(stamped.rows[0].acked_at).not.toBeNull()
|
||
})
|
||
})
|
||
|
||
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/)
|
||
})
|
||
})
|