feat(inbox): staged upload ack, HEIC/HEIF validation, WhatsApp silence fixes (#1605)

* fix(whatsapp): app-side unmute, close silent intake paths, health visibility

- add POST /link/unmute and a Reactivate control on the Pausad state
- company resolution: transient query errors release the row for sweep
  retry; genuine zero-options sends M19 instead of parking silently
- media from unlinked senders bypasses the hourly greeting throttle
  (10 min burst window, daily cap kept)
- GET /link returns 7-day failed-delivery and parked-inbound counts;
  sweep summary logs outboundFailed24h

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(documents): real HEIC/HEIF magic-byte validation, bilingual upload errors

- detect ISO-BMFF ftyp brands (heic/heix/heim/heis/hevc/hevx/hevm/hevs,
  mif1/msf1) instead of exempting image/heic from validation; declared
  heic/heif accepts either family member (iOS labels vary)
- new INBOX_UPLOAD_* structured error codes replace raw English strings
  on the inbox upload and attach-document routes
- registry doc corrected to the real 10 MB cap

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(inbox): staged upload with instant ack and deferred AI extraction

- web uploads insert the inbox item as status processing and respond
  immediately; Bedrock extraction and supplier match run via after()
  with a CAS flip to received (email and WhatsApp channels keep the
  synchronous path)
- widen invoice_inbox_items.status CHECK to include processing
  (migration 20260813180000, pg-real test included)
- crash-recovery sweep cron (*/2) flips stale processing rows;
  bulk-book skips extraction_in_progress items
- workspace: processing chip, in-flight rows disable actions, realtime
  flip, retry-extraction button for empty extractions
- picker accept list drops HEIC/HEIF so iOS transcodes library photos
  to JPEG; server allowlists unchanged (supersedes 2026-08-01 HEIC
  decision, see DECISIONS.md)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): bump inbox processing-status migration past main's latest

Main merged 20260813210000 while this PR was in flight; an inserted
version older than the latest applied aborts the prod db push at merge.
Renamed 20260813180000 to 20260813213000 and updated references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(decisions): log preview-tracker orphan repair after migration rename

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-13 23:57:53 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 456366fa70
commit 4a9fa5e6c5
40 changed files with 1732 additions and 127 deletions
@@ -41,8 +41,17 @@ vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) }
})
// The deferred extraction worker (staged upload) builds its own cookieless
// service client; route it to the same mock supabase so the flow is
// observable in the one test that reaches Bedrock.
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
return { ...actual, createServiceClientNoCookies: vi.fn() }
})
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
function findRoute(method: string, path: string) {
return invoiceInboxExtension.apiRoutes!.find(
@@ -86,7 +95,7 @@ function makeMultipartRequest(form: FormData, path: string): Request {
// suppliers (match) in that order via separate .from() chains.
function makeUploadSupabase(opts: {
isSandbox: boolean
captured: { row?: Record<string, unknown> }
captured: { row?: Record<string, unknown>; flip?: Record<string, unknown> }
}) {
const settingsChain = {
select: vi.fn().mockReturnThis(),
@@ -100,6 +109,11 @@ function makeUploadSupabase(opts: {
limit: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: null }),
}
// The deferred worker's CAS flip: .update(p).eq().eq().select('id').
const updateChain = {
eq: vi.fn(() => updateChain),
select: vi.fn().mockResolvedValue({ data: [{ id: 'inbox-1' }], error: null }),
}
const inboxChain = {
insert: vi.fn((row: Record<string, unknown>) => {
opts.captured.row = row
@@ -112,8 +126,9 @@ function makeUploadSupabase(opts: {
}),
}
}),
update: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnThis(),
update: vi.fn((payload: Record<string, unknown>) => {
opts.captured.flip = payload
return updateChain
}),
}
return {
@@ -151,9 +166,10 @@ describe('Sandbox companies skip Bedrock extraction', () => {
expect(captured.row?.extraction_skipped).toBe(true)
})
it('runs extraction normally for non-sandbox companies', async () => {
const captured: { row?: Record<string, unknown> } = {}
it('defers extraction for non-sandbox companies: responds processing, then flips', async () => {
const captured: { row?: Record<string, unknown>; flip?: Record<string, unknown> } = {}
const supabase = makeUploadSupabase({ isSandbox: false, captured })
vi.mocked(createServiceClientNoCookies).mockReturnValue(supabase as never)
vi.mocked(extractInvoiceFields).mockResolvedValueOnce({
data: {
supplier: { name: null, orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
@@ -174,10 +190,19 @@ describe('Sandbox companies skip Bedrock extraction', () => {
const res = await uploadRoute.handler(makeMultipartRequest(form, '/upload'), buildCtx(supabase))
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
// Staged upload: the response is the receipt ack, extraction runs after.
expect(status).toBe(200)
expect(extractInvoiceFields).toHaveBeenCalledOnce()
expect(body.data.status).toBe('processing')
expect(body.data.extracted_data).toBeNull()
expect(body.data.extraction_skipped).toBe(false)
expect(body.data.skip_reason).toBeNull()
expect(captured.row?.status).toBe('processing')
expect(captured.row?.extracted_data).toBeNull()
await vi.waitFor(() => expect(extractInvoiceFields).toHaveBeenCalledOnce())
await vi.waitFor(() => expect(captured.flip).toBeDefined())
expect(captured.flip?.status).toBe('received')
expect(captured.flip?.extraction_skipped).toBe(false)
})
})
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { runInboxSweep, PROCESSING_STUCK_MS } from '../lib/sweep'
import { emptyResult } from '../lib/extract-invoice-fields'
/**
* The staged-upload crash recovery: only rows that are (a) still
* 'processing', (b) older than the stuck threshold, and (c) still carrying a
* NULL extracted_data are flipped, and the flip writes the same empty
* skeleton the synchronous swallow-failure path persists. No re-extraction.
*/
interface Captured {
selectFilters: Array<{ method: string; args: unknown[] }>
updatePayload?: Record<string, unknown>
updateFilters: Array<{ method: string; args: unknown[] }>
}
function makeSupabase(opts: {
staleIds: string[]
claimedIds?: string[]
selectError?: { message: string }
updateError?: { message: string }
}) {
const captured: Captured = { selectFilters: [], updateFilters: [] }
const selectChain = {
eq: vi.fn((...args: unknown[]) => {
captured.selectFilters.push({ method: 'eq', args })
return selectChain
}),
lt: vi.fn((...args: unknown[]) => {
captured.selectFilters.push({ method: 'lt', args })
return selectChain
}),
limit: vi.fn().mockResolvedValue(
opts.selectError
? { data: null, error: opts.selectError }
: { data: opts.staleIds.map((id) => ({ id })), error: null },
),
}
const updateChain = {
in: vi.fn((...args: unknown[]) => {
captured.updateFilters.push({ method: 'in', args })
return updateChain
}),
eq: vi.fn((...args: unknown[]) => {
captured.updateFilters.push({ method: 'eq', args })
return updateChain
}),
is: vi.fn((...args: unknown[]) => {
captured.updateFilters.push({ method: 'is', args })
return updateChain
}),
select: vi.fn().mockResolvedValue(
opts.updateError
? { data: null, error: opts.updateError }
: { data: (opts.claimedIds ?? opts.staleIds).map((id) => ({ id })), error: null },
),
}
const supabase = {
from: vi.fn(() => ({
select: vi.fn(() => selectChain),
update: vi.fn((payload: Record<string, unknown>) => {
captured.updatePayload = payload
return updateChain
}),
})),
}
return { supabase: supabase as never, captured }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('runInboxSweep', () => {
it('flips stale processing rows to received with the empty skeleton', async () => {
const { supabase, captured } = makeSupabase({ staleIds: ['i1', 'i2'] })
const summary = await runInboxSweep(supabase)
expect(summary).toEqual({ flipped: 2 })
// The stale scan targets processing rows older than the threshold.
expect(captured.selectFilters).toEqual([
{ method: 'eq', args: ['status', 'processing'] },
{ method: 'lt', args: ['created_at', expect.any(String)] },
])
const cutoff = new Date(captured.selectFilters[1].args[1] as string).getTime()
expect(Date.now() - cutoff).toBeGreaterThanOrEqual(PROCESSING_STUCK_MS - 1000)
// The flip is a guarded claim: status still processing, data still NULL.
expect(captured.updateFilters).toEqual([
{ method: 'in', args: ['id', ['i1', 'i2']] },
{ method: 'eq', args: ['status', 'processing'] },
{ method: 'is', args: ['extracted_data', null] },
])
expect(captured.updatePayload).toEqual({
status: 'received',
extracted_data: emptyResult(),
extraction_skipped: false,
})
})
it('counts only the rows the guarded update actually claimed', async () => {
// A worker finished i2 between the scan and the flip: its result wins.
const { supabase } = makeSupabase({ staleIds: ['i1', 'i2'], claimedIds: ['i1'] })
const summary = await runInboxSweep(supabase)
expect(summary).toEqual({ flipped: 1 })
})
it('does nothing when no processing row is stale', async () => {
const { supabase, captured } = makeSupabase({ staleIds: [] })
const summary = await runInboxSweep(supabase)
expect(summary).toEqual({ flipped: 0 })
expect(captured.updatePayload).toBeUndefined()
})
it('never throws: a failed select reports zero flips', async () => {
const { supabase } = makeSupabase({ staleIds: [], selectError: { message: 'boom' } })
await expect(runInboxSweep(supabase)).resolves.toEqual({ flipped: 0 })
})
it('never throws: a failed update reports zero flips', async () => {
const { supabase } = makeSupabase({ staleIds: ['i1'], updateError: { message: 'boom' } })
await expect(runInboxSweep(supabase)).resolves.toEqual({ flipped: 0 })
})
})
@@ -37,7 +37,16 @@ vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) }
})
// The deferred extraction worker builds its own cookieless service client
// (the request-scoped one may be gone once the response is flushed). Route
// it to the same mock supabase so the CAS flip is observable.
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
return { ...actual, createServiceClientNoCookies: vi.fn() }
})
import { extractInvoiceFields, emptyResult } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
function findRoute(method: string, path: string) {
return invoiceInboxExtension.apiRoutes!.find(
@@ -47,10 +56,19 @@ function findRoute(method: string, path: string) {
const uploadRoute = findRoute('POST', '/upload')
// Build the supabase mock the upload handler needs:
interface FlipCapture {
payload?: Record<string, unknown>
filters: Array<[string, unknown]>
}
// Build the supabase mock the upload handler and the deferred worker need:
// .from('invoice_inbox_items').insert(row).select('*').single() → { data: row, error: null }
// .from('invoice_inbox_items').update(p).eq().eq().select('id') → CAS flip (captured)
// .from('suppliers').select().eq().eq()... .maybeSingle() → { data: null }
function makeSupabase(captured: { row?: Record<string, unknown> }) {
function makeSupabase(
captured: { row?: Record<string, unknown> },
flip: FlipCapture = { filters: [] },
) {
const supplierChain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
@@ -58,6 +76,13 @@ function makeSupabase(captured: { row?: Record<string, unknown> }) {
limit: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: null }),
}
const updateChain = {
eq: vi.fn((...args: [string, unknown]) => {
flip.filters.push(args)
return updateChain
}),
select: vi.fn().mockResolvedValue({ data: [{ id: 'inbox-1' }], error: null }),
}
const inboxChain = {
insert: vi.fn((row: Record<string, unknown>) => {
captured.row = row
@@ -70,6 +95,10 @@ function makeSupabase(captured: { row?: Record<string, unknown> }) {
}),
}
}),
update: vi.fn((payload: Record<string, unknown>) => {
flip.payload = payload
return updateChain
}),
}
return {
from: vi.fn((table: string) => {
@@ -120,10 +149,12 @@ beforeEach(() => {
vi.clearAllMocks()
})
describe('POST /upload: page-count gate (issue #553)', () => {
it('slices long PDFs to the first 3 pages and extracts, instead of skipping', async () => {
describe('POST /upload: staged extraction + page-count gate (issue #553)', () => {
it('defers long PDFs: responds processing, then slices to 3 pages and flips to received', async () => {
const captured: { row?: Record<string, unknown> } = {}
const supabase = makeSupabase(captured)
const flip: FlipCapture = { filters: [] }
const supabase = makeSupabase(captured, flip)
vi.mocked(createServiceClientNoCookies).mockReturnValue(supabase as never)
vi.mocked(extractInvoiceFields).mockResolvedValueOnce({
data: emptyResult(),
rawText: 'ok',
@@ -131,28 +162,46 @@ describe('POST /upload: page-count gate (issue #553)', () => {
const req = await makeUploadRequest(6)
const res = await uploadRoute.handler(req, buildCtx(supabase))
const { status, body } = await parseJsonResponse<{
data: { extracted_data?: { pages?: { total: number; analyzed: number } } } & Record<string, unknown>
}>(res)
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
// The response is the receipt ack: the row exists, extraction has not
// landed, nothing is reported skipped.
expect(status).toBe(200)
expect(extractInvoiceFields).toHaveBeenCalledOnce()
// The buffer handed to Bedrock is the sliced copy, not the original.
const sentBuffer = vi.mocked(extractInvoiceFields).mock.calls[0][0].buffer
const sentPdf = await PDFDocument.load(sentBuffer)
expect(sentPdf.getPageCount()).toBe(3)
// The row is a normal extracted row: the truncation is recorded in
// extracted_data.pages rather than as a skip.
expect(captured.row?.extraction_skipped).toBe(false)
expect(body.data.status).toBe('processing')
expect(body.data.extracted_data).toBeNull()
expect(body.data.extraction_skipped).toBe(false)
expect(body.data.skip_reason).toBeNull()
expect(body.data.page_count).toBe(6)
expect(body.data.extracted_data?.pages).toEqual({ total: 6, analyzed: 3 })
expect(captured.row?.status).toBe('processing')
expect(captured.row?.extracted_data).toBeNull()
expect(captured.row?.extraction_skipped).toBe(false)
// The deferred worker extracts from the sliced copy, not the original.
await vi.waitFor(() => expect(extractInvoiceFields).toHaveBeenCalledOnce())
const sentBuffer = vi.mocked(extractInvoiceFields).mock.calls[0][0].buffer
const sentPdf = await PDFDocument.load(sentBuffer)
expect(sentPdf.getPageCount()).toBe(3)
// ...and CAS-flips the processing row to received, with the truncation
// recorded in extracted_data.pages rather than as a skip.
await vi.waitFor(() => expect(flip.payload).toBeDefined())
expect(flip.payload?.status).toBe('received')
expect(flip.payload?.extraction_skipped).toBe(false)
expect((flip.payload?.extracted_data as { pages?: unknown })?.pages).toEqual({
total: 6,
analyzed: 3,
})
expect(flip.filters).toEqual([
['id', 'inbox-1'],
['status', 'processing'],
])
})
it('runs extraction normally for PDFs at or below the page-count limit', async () => {
it('defers PDFs at or below the page-count limit and extracts the full buffer', async () => {
const captured: { row?: Record<string, unknown> } = {}
const supabase = makeSupabase(captured)
const flip: FlipCapture = { filters: [] }
const supabase = makeSupabase(captured, flip)
vi.mocked(createServiceClientNoCookies).mockReturnValue(supabase as never)
vi.mocked(extractInvoiceFields).mockResolvedValueOnce({
data: emptyResult(),
rawText: 'ok',
@@ -163,14 +212,26 @@ describe('POST /upload: page-count gate (issue #553)', () => {
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
expect(status).toBe(200)
expect(extractInvoiceFields).toHaveBeenCalledOnce()
expect(captured.row?.extraction_skipped).toBe(false)
expect(body.data.status).toBe('processing')
expect(body.data.extraction_skipped).toBe(false)
expect(body.data.skip_reason).toBeNull()
expect(body.data.page_count).toBe(2)
await vi.waitFor(() => expect(extractInvoiceFields).toHaveBeenCalledOnce())
const sentBuffer = vi.mocked(extractInvoiceFields).mock.calls[0][0].buffer
const sentPdf = await PDFDocument.load(sentBuffer)
expect(sentPdf.getPageCount()).toBe(2)
await vi.waitFor(() => expect(flip.payload).toBeDefined())
expect(flip.payload?.status).toBe('received')
// No slice happened, so no pages truncation marker.
expect((flip.payload?.extracted_data as { pages?: unknown })?.pages).toBeUndefined()
})
it('honors client-side skip_extraction=true with skip_reason=client_opt_out', async () => {
it('honors client-side skip_extraction=true synchronously with skip_reason=client_opt_out', async () => {
// The BYO-extraction opt-out must stay on the synchronous path: the
// caller PUTs its parsed fields right after upload, and a deferred flip
// would overwrite them.
const captured: { row?: Record<string, unknown> } = {}
const supabase = makeSupabase(captured)
@@ -185,7 +246,9 @@ describe('POST /upload: page-count gate (issue #553)', () => {
const { body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
expect(extractInvoiceFields).not.toHaveBeenCalled()
expect(body.data.status).toBe('received')
expect(body.data.extraction_skipped).toBe(true)
expect(body.data.skip_reason).toBe('client_opt_out')
expect(captured.row?.status).toBe('received')
})
})
+58 -23
View File
@@ -189,10 +189,17 @@ export const invoiceInboxExtension: Extension = {
if (!limit.ok) {
return NextResponse.json(
{
error:
limit.scope === 'minute'
? 'För många uppladdningar på kort tid. Försök igen om en stund.'
: 'Dagsgränsen för uppladdningar är nådd. Försök igen imorgon.',
error: {
code: 'RATE_LIMITED',
message:
limit.scope === 'minute'
? 'För många uppladdningar på kort tid. Försök igen om en stund.'
: 'Dagsgränsen för uppladdningar är nådd. Försök igen imorgon.',
message_en:
limit.scope === 'minute'
? 'Too many uploads in a short time. Try again in a moment.'
: 'The daily upload limit has been reached. Try again tomorrow.',
},
retry_after: limit.retryAfterSec,
},
{ status: 429, headers: { 'Retry-After': String(limit.retryAfterSec ?? 60) } },
@@ -213,15 +220,18 @@ export const invoiceInboxExtension: Extension = {
formData.get('skip_extraction') === 'true' ||
formData.get('skip_extraction') === '1'
if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
if (!file) return errorResponseFromCode('INBOX_UPLOAD_NO_FILE', ctx.log)
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: `File too large (max ${MAX_FILE_SIZE / 1024 / 1024} MB)` }, { status: 400 })
return errorResponseFromCode('INBOX_UPLOAD_TOO_LARGE', ctx.log, {
messageSv: `Filen är för stor. Maxstorlek är ${MAX_FILE_SIZE / 1024 / 1024} MB.`,
messageEn: `File exceeds the ${MAX_FILE_SIZE / 1024 / 1024} MB size limit.`,
})
}
if (!UPLOAD_ALLOWED_MIME_TYPES.has(file.type)) {
return NextResponse.json(
{ error: `Unsupported file type: ${file.type}. Allowed: PDF, JPEG, PNG, HEIC, WebP` },
{ status: 400 }
)
return errorResponseFromCode('INBOX_UPLOAD_UNSUPPORTED_TYPE', ctx.log, {
messageSv: `Filtypen stöds inte: ${file.type || 'okänd'}. Tillåtna format: PDF, JPEG, PNG, HEIC och WebP.`,
messageEn: `Unsupported file type: ${file.type || 'unknown'}. Allowed: PDF, JPEG, PNG, HEIC, WebP.`,
})
}
// Validate matched_transaction_id belongs to this company before we
@@ -240,15 +250,18 @@ export const invoiceInboxExtension: Extension = {
return NextResponse.json({ error: txErr.message }, { status: 500 })
}
if (!tx) {
return NextResponse.json(
{ error: 'matched_transaction_id refers to a transaction outside this company.' },
{ status: 400 },
)
return errorResponseFromCode('INBOX_UPLOAD_TX_NOT_IN_COMPANY', ctx.log)
}
}
try {
const buffer = await file.arrayBuffer()
// Staged upload: the row lands as 'processing' and the response
// returns before Bedrock runs; the deferred worker (or the sweep
// cron) flips it to 'received'. Only this web route defers: email
// and WhatsApp ingestion already run in background contexts and
// consume the returned item synchronously. When skipExtraction is
// set the call stays synchronous (see uploadAndExtract).
const result = await uploadAndExtract(
ctx.supabase,
ctx.userId,
@@ -257,13 +270,24 @@ export const invoiceInboxExtension: Extension = {
'upload',
undefined,
matchedTransactionId,
{ skipExtraction },
{ skipExtraction, deferExtraction: true },
)
return NextResponse.json({ data: result })
} catch (error) {
console.error('[invoice-inbox/upload] Failed:', error)
// Pass the thrown message through: the document service raises its
// user-facing failures (magic-byte mismatch, corrupt content) in
// Swedish, and getErrorMessage substitutes the registry copy for
// this code when the message is internal English.
const message = error instanceof Error && error.message.trim() ? error.message : null
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Upload failed' },
{
error: {
code: 'INBOX_UPLOAD_FAILED',
message: message ?? 'Uppladdningen misslyckades. Försök igen.',
message_en: message ?? 'Upload failed.',
},
},
{ status: 500 }
)
}
@@ -651,15 +675,18 @@ export const invoiceInboxExtension: Extension = {
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
if (!file) return errorResponseFromCode('INBOX_UPLOAD_NO_FILE', ctx.log)
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: `File too large (max ${MAX_FILE_SIZE / 1024 / 1024} MB)` }, { status: 400 })
return errorResponseFromCode('INBOX_UPLOAD_TOO_LARGE', ctx.log, {
messageSv: `Filen är för stor. Maxstorlek är ${MAX_FILE_SIZE / 1024 / 1024} MB.`,
messageEn: `File exceeds the ${MAX_FILE_SIZE / 1024 / 1024} MB size limit.`,
})
}
if (!UPLOAD_ALLOWED_MIME_TYPES.has(file.type)) {
return NextResponse.json(
{ error: `Unsupported file type: ${file.type}. Allowed: PDF, JPEG, PNG, HEIC, WebP` },
{ status: 400 }
)
return errorResponseFromCode('INBOX_UPLOAD_UNSUPPORTED_TYPE', ctx.log, {
messageSv: `Filtypen stöds inte: ${file.type || 'okänd'}. Tillåtna format: PDF, JPEG, PNG, HEIC och WebP.`,
messageEn: `Unsupported file type: ${file.type || 'unknown'}. Allowed: PDF, JPEG, PNG, HEIC, WebP.`,
})
}
const { data: item } = await ctx.supabase
@@ -774,8 +801,16 @@ export const invoiceInboxExtension: Extension = {
})
} catch (error) {
console.error('[invoice-inbox/attach-document] Failed:', error)
// Same pass-through rationale as the /upload catch above.
const message = error instanceof Error && error.message.trim() ? error.message : null
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Attach failed' },
{
error: {
code: 'INBOX_ATTACH_FAILED',
message: message ?? 'Bilagan kunde inte kopplas. Försök igen.',
message_en: message ?? 'Failed to attach the document.',
},
},
{ status: 500 }
)
}
@@ -0,0 +1,78 @@
/**
* Crash recovery for the staged upload.
*
* The web upload route inserts the inbox row as 'processing' and defers
* Bedrock extraction to an after() worker that can die with the serverless
* instance. A row stuck in 'processing' is durable state with no live owner:
* this sweep flips it to 'received' with the empty extraction skeleton so it
* becomes a normal manually-editable item. Deliberately NO re-extraction
* here: the UI retry button covers that, and a cron that silently re-spends
* Bedrock tokens on every crash would hide the crashes.
*
* Overlap with a slow live worker is safe: every mutation is a guarded claim
* on status='processing' (and extracted_data still NULL), so the sweep and
* the worker never both win one row.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { emptyResult } from './extract-invoice-fields'
const log = createLogger('invoice-inbox/sweep')
/**
* A deferred extraction is one Bedrock call (the WhatsApp cron budgets
* 10-60s for the same call). Two minutes of silence means no live worker
* can still deliver a flip that beats the sweep by enough to matter.
*/
export const PROCESSING_STUCK_MS = 2 * 60 * 1000
const BATCH = 50
export interface InboxSweepSummary {
/** Stale 'processing' rows flipped to 'received' with the empty skeleton. */
flipped: number
}
/** Run one sweep pass. Never throws. */
export async function runInboxSweep(supabase: SupabaseClient): Promise<InboxSweepSummary> {
const cutoff = new Date(Date.now() - PROCESSING_STUCK_MS).toISOString()
const { data: stale, error: selectError } = await supabase
.from('invoice_inbox_items')
.select('id')
.eq('status', 'processing')
.lt('created_at', cutoff)
.limit(BATCH)
if (selectError) {
log.error('stale-processing select failed', { error: selectError.message })
return { flipped: 0 }
}
const ids = ((stale ?? []) as Array<{ id: string }>).map((r) => r.id)
if (ids.length === 0) return { flipped: 0 }
// CAS: the status guard keeps a just-finished worker's real result, and
// the extracted_data-still-NULL guard keeps any fields a caller PUT onto
// the row in the meantime; a row that fails either guard is someone
// else's win, not ours.
const { data: claimed, error: updateError } = await supabase
.from('invoice_inbox_items')
.update({
status: 'received',
extracted_data: emptyResult() as unknown as Record<string, unknown>,
extraction_skipped: false,
})
.in('id', ids)
.eq('status', 'processing')
.is('extracted_data', null)
.select('id')
if (updateError) {
log.error('stale-processing flip failed', { error: updateError.message })
return { flipped: 0 }
}
const flipped = Array.isArray(claimed) ? claimed.length : 0
if (flipped > 0) {
log.info('flipped stale processing rows to received', { flipped })
}
return { flipped }
}
@@ -1,8 +1,11 @@
import { after } from 'next/server'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { extractInvoiceFields, emptyResult } from './extract-invoice-fields'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import type { InvoiceExtractionResult } from '@/types'
import { PDFDocument } from 'pdf-lib'
import path from 'node:path'
@@ -199,6 +202,20 @@ export async function uploadAndExtract(
/** Overrides the system actor id on the DocumentIngested history event.
* Omitted = today's behavior (resend-inbound for email, user otherwise). */
actorId?: string
/**
* Staged upload (web route only). When true AND extraction would actually
* call Bedrock, the inbox row is inserted first (status 'processing',
* extracted_data NULL), the function returns immediately, and extraction
* runs after the response via a deferred worker that CAS-flips the row to
* 'received'. Verdicts that never reach Bedrock (no AI entitlement,
* sandbox, client opt-out) stay on the synchronous path: they are quick
* and their response contract (empty skeleton + skip_reason) is
* unchanged. skipExtraction in particular MUST stay synchronous: a
* BYO-extraction agent PUTs its fields right after upload, and a deferred
* flip would overwrite them. Default false = today's synchronous behavior
* (email and WhatsApp callers are untouched).
*/
deferExtraction?: boolean
} = {},
) {
const correlationId = crypto.randomUUID()
@@ -319,27 +336,96 @@ export async function uploadAndExtract(
// extraction path, so the document is still stored and can be filled in
// manually. Highest priority (a hard paywall rule, not a heuristic).
const hasAiEntitlement = await hasCapability(supabase, companyId, CAPABILITY.ai)
// Long PDFs are sliced to their first pages instead of skipped, but only
// when extraction would actually run: slicing after an entitlement/sandbox/
// opt-out verdict would be wasted CPU.
const slicedBuffer =
gatedByPageCount && hasAiEntitlement && !sandbox && !opts.skipExtraction
? await slicePdfForExtraction(file.buffer, MAX_PAGES_FOR_AUTO_EXTRACT)
: null
// Skip-reason priority: no-AI-entitlement > sandbox > client opt-out >
// page-count. Opt-out now outranks the page gate (an opted-out caller never
// extracts regardless of length), and too_many_pages only fires when the
// slice fallback also failed (encrypted/malformed PDF).
const skipReason: 'no_ai_entitlement' | 'too_many_pages' | 'client_opt_out' | 'sandbox' | null =
// The verdicts that avoid Bedrock entirely, in the established priority
// order: no-AI-entitlement > sandbox > client opt-out. All three are
// decidable without touching the PDF; too_many_pages is not (it only fires
// when the slice fallback fails) and is resolved below, on whichever path
// (sync or deferred) actually attempts the slice.
const syncSkipReason: 'no_ai_entitlement' | 'client_opt_out' | 'sandbox' | null =
!hasAiEntitlement
? 'no_ai_entitlement'
: sandbox
? 'sandbox'
: opts.skipExtraction
? 'client_opt_out'
: gatedByPageCount && slicedBuffer == null
? 'too_many_pages'
: null
: null
if (opts.deferExtraction && syncSkipReason === null) {
// Staged path: extraction WILL call Bedrock, so create the row now and
// let the response go. The deferred worker (or, after a crash, the sweep
// cron at /api/extensions/invoice-inbox/sweep/cron) flips the row to
// 'received'. extracted_data stays NULL until then: that is what keeps
// the MCP create-from-inbox guard ("re-run extraction first") holding
// for free on in-flight rows.
const { data: inbox, error: inboxError } = await supabase
.from('invoice_inbox_items')
.insert({
company_id: companyId,
user_id: userId,
status: 'processing',
source,
document_id: doc.id,
extracted_data: null,
extraction_skipped: false,
matched_supplier_id: null,
email_from: emailMeta?.from || null,
email_subject: emailMeta?.subject || null,
email_received_at: emailMeta?.receivedAt || null,
email_body_text: emailMeta?.bodyText || null,
resend_email_id: emailMeta?.resendEmailId || null,
resend_attachment_id: emailMeta?.resendAttachmentId || null,
raw_email_payload: emailMeta?.messageId
? { messageId: emailMeta.messageId, filename: file.name }
: null,
correlation_id: correlationId,
matched_transaction_id: matchedTransactionId ?? null,
whatsapp_message_id: opts.channelMeta?.whatsappMessageId ?? null,
channel_context: opts.channelMeta
? { channel: 'whatsapp', caption: sanitiseCaption(opts.channelMeta.caption) }
: null,
})
.select('*')
.single()
if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`)
scheduleDeferredExtraction({
itemId: inbox.id,
documentId: doc.id,
companyId,
correlationId,
file,
pageCount,
gatedByPageCount,
})
return {
document_id: doc.id,
inbox_item_id: inbox.id,
status: inbox.status,
extracted_data: null,
matched_supplier_id: inbox.matched_supplier_id,
matched_transaction_id: inbox.matched_transaction_id,
extraction_skipped: false,
skip_reason: null,
page_count: pageCount,
}
}
// Long PDFs are sliced to their first pages instead of skipped, but only
// when extraction would actually run: slicing after an entitlement/sandbox/
// opt-out verdict would be wasted CPU.
const slicedBuffer =
gatedByPageCount && syncSkipReason === null
? await slicePdfForExtraction(file.buffer, MAX_PAGES_FOR_AUTO_EXTRACT)
: null
// Skip-reason priority: no-AI-entitlement > sandbox > client opt-out >
// page-count. Opt-out outranks the page gate (an opted-out caller never
// extracts regardless of length), and too_many_pages only fires when the
// slice fallback also failed (encrypted/malformed PDF).
const skipReason: 'no_ai_entitlement' | 'too_many_pages' | 'client_opt_out' | 'sandbox' | null =
syncSkipReason ??
(gatedByPageCount && slicedBuffer == null ? 'too_many_pages' : null)
const skipExtraction = skipReason !== null
// Bring-your-own-extraction: skip the Bedrock call entirely and seed an
@@ -453,3 +539,155 @@ export async function uploadAndExtract(
page_count: pageCount,
}
}
// ── Deferred extraction (staged web upload) ──────────────────
interface DeferredExtractionJob {
itemId: string
documentId: string
companyId: string
correlationId: string
file: { name: string; buffer: ArrayBuffer; type: string }
pageCount: number | null
gatedByPageCount: boolean
}
/**
* Run extraction after the upload response is sent, then CAS-flip the
* 'processing' row to 'received'. Exact dispatch-kick idiom (whatsapp-inbox
* kickInboundProcessing): never awaited, never throws out, falls back to a
* microtask outside a request scope (tests). The request-scoped supabase
* client may be gone once the response is flushed, so the worker builds its
* own cookieless service client.
*/
function scheduleDeferredExtraction(job: DeferredExtractionJob): void {
const run = async (): Promise<void> => {
try {
const supabase = createServiceClientNoCookies()
// Mirrors the synchronous path: extraction failures are swallowed into
// the empty skeleton (extractInvoiceFields does most of that itself);
// an unsliceable long PDF becomes the too_many_pages skip.
let extracted: InvoiceExtractionResult = emptyResult()
let rawText: string | null = null
let extractionSkipped = false
let skipReason: 'too_many_pages' | null = null
try {
const slicedBuffer = job.gatedByPageCount
? await slicePdfForExtraction(job.file.buffer, MAX_PAGES_FOR_AUTO_EXTRACT)
: null
if (job.gatedByPageCount && slicedBuffer == null) {
extractionSkipped = true
skipReason = 'too_many_pages'
} else {
const result = await extractInvoiceFields({
buffer: Buffer.from(slicedBuffer ?? job.file.buffer),
mimeType: job.file.type,
fileName: job.file.name,
})
extracted = result.data
rawText = result.rawText
if (slicedBuffer != null && job.pageCount != null) {
extracted.pages = { total: job.pageCount, analyzed: MAX_PAGES_FOR_AUTO_EXTRACT }
}
}
} catch (err) {
// Persist the empty skeleton below, exactly like the sync path does
// on a swallowed failure: the row becomes a normal received item with
// empty fields, and the manual-edit / retry affordances take over.
console.error('[invoice-inbox] Deferred extraction failed:', err)
}
// Supplier match by org-nr, then case-insensitive name (no AI fuzz).
let matchedSupplierId: string | null = null
if (extracted.supplier.orgNumber) {
const { data: s } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', job.companyId)
.eq('org_number', extracted.supplier.orgNumber)
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
if (!matchedSupplierId && extracted.supplier.name) {
const { data: s } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', job.companyId)
.ilike('name', extracted.supplier.name)
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
// CAS: only the row still waiting on THIS worker flips. A user retry
// or the sweep cron may have claimed it first; their result wins.
const { data: claimed, error: updateError } = await supabase
.from('invoice_inbox_items')
.update({
status: 'received',
extracted_data: extracted as unknown as Record<string, unknown>,
extraction_skipped: extractionSkipped,
matched_supplier_id: matchedSupplierId,
})
.eq('id', job.itemId)
.eq('status', 'processing')
.select('id')
if (updateError) {
// The sweep cron flips the row to 'received' once it goes stale.
console.error('[invoice-inbox] Deferred extraction flip failed:', updateError)
return
}
if (!Array.isArray(claimed) || claimed.length === 0) return
try {
await appendProcessingHistory({
companyId: job.companyId,
correlationId: job.correlationId,
aggregateType: 'Document',
aggregateId: job.documentId,
eventType: 'DocumentExtractionAttempted',
payload: {
document_id: job.documentId,
inbox_item_id: job.itemId,
succeeded: rawText != null && rawText.length > 0,
extracted_total: extracted.totals.total,
has_org_number: extracted.supplier.orgNumber != null,
has_ocr: extracted.invoice.paymentReference != null,
skipped: extractionSkipped,
skip_reason: skipReason,
page_count: job.pageCount,
},
actor: { type: 'system', id: 'invoice-inbox-extract' },
occurredAt: new Date(),
})
} catch (err) {
console.error('[invoice-inbox] Failed to append DocumentExtractionAttempted:', err)
}
} catch (err) {
console.error('[invoice-inbox] Deferred extraction worker crashed:', err)
// Best-effort flip so the row does not sit in 'processing' until the
// sweep: same CAS, same empty skeleton the sync swallow persists.
try {
await createServiceClientNoCookies()
.from('invoice_inbox_items')
.update({
status: 'received',
extracted_data: emptyResult() as unknown as Record<string, unknown>,
extraction_skipped: false,
})
.eq('id', job.itemId)
.eq('status', 'processing')
} catch {
// The sweep cron is the recovery of last resort.
}
}
}
try {
after(() => run())
} catch {
queueMicrotask(() => void run())
}
}
@@ -24,7 +24,10 @@ import {
askCompanyQuestion,
applyCompanyChoice,
} from '@/extensions/general/whatsapp-inbox/lib/company-question'
import { STAGED_AWAITING_COMPANY } from '@/extensions/general/whatsapp-inbox/lib/conversation'
import {
NO_COMPANY_OPTIONS,
STAGED_AWAITING_COMPANY,
} from '@/extensions/general/whatsapp-inbox/lib/conversation'
import { TEMPLATE } from '@/extensions/general/whatsapp-inbox/lib/messages'
const sendTextMock = vi.mocked(sendText)
@@ -103,7 +106,7 @@ describe('askCompanyQuestion', () => {
stagedCount: 1,
})
expect(asked).toBe(true)
expect(asked).toBe('asked')
expect(sendButtonsMock).toHaveBeenCalledTimes(1)
const args = sendButtonsMock.mock.calls[0][1]
expect(args.buttons).toHaveLength(3)
@@ -175,11 +178,84 @@ describe('askCompanyQuestion', () => {
stagedCount: 1,
})
expect(asked).toBe(false)
expect(asked).toBe('not_asked')
expect(sendButtonsMock).not.toHaveBeenCalled()
expect(sendListMock).not.toHaveBeenCalled()
expect(sendTextMock).not.toHaveBeenCalled()
})
it('treats a failed membership query as transient: nothing sent, nothing marked', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ error: { message: 'connection reset' } }) // memberships query fails
const asked = await askCompanyQuestion(supabase as unknown as SupabaseClient, {
conversation: makeConversation(),
link: makeLink(),
to: '46701234567',
replyBase,
stagedCount: 1,
})
expect(asked).toBe('transient_error')
expect(sendButtonsMock).not.toHaveBeenCalled()
expect(sendListMock).not.toHaveBeenCalled()
expect(sendTextMock).not.toHaveBeenCalled()
// Neither the question state nor the staged rows were touched: the
// CALLER releases its row for retry.
expect(findCalls('whatsapp_conversations', 'update')).toHaveLength(0)
expect(findCalls('whatsapp_messages', 'update')).toHaveLength(0)
})
it('genuinely zero options: re-marks the staged rows and sends ONE actionable M19', async () => {
const { supabase, enqueue, findCalls, calls } = createQueuedMockSupabase()
enqueue({ data: [] }) // memberships: really none
enqueue({ data: null }) // staged rows re-marked no_company_options
enqueue({ data: null }) // M19 burst dedupe: none sent yet
const asked = await askCompanyQuestion(supabase as unknown as SupabaseClient, {
conversation: makeConversation(),
link: makeLink(),
to: '46701234567',
replyBase,
stagedCount: 1,
})
expect(asked).toBe('no_options')
const marker = findCalls('whatsapp_messages', 'update')[0][0] as Record<string, unknown>
expect(marker.error_message).toBe(NO_COMPANY_OPTIONS)
// The re-mark targets exactly the rows parked behind this episode.
expect(
calls.some(
(c) =>
c.table === 'whatsapp_messages' &&
c.method === 'eq' &&
c.args[0] === 'error_message' &&
c.args[1] === STAGED_AWAITING_COMPANY,
),
).toBe(true)
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m19NoCompany)
// No question is ever parked: there is nothing to answer.
expect(findCalls('whatsapp_conversations', 'update')).toHaveLength(0)
})
it('suppresses a repeat M19 inside the burst window', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [] }) // memberships: none
enqueue({ data: null }) // staged rows re-mark
enqueue({ data: { id: 'm19-earlier' } }) // an M19 already went out
const asked = await askCompanyQuestion(supabase as unknown as SupabaseClient, {
conversation: makeConversation(),
link: makeLink(),
to: '46701234567',
replyBase,
stagedCount: 2,
})
expect(asked).toBe('no_options')
expect(sendTextMock).not.toHaveBeenCalled()
})
})
describe('applyCompanyChoice', () => {
@@ -161,7 +161,7 @@ describe('company question is not one-shot when the send fails', () => {
stagedCount: 3,
})
expect(asked).toBe(false)
expect(asked).toBe('not_asked')
const updates = mock
.findCalls('whatsapp_conversations', 'update')
.map((args) => args[0] as { state?: string; context?: Record<string, unknown> })
@@ -184,7 +184,7 @@ describe('company question is not one-shot when the send fails', () => {
stagedCount: 1,
})
expect(asked).toBe(true)
expect(asked).toBe('asked')
expect(mock.findCalls('whatsapp_conversations', 'update')).toHaveLength(1)
})
})
@@ -0,0 +1,168 @@
/**
* Route tests for the authenticated /link endpoints: unmute from the app
* (the in-chat counterpart is the literal text `start`) and the health
* counters GET /link exposes for the settings panel.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(),
}))
vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => {
const actual = await vi.importActual<
typeof import('@/extensions/general/whatsapp-inbox/lib/graph-api')
>('@/extensions/general/whatsapp-inbox/lib/graph-api')
return {
...actual,
sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null }),
getDisplayPhoneNumber: vi.fn().mockResolvedValue(null),
}
})
vi.mock('@/extensions/general/whatsapp-inbox/lib/process-inbound', () => ({
kickInboundProcessing: vi.fn(),
}))
import { createServiceClient } from '@/lib/supabase/server'
import { whatsappInboxExtension } from '@/extensions/general/whatsapp-inbox'
function findRoute(method: string, path: string) {
return whatsappInboxExtension.apiRoutes!.find((r) => r.method === method && r.path === path)!
}
function makeCtx(supabase: unknown): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'whatsapp-inbox',
supabase,
} as unknown as ExtensionContext
}
function request(path: string, method = 'POST'): Request {
return new Request(`http://localhost:3000/api/extensions/ext/whatsapp-inbox${path}`, { method })
}
describe('POST /link/unmute', () => {
const route = findRoute('POST', '/link/unmute')
beforeEach(() => {
vi.clearAllMocks()
})
it('401s without an authenticated context', async () => {
const response = await route.handler(request('/link/unmute'))
expect(response.status).toBe(401)
})
it('clears muted_at on the caller own active link', async () => {
const { supabase, enqueue, findCall, calls } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'link-1' }] }) // guarded update matched the row
const response = await route.handler(request('/link/unmute'), makeCtx(supabase))
expect(response.status).toBe(200)
const body = (await response.json()) as { data: { unmuted: boolean } }
expect(body.data).toEqual({ unmuted: true })
const [patch] = findCall('whatsapp_phone_links', 'update') as [Record<string, unknown>]
expect(patch).toEqual({ muted_at: null })
// Scoped to the caller's own active link.
expect(
calls.some(
(c) =>
c.table === 'whatsapp_phone_links' &&
c.method === 'eq' &&
c.args[0] === 'user_id' &&
c.args[1] === 'user-1',
),
).toBe(true)
expect(
calls.some(
(c) =>
c.table === 'whatsapp_phone_links' && c.method === 'is' && c.args[0] === 'revoked_at',
),
).toBe(true)
})
it('404s when there is no active link', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [] }) // no row matched
const response = await route.handler(request('/link/unmute'), makeCtx(supabase))
expect(response.status).toBe(404)
})
})
describe('GET /link', () => {
const route = findRoute('GET', '/link')
beforeEach(() => {
vi.clearAllMocks()
})
it('401s without an authenticated context', async () => {
const response = await route.handler(request('/link', 'GET'))
expect(response.status).toBe(401)
})
it('includes the 7-day health counters for the caller link only', async () => {
const userMock = createQueuedMockSupabase()
userMock.enqueue({
data: {
id: 'link-1',
phone_masked: '+46 70 *** ** 67',
default_company_id: null,
muted_at: null,
verified_at: '2026-08-01T09:00:00Z',
},
})
const serviceMock = createQueuedMockSupabase()
serviceMock.enqueue({
data: {
created_at: '2026-08-12T10:00:00Z',
processing_status: 'done',
error_message: null,
inbox_item_id: 'item-1',
},
}) // last inbound
serviceMock.enqueue({ data: { delivery_status: 'sent' } }) // last outbound
serviceMock.enqueue({ count: 2 }) // outboundFailed7d
serviceMock.enqueue({ count: 1 }) // parkedInbound7d
vi.mocked(createServiceClient).mockReturnValue(serviceMock.supabase as never)
const response = await route.handler(request('/link', 'GET'), makeCtx(userMock.supabase))
expect(response.status).toBe(200)
const { data } = (await response.json()) as {
data: { linked: boolean; health: { outboundFailed7d: number; parkedInbound7d: number } }
}
expect(data.linked).toBe(true)
expect(data.health).toEqual({ outboundFailed7d: 2, parkedInbound7d: 1 })
// Both head counts are keyed strictly by the link row RLS just proved
// the caller owns.
const linkScoped = serviceMock.calls.filter(
(c) =>
c.table === 'whatsapp_messages' &&
c.method === 'eq' &&
c.args[0] === 'phone_link_id' &&
c.args[1] === 'link-1',
)
expect(linkScoped.length).toBeGreaterThanOrEqual(4)
})
it('reports linked: false without touching the service client', async () => {
const userMock = createQueuedMockSupabase()
userMock.enqueue({ data: null }) // no active link row
const response = await route.handler(request('/link', 'GET'), makeCtx(userMock.supabase))
const { data } = (await response.json()) as { data: { linked: boolean } }
expect(data).toEqual({ linked: false })
expect(vi.mocked(createServiceClient)).not.toHaveBeenCalled()
})
})
@@ -20,7 +20,7 @@ vi.mock('@/extensions/general/whatsapp-inbox/lib/company-question', async () =>
>('@/extensions/general/whatsapp-inbox/lib/company-question')
return {
...actual,
askCompanyQuestion: vi.fn().mockResolvedValue(true),
askCompanyQuestion: vi.fn().mockResolvedValue('asked'),
}
})
@@ -142,7 +142,7 @@ describe('processInboundMessage (media intake)', () => {
beforeEach(() => {
vi.clearAllMocks()
sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT', errorDetail: null })
askCompanyQuestionMock.mockResolvedValue(true)
askCompanyQuestionMock.mockResolvedValue('asked')
rateLimitMock.mockResolvedValue({ ok: true })
downloadMediaMock.mockResolvedValue({
buffer: new Uint8Array([1, 2, 3, 4]).buffer,
@@ -272,6 +272,48 @@ describe('processInboundMessage (media intake)', () => {
})
})
it('releases the row for retry when the membership query fails (transient, never parked)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() }) // load row
enqueue({ data: { id: 'msg-1' } }) // claim
enqueue({ data: makeLink() }) // load link
enqueue({ data: makeConversation() }) // load conversation
enqueue({ error: { message: 'connection reset' } }) // memberships query fails
enqueue({ data: null }) // release back to received
const outcome = await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(outcome).toEqual({ kind: 'none' })
expect(askCompanyQuestionMock).not.toHaveBeenCalled()
expect(sendTextMock).not.toHaveBeenCalled()
expect(downloadMediaMock).not.toHaveBeenCalled()
expect(uploadAndExtractMock).not.toHaveBeenCalled()
// The claim was released untouched: the sweep re-runs it within a minute
// and gives up through the normal MAX_ATTEMPTS -> M18 path.
const release = lastUpdate(findCalls)
expect(release.processing_status).toBe('received')
})
it('un-parks the staged row when the company question hits a transient options failure', async () => {
askCompanyQuestionMock.mockResolvedValue('transient_error')
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }, { company_id: 'company-2' }] }) // ask path
enqueue({ data: null }) // markStatus skipped (staged)
enqueue({ data: null, count: 1 }) // staged count
enqueue({ data: null }) // un-park release
const outcome = await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(outcome).toEqual({ kind: 'none' })
const release = lastUpdate(findCalls)
expect(release.processing_status).toBe('received')
expect(release.error_message).toBeNull()
})
it('uses a live conversation pin over everything and stamps via=pin', async () => {
const pinExpiry = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString()
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
@@ -75,6 +75,7 @@ describe('runSweep', () => {
enqueue({ data: [] }) // unacked re-arm
enqueue({ data: [] }) // TTL scan
enqueue({ data: [] }) // pin scan
enqueue({ count: 2 }) // outbound failures, last 24h
const summary = await runSweep(supabase as unknown as SupabaseClient)
@@ -84,6 +85,7 @@ describe('runSweep', () => {
expect(summary.reclaimedReceived).toBe(1)
expect(summary.erroredMaxAttempts).toBe(1)
expect(summary.finalizedAcks).toBe(1)
expect(summary.outboundFailed24h).toBe(2)
const errorPatch = findCalls('whatsapp_messages', 'update')[0][0] as Record<string, unknown>
expect(errorPatch.processing_status).toBe('error')
@@ -284,6 +284,69 @@ describe('POST /webhook', () => {
expect(row.body_text).toBeUndefined()
})
it('media inside the hour but outside the 10 min burst window still gets M1', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null }) // no active link
enqueue({ data: { ok: true } }) // sender quota RPC
enqueue({
data: [{ created_at: new Date(Date.now() - 30 * 60 * 1000).toISOString() }],
}) // one greeting 30 min ago: inside the text hour, outside the burst
enqueue({ data: null, error: null }) // trace row insert ('done')
await route.handler(signedRequest(envelope({ messages: [imageMessage()] })))
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m1Unlinked)
// The privacy invariant holds: still no media touch for unlinked senders.
expect(vi.mocked(downloadMedia)).not.toHaveBeenCalled()
})
it('media inside the 10 min burst window stays silent (one M1 per burst)', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null })
enqueue({ data: { ok: true } })
enqueue({
data: [{ created_at: new Date(Date.now() - 2 * 60 * 1000).toISOString() }],
}) // greeted 2 min ago: same burst
enqueue({ count: 0 }) // decline-trace day cap
enqueue({ data: null, error: null }) // declined trace insert
await route.handler(signedRequest(envelope({ messages: [imageMessage()] })))
expect(sendTextMock).not.toHaveBeenCalled()
})
it('a text message inside the hour stays silent (hour rule unchanged for text)', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null })
enqueue({ data: { ok: true } })
enqueue({
data: [{ created_at: new Date(Date.now() - 30 * 60 * 1000).toISOString() }],
}) // greeted 30 min ago: text keeps the 1/hour rule
enqueue({ count: 0 }) // decline-trace day cap
enqueue({ data: null, error: null }) // declined trace insert
await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] })))
expect(sendTextMock).not.toHaveBeenCalled()
})
it('the fourth media greeting of the day stays silent (daily cap kept)', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null })
enqueue({ data: { ok: true } })
enqueue({
data: [
{ created_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString() },
{ created_at: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString() },
{ created_at: new Date(Date.now() - 9 * 60 * 60 * 1000).toISOString() },
],
}) // GREETING_DAY_MAX greetings already sent today
enqueue({ count: 0 }) // decline-trace day cap
enqueue({ data: null, error: null }) // declined trace insert
await route.handler(signedRequest(envelope({ messages: [imageMessage()] })))
expect(sendTextMock).not.toHaveBeenCalled()
})
it('stays silent but records the decline when the pre-binding quota is exhausted', async () => {
const mock = mockSupabase()
mock.enqueue({ data: null })
+55 -1
View File
@@ -262,7 +262,11 @@ async function handleUnknownSender(
// Anything else from an unknown number: the AI-disclosure greeting, hard
// throttled per phone hash (EU AI Act Art 50 disclosure lives in M1).
if (await greetingThrottled(supabase, phoneHash)) {
// Media bypasses the hour rule (10-minute burst window instead, daily cap
// kept): a photo is a receipt someone expects to be handled, and pure
// silence taught senders their receipts were being filed when nothing was.
const carriesMedia = msg.type === 'image' || msg.type === 'document'
if (await greetingThrottled(supabase, phoneHash, { media: carriesMedia })) {
await recordUnknownSenderMessage(
supabase, msg, phoneHash, 'skipped', 'Unknown sender: greeting throttled, declined',
)
@@ -859,6 +863,27 @@ export const whatsappInboxExtension: Extension = {
.limit(1)
.maybeSingle()
// Channel health for THIS link, 7-day window, head counts only: how
// many replies never reached the sender and how many inbound rows
// parked with a reason. The panel turns nonzero counts into one
// attention line; nothing else in the app reads delivery_status.
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
const { count: outboundFailed7d } = await serviceClient
.from('whatsapp_messages')
.select('id', { count: 'exact', head: true })
.eq('phone_link_id', row.id)
.eq('direction', 'outbound')
.eq('delivery_status', 'failed')
.gte('created_at', weekAgo)
const { count: parkedInbound7d } = await serviceClient
.from('whatsapp_messages')
.select('id', { count: 'exact', head: true })
.eq('phone_link_id', row.id)
.eq('direction', 'inbound')
.in('processing_status', ['skipped', 'error'])
.not('error_message', 'is', null)
.gte('created_at', weekAgo)
const inboundRow = lastInbound as {
created_at: string
processing_status: string
@@ -877,6 +902,10 @@ export const whatsappInboxExtension: Extension = {
lastReplyFailed:
(lastOutbound as { delivery_status: string | null } | null)?.delivery_status ===
'failed',
health: {
outboundFailed7d: outboundFailed7d ?? 0,
parkedInbound7d: parkedInbound7d ?? 0,
},
},
})
},
@@ -896,6 +925,31 @@ export const whatsappInboxExtension: Extension = {
},
},
{
method: 'POST',
path: '/link/unmute',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// Clears a stopp pause from the app (the in-chat counterpart is the
// literal text `start`). The user-scoped client is deliberate: RLS
// (whatsapp_phone_links_update_own) proves the caller owns the row.
// Idempotent on an unpaused link; only a missing active link 404s.
const { data } = await ctx.supabase
.from('whatsapp_phone_links')
.update({ muted_at: null })
.eq('user_id', ctx.userId)
.is('revoked_at', null)
.select('id')
if (!Array.isArray(data) || data.length === 0) {
return NextResponse.json(
{ error: 'Ingen aktiv WhatsApp-koppling hittades.' },
{ status: 404 },
)
}
return NextResponse.json({ data: { unmuted: true } })
},
},
{
method: 'POST',
path: '/link/default-company',
@@ -25,6 +25,7 @@ import {
import { botCopy, TEMPLATE } from './messages'
import {
COMPANY_PIN_TTL_MS,
NO_COMPANY_OPTIONS,
STAGED_AWAITING_COMPANY,
getContext,
updateConversation,
@@ -36,6 +37,11 @@ const log = createLogger('whatsapp-inbox/company-question')
/** Defensive ceiling on the numbered text list (>10 companies). */
const MAX_NUMBERED_OPTIONS = 30
/** M19 (no company to file into) is sent at most once per this window per
* conversation: every parked row of a burst walks through the no-options
* branch, and five photos must not earn five identical replies. */
const NO_OPTIONS_NOTICE_WINDOW_MS = 10 * 60 * 1000
export type CompanyChoiceVia = 'button' | 'list' | 'numbered'
export interface CompanyOption {
@@ -45,22 +51,33 @@ export interface CompanyOption {
type ReplyBase = Omit<SendMessageBase, 'to' | 'template'>
/** All companies the linked user belongs to, alphabetical (stable digits). */
/** All companies the linked user belongs to, alphabetical (stable digits).
* Returns null when a query FAILED: a transient DB error must read as
* "unknown", never as "no memberships", or the caller parks receipts behind
* a question that can never be asked. */
export async function loadCompanyOptions(
supabase: SupabaseClient,
userId: string,
): Promise<CompanyOption[]> {
const { data: memberships } = await supabase
): Promise<CompanyOption[] | null> {
const { data: memberships, error: membershipsError } = await supabase
.from('company_members')
.select('company_id')
.eq('user_id', userId)
if (membershipsError) {
log.warn('company options membership query failed', { error: membershipsError.message })
return null
}
const companyIds = [...new Set((memberships ?? []).map((m) => m.company_id as string))]
if (companyIds.length === 0) return []
const { data: companies } = await supabase
const { data: companies, error: companiesError } = await supabase
.from('companies')
.select('id, name')
.in('id', companyIds)
if (companiesError) {
log.warn('company options company query failed', { error: companiesError.message })
return null
}
const options = ((companies ?? []) as { id: string; name: string | null }[]).map((c) => ({
id: c.id,
name: c.name ?? 'Företag',
@@ -69,6 +86,17 @@ export async function loadCompanyOptions(
return options.slice(0, MAX_NUMBERED_OPTIONS)
}
/** How an ask attempt ended.
* - asked: this caller sent the question; rows stay staged for the answer.
* - not_asked: lost the guarded transition or the send failed; the episode
* is owned elsewhere (or re-triggers on the next receipt). Rows stay staged.
* - no_options: fewer than 2 companies genuinely exist, so no question can
* help; the staged rows were re-marked no_company_options and M19 told the
* sender to fix the linking in the app. Terminal by design.
* - transient_error: the options could not be LOADED (DB error): the caller
* must release its row back to 'received' so the sweep retries it. */
export type AskCompanyQuestionOutcome = 'asked' | 'not_asked' | 'no_options' | 'transient_error'
/**
* Ask the company question ONCE per open episode. The guarded state
* transition (WHERE state <> 'awaiting_company') makes concurrent workers of
@@ -79,8 +107,6 @@ export async function loadCompanyOptions(
* the rollback an expired token or a Graph 5xx left the conversation parked on
* a question the user never received, with the guard suppressing every later
* ask and the 48h TTL eventually discarding the staged receipts.
*
* Returns true when this caller sent the question.
*/
export async function askCompanyQuestion(
supabase: SupabaseClient,
@@ -92,15 +118,44 @@ export async function askCompanyQuestion(
/** How many receipts wait on the answer (question body wording). */
stagedCount: number
},
): Promise<boolean> {
): Promise<AskCompanyQuestionOutcome> {
const options = await loadCompanyOptions(supabase, args.link.user_id)
if (options === null) return 'transient_error'
if (options.length < 2) {
// Degenerate: resolution should have succeeded. Leave the rows staged;
// the sweep TTL cleans up if this persists.
// Genuinely degenerate (a real empty/1-length result, not a query error):
// the sender has nothing to choose between, so no question can help and
// nothing will change until they act in the app. The old behavior parked
// the rows silently forever; instead mark them with a terminal, greppable
// marker and say so in the chat.
log.warn('company question requested with fewer than 2 options', {
conversationId: args.conversation.id,
})
return false
await supabase
.from('whatsapp_messages')
.update({ error_message: NO_COMPANY_OPTIONS })
.eq('conversation_id', args.conversation.id)
.eq('processing_status', 'skipped')
.eq('error_message', STAGED_AWAITING_COMPANY)
// One M19 per burst window (same idiom as the M17 rate-limit notice).
const since = new Date(Date.now() - NO_OPTIONS_NOTICE_WINDOW_MS).toISOString()
const { data: noticeSent } = await supabase
.from('whatsapp_messages')
.select('id')
.eq('direction', 'outbound')
.eq('conversation_id', args.conversation.id)
.eq('raw_payload->>template', TEMPLATE.m19NoCompany)
.gte('created_at', since)
.limit(1)
.maybeSingle()
if (!noticeSent) {
await sendText(supabase, {
to: args.to,
body: botCopy('sv').m19NoCompany(),
template: TEMPLATE.m19NoCompany,
...args.replyBase,
})
}
return 'no_options'
}
const now = new Date()
@@ -119,7 +174,7 @@ export async function askCompanyQuestion(
.eq('id', args.conversation.id)
.neq('state', 'awaiting_company')
.select('*')
if (!Array.isArray(won) || won.length === 0) return false
if (!Array.isArray(won) || won.length === 0) return 'not_asked'
// Take updated_at (the revision guard) from the echoed row, but state and
// context from what we just wrote: that is what a rollback must match.
const committed: WhatsAppConversation = {
@@ -175,9 +230,9 @@ export async function askCompanyQuestion(
log.warn('company question send failed; question rolled back', {
conversationId: args.conversation.id,
})
return false
return 'not_asked'
}
return true
return 'asked'
}
export interface AppliedCompanyChoice {
@@ -30,19 +30,28 @@ import { TEMPLATE } from './messages'
const log = createLogger('whatsapp-inbox/conversation')
// The M1 "not linked" greeting is hard throttled per phone hash: 1/hour,
// 3/day, then silence. Shared by the unknown-sender path (webhook) and the
// revoked-mid-processing path (process-inbound), so it lives here.
// The M1 "not linked" greeting is hard throttled per phone hash: text gets
// 1/hour, media rides a 10-minute burst window instead (see below); both
// share the 3/day cap, then silence. Shared by the unknown-sender path
// (webhook) and the revoked-mid-processing path (process-inbound), so it
// lives here.
const GREETING_HOUR_MS = 60 * 60 * 1000
const GREETING_BURST_MS = 10 * 60 * 1000
const GREETING_DAY_MS = 24 * 60 * 60 * 1000
const GREETING_DAY_MAX = 3
/** True when another M1 greeting to this phone hash would exceed the cap.
* Fails CLOSED: if the throttle window cannot be read, no greeting goes
* out, matching the unknown-sender quota's stance. */
* out, matching the unknown-sender quota's stance.
*
* `media: true` marks a message that carried an image or document: the
* sender is handing over a receipt they expect to be handled, so repeats are
* suppressed only inside a 10-minute burst window (one burst of photos earns
* exactly one M1) instead of the full hour. The daily cap applies to both. */
export async function greetingThrottled(
supabase: SupabaseClient,
phoneHash: string,
opts: { media?: boolean } = {},
): Promise<boolean> {
const since = new Date(Date.now() - GREETING_DAY_MS).toISOString()
const { data, error } = await supabase
@@ -60,8 +69,8 @@ export async function greetingThrottled(
}
const rows = (data ?? []) as Array<{ created_at: string }>
if (rows.length >= GREETING_DAY_MAX) return true
const hourAgo = Date.now() - GREETING_HOUR_MS
return rows.some((r) => new Date(r.created_at).getTime() > hourAgo)
const windowStart = Date.now() - (opts.media ? GREETING_BURST_MS : GREETING_HOUR_MS)
return rows.some((r) => new Date(r.created_at).getTime() > windowStart)
}
export const COMPANY_PIN_TTL_MS = 8 * 60 * 60 * 1000
@@ -76,6 +85,11 @@ export const MAX_QUESTIONS_PER_DAY = 6
export const STAGED_AWAITING_COMPANY = 'staged_awaiting_company'
/** Marker after the 48h TTL expired: excluded from any later re-open. */
export const COMPANY_CHOICE_EXPIRED = 'company_choice_expired'
/** Terminal marker when the company question could not be asked at all:
* the sender has fewer than 2 companies to choose between, so nothing will
* change until they fix the linking in the app. The rows are never retried;
* M19 tells the sender why. */
export const NO_COMPANY_OPTIONS = 'no_company_options'
export type QuestionType = 'representation' | 'context' | 'resend'
export type ConversationQuestionType = QuestionType | 'company'
@@ -56,6 +56,7 @@ export const TEMPLATE = {
m17RateLimited: 'm17_rate_limited',
m17RateLimitedDay: 'm17_rate_limited_day',
m18Error: 'm18_error',
m19NoCompany: 'm19_no_company',
} as const
export type TemplateId = (typeof TEMPLATE)[keyof typeof TEMPLATE]
@@ -194,6 +195,13 @@ const SV = {
m18Error: () =>
'Något gick fel när jag tog emot filen. Försök igen om en stund, eller ladda upp kvittot direkt i appen under *Underlag*. Skriv *hjälp* om det fortsätter.',
// The company question could not even be asked: the linked user has fewer
// than 2 companies to choose between, so the receipt cannot be filed and
// nothing changes until they act in the app. Honest and actionable beats
// the old silent parking.
m19NoCompany: () =>
'Jag kunde inte koppla kvittot till något företag. Öppna Accounted och kontrollera WhatsApp-kopplingen under *Inställningar -> WhatsApp*, och skicka sedan kvittot igen.',
}
const EN: typeof SV = {
@@ -318,6 +326,9 @@ const EN: typeof SV = {
m18Error: () =>
'Something went wrong receiving the file. Try again in a moment, or upload the receipt directly in the app under *Underlag*. Type *hjälp* if it keeps happening.',
m19NoCompany: () =>
'I could not assign the receipt to any company. Open Accounted and check the WhatsApp linking under *Settings -> WhatsApp*, then send the receipt again.',
}
const COPY: Record<BotLocale, typeof SV> = { sv: SV, en: EN }
@@ -285,13 +285,18 @@ interface ResolvedCompany {
/**
* Conversation pin (live + still a member, sliding 8h) -> default company
* (still a member) -> sole membership -> null (ask).
*
* Returns 'transient_error' when the membership query FAILED: a DB blip must
* not read as "no memberships", which used to park the rows behind an
* unanswerable company question forever. The caller releases the row so the
* sweep retries it.
*/
async function resolveCompanyTarget(
supabase: SupabaseClient,
link: WhatsAppPhoneLink,
conversation: WhatsAppConversation | null,
selectedViaOverride: CompanyChoiceVia | undefined,
): Promise<ResolvedCompany | null> {
): Promise<ResolvedCompany | null | 'transient_error'> {
const now = new Date()
if (conversation && hasLivePin(conversation, now) && conversation.company_id) {
@@ -318,10 +323,16 @@ async function resolveCompanyTarget(
return { companyId: link.default_company_id, via: 'default' }
}
const { data: memberships } = await supabase
const { data: memberships, error: membershipsError } = await supabase
.from('company_members')
.select('company_id')
.eq('user_id', link.user_id)
if (membershipsError) {
log.warn('membership query failed during company resolution; will retry', {
error: membershipsError.message,
})
return 'transient_error'
}
const companyIds = [...new Set((memberships ?? []).map((m) => m.company_id as string))]
if (companyIds.length === 1) return { companyId: companyIds[0], via: 'single' }
return null
@@ -457,6 +468,17 @@ async function processMediaMessage(
// ── Company resolution ─────────────────────────────────
const resolved = await resolveCompanyTarget(supabase, link, conversation, opts.companySelectedVia)
if (resolved === 'transient_error') {
// Release the claim untouched: the sweep re-runs this row within a
// minute and gives up through the normal MAX_ATTEMPTS -> M18 path if
// the failure persists. Parking it as skipped would be permanent.
await supabase
.from('whatsapp_messages')
.update({ processing_status: 'received' })
.eq('id', row.id)
.eq('processing_status', 'processing')
return { kind: 'none' }
}
if (!resolved) {
if (!conversation) {
await markStatus(supabase, row.id, 'error', {
@@ -474,13 +496,25 @@ async function processMediaMessage(
.eq('conversation_id', conversation.id)
.eq('processing_status', 'skipped')
.eq('error_message', STAGED_AWAITING_COMPANY)
await askCompanyQuestion(supabase, {
const askOutcome = await askCompanyQuestion(supabase, {
conversation,
link,
to,
replyBase,
stagedCount: count ?? 1,
})
if (askOutcome === 'transient_error') {
// The options could not even be loaded: un-park this row so the
// sweep retries it, instead of leaving it staged behind a question
// that was never asked (and might never be).
await supabase
.from('whatsapp_messages')
.update({ processing_status: 'received', error_message: null })
.eq('id', row.id)
.eq('processing_status', 'skipped')
.eq('error_message', STAGED_AWAITING_COMPANY)
return { kind: 'none' }
}
return { kind: 'media_staged', conversationId: conversation.id }
}
const companyId = resolved.companyId
@@ -18,6 +18,9 @@
* questions keep their options and their parked receipts, so a late
* answer still files them (see the pass itself).
* 4. Clear expired 8h company pins.
* 5. Count outbound sends that failed in the last 24h, so the per-minute
* "whatsapp sweep complete" log line surfaces delivery problems nothing
* else reads (delivery_status is otherwise write-only).
*/
import type { SupabaseClient } from '@supabase/supabase-js'
@@ -64,6 +67,10 @@ export interface SweepSummary {
finalizedAcks: number
expiredQuestions: number
clearedPins: number
/** Outbound rows with delivery_status='failed' created in the last 24h
* (Graph send failure or a Meta 'failed' status callback). Observability
* only: the sweep log line is the consumer. */
outboundFailed24h: number
}
interface StuckRow {
@@ -133,6 +140,7 @@ export async function runSweep(supabase: SupabaseClient): Promise<SweepSummary>
finalizedAcks: 0,
expiredQuestions: 0,
clearedPins: 0,
outboundFailed24h: 0,
}
const finalizeConversations = new Set<string>()
const now = Date.now()
@@ -380,5 +388,19 @@ export async function runSweep(supabase: SupabaseClient): Promise<SweepSummary>
log.error('sweep: pin expiry pass failed', err)
}
// ── 5. Outbound delivery failures, last 24h (one head count) ──
try {
const since = new Date(now - 24 * 60 * 60 * 1000).toISOString()
const { count } = await supabase
.from('whatsapp_messages')
.select('id', { count: 'exact', head: true })
.eq('direction', 'outbound')
.eq('delivery_status', 'failed')
.gte('created_at', since)
summary.outboundFailed24h = count ?? 0
} catch (err) {
log.error('sweep: outbound failure count failed', err)
}
return summary
}