* 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>
79 lines
2.9 KiB
TypeScript
79 lines
2.9 KiB
TypeScript
/**
|
|
* 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 }
|
|
}
|