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:
@@ -948,9 +948,15 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-13] Per-account VAT treatment is class-aware; explicit values extend custom accounts while canonical accounts keep their static BAS momsdeklaration mapping. SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. VMB carries no default account rate because its VAT base is the margin, not gross sales.
|
||||
[2026-08-13] Issue #1408 separates evidence classification from lock and VAT overlays, and treats import and correction sources as false positives for live-template provenance: 1,316 of 1,361 production signature matches came from those sources, so a broad account signature must not become a correction queue.
|
||||
[2026-08-13] Issue #1408 does not route locked candidates through the current correctEntry service or an unlock: the service creates its storno in the original period, so a compliant locked-period correction needs a separately tested open-period storno and replacement path that preserves the original and full rättelse trail.
|
||||
[2026-08-13] Web picker no longer advertises HEIC/HEIF (accept lists in InvoiceInboxWorkspace): supersedes the 2026-08-01 HEIC decision (runtime sharp transcode + hint). The transcode+hint stays as fallback for drag-drop/API/channel HEIC, but without heic in accept iOS transcodes photo-library picks to JPEG, which makes AI extraction work and shrinks 413 risk; magic-byte validation now covers real HEIC/HEIF via ftyp brands instead of a blanket exemption.
|
||||
[2026-08-13] Skipped the planned lib/browser/downscale-image.ts module: lib/documents/shrink-image.ts + upload-size.ts already shipped the client-side downscale wired into the inbox uploadFile (hosted 4 MB ceiling, 2400px long edge, honest refusal message); a second overlapping compressor with different thresholds would create contradictory policies.
|
||||
[2026-08-13] WhatsApp M1 greeting: media from unlinked senders bypasses the 1-hour throttle behind a 10-minute burst window (3/day cap kept): a photo is a receipt someone expects to be handled, and pure hour-long silence taught senders their receipts were being filed; one burst still earns exactly one reply. In-channel silence after stopp stays absolute (only the literal start is read): stopp is an opt-out and any other reply would violate it, so the escape hatch is app-side instead (POST /link/unmute + panel button), where the user is already authenticated and asking.
|
||||
[2026-08-13] Staged inbox upload re-adds 'processing' to invoice_inbox_items.status (migration 20260813213000, supersedes 20260504180000's tightening, which assumed extraction stays synchronous): web uploads insert the row before Bedrock runs with extracted_data NULL, so the existing MCP create-from-inbox null-guard blocks booking in-flight rows for free; the gnubok_list_inbox_items status filter enum was deliberately NOT widened (tools/list payload sits ~25 tokens under the 57 500-token cap per 2026-07-26; unfiltered listings already return 'processing' verbatim); the sweep cron flips stale processing rows to 'received' with the empty skeleton instead of re-extracting (the UI retry button covers re-runs, and a cron silently re-spending Bedrock tokens on every crash would hide the crashes).
|
||||
[2026-08-13] payment_match_log CHECK migration renamed 20260813120000 -> 20260813210000 at merge time: main gained its own 20260813120000 (fix_franvaro_audit_trigger_definer) while this branch was in flight, and identical versions abort the Supabase apply with a schema_migrations_pkey duplicate. Staging's tracking row was updated to the new version in the same step, freeing the 20260813120000 slot for main's migration when staging reconciles.
|
||||
[2026-08-13] PR #1598, compliance findings closed with the rollout after the Swedish accounting review escalated them from follow-up to fix-with-rollout: (a) runReconciliation's >= 0.9 auto-apply now writes 'matched' to payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); (b) the three match-route storno-conflict branches no longer storno-reverse a reconciliation-linked verifikat: a reconciliation link points at an independent verifikat that may evidence other affarshandelser, and a wholesale reversal is an over-broad rattelse (BFL 5 kap 5 §). The detach is DEFERRED (round 2, CodeRabbit): nothing is persisted up front; the final transaction update overwrites the pointer and clears reconciliation_method in the same write, so a failure anywhere in the match flow leaves the existing link intact, and the release is logged as 'unmatched' after the commit.
|
||||
[2026-08-13] PR #1598, CodeRabbit findings: confirm-suggestions maxDuration 300; lookbackTouched on the migrator nudge buttons; persistSuggestions on main's post-backfill sweep; sie_sweep stamp errors logged; sandbox keeps the CSV CTA (file import works there); payment_match_log CHECK swap now NOT VALID + VALIDATE (no table-scan under ACCESS EXCLUSIVE); every logMatchEvent call awaited (serverless can freeze unawaited work).
|
||||
[2026-08-13] Historical audit gap quantified on prod (read-only): 762 manual-method links across 52 companies since 2026-03-23 have no payment_match_log row (upper bound: includes linked_to_existing_voucher drops AND older unlogged manual paths). Not backfillable (the inserts never landed); the links themselves are intact on transactions. Recorded here as the explicit ops note the compliance review asked for.
|
||||
[2026-08-13] Startkort empty states use inline gradient scrims over their strata images: this is imagery treatment inside a hero surface, not card chrome, so the "no bg-gradient on cards" rule deliberately does not apply there (and nowhere else).
|
||||
[2026-08-13] Startkort webp assets (public/startkort/) are rendered outputs from the strata-engine in the CRM workspace, with per-file sources and flags recorded in components/dashboard/startkort-assets.ts; regenerate there, never edit the webp files by hand.
|
||||
|
||||
[2026-08-13] Renaming a migration after the Supabase preview applied it orphans the PREVIEW tracker, not prod: the preview branch for PR 1605 had recorded 20260813180000, the rename to 20260813213000 removed that file, and the next Migrations task aborted with "Remote migration versions not found in local migrations directory". Repair = delete the orphaned version row from supabase_migrations.schema_migrations on the preview branch project only (prod never applied it, so merge-time apply is unaffected), then re-run tasks with a fresh commit. The migration itself is drop-if-exists idempotent so the replay under the new version is safe.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
vi.mock('@/lib/extensions/loader', () => ({
|
||||
loadExtensions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/extensions/registry', () => ({
|
||||
extensionRegistry: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', () => ({
|
||||
createServiceClientNoCookies: vi.fn().mockReturnValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/invoice-inbox/lib/sweep', () => ({
|
||||
runInboxSweep: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/cron', () => ({
|
||||
verifyCronSecret: vi.fn().mockReturnValue(null),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { loadExtensions } from '@/lib/extensions/loader'
|
||||
import { runInboxSweep } from '@/extensions/general/invoice-inbox/lib/sweep'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
|
||||
const mockRegistryGet = vi.mocked(extensionRegistry.get)
|
||||
const mockVerifyCronSecret = vi.mocked(verifyCronSecret)
|
||||
const mockRunInboxSweep = vi.mocked(runInboxSweep)
|
||||
|
||||
function makeRequest() {
|
||||
return new Request('http://localhost/api/extensions/invoice-inbox/sweep/cron', {
|
||||
headers: { authorization: 'Bearer synthetic-cron-secret' },
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockVerifyCronSecret.mockReturnValue(null)
|
||||
})
|
||||
|
||||
describe('GET /api/extensions/invoice-inbox/sweep/cron', () => {
|
||||
it('returns 401 when the cron secret is rejected', async () => {
|
||||
mockVerifyCronSecret.mockReturnValue(
|
||||
NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
)
|
||||
|
||||
const response = await GET(makeRequest())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockRunInboxSweep).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 503 EXTENSION_DISABLED when the extension is not in the registry', async () => {
|
||||
// Physical extension routes deploy in every build; the registry, generated
|
||||
// from extensions.config.json, is what turns them on. Disabled must mean
|
||||
// no sweeping AND a visible failure if the cron is scheduled anyway.
|
||||
mockRegistryGet.mockReturnValue(undefined)
|
||||
|
||||
const response = await GET(makeRequest())
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(body.code).toBe('EXTENSION_DISABLED')
|
||||
expect(mockRunInboxSweep).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs the sweep and returns its summary when enabled', async () => {
|
||||
mockRegistryGet.mockReturnValue({ id: 'invoice-inbox' } as never)
|
||||
mockRunInboxSweep.mockResolvedValue({ flipped: 3 })
|
||||
|
||||
const response = await GET(makeRequest())
|
||||
const body = await response.json()
|
||||
|
||||
expect(loadExtensions).toHaveBeenCalled()
|
||||
expect(mockRegistryGet).toHaveBeenCalledWith('invoice-inbox')
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data).toEqual({ flipped: 3 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { loadExtensions } from '@/lib/extensions/loader'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { withCronContext } from '@/lib/api/with-cron-context'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { runInboxSweep } from '@/extensions/general/invoice-inbox/lib/sweep'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/invoice-inbox/sweep/cron: crash recovery for the
|
||||
* staged upload. Flips invoice_inbox_items rows stuck in 'processing' (a
|
||||
* deferred extraction worker that died with its instance) to 'received'
|
||||
* with the empty extraction skeleton. No re-extraction here: the UI retry
|
||||
* button covers that. Scheduled every 2 minutes in vercel.json (and the
|
||||
* Docker crontabs).
|
||||
*
|
||||
* Overlap with a slow previous run is safe: the flip is a guarded claim on
|
||||
* status='processing' with extracted_data still NULL.
|
||||
*/
|
||||
|
||||
// The work is one indexed select plus one guarded update: seconds, not
|
||||
// minutes. Kept well under the WhatsApp sweep's 300s Bedrock budget.
|
||||
export const maxDuration = 60
|
||||
|
||||
export const GET = withCronContext('cron.invoice_inbox_sweep', async (_request, ctx) => {
|
||||
// Load the registry so it reflects extensions.config.json.
|
||||
loadExtensions()
|
||||
|
||||
// Physical routes under app/api/extensions/<id>/ compile into EVERY build,
|
||||
// including the core-with-zero-extensions one: the registry (generated from
|
||||
// extensions.config.json) is what actually switches an extension on. Mirror
|
||||
// the ext/[...path] dispatcher: a disabled extension must not expose a live
|
||||
// surface, and a scheduled-but-disabled cron must fail visibly (503)
|
||||
// instead of quietly doing the work anyway.
|
||||
if (!extensionRegistry.get('invoice-inbox')) {
|
||||
ctx.log.warn('invoice-inbox extension is not enabled; cron refused')
|
||||
return NextResponse.json(
|
||||
{ error: 'Invoice inbox extension is not enabled', code: 'EXTENSION_DISABLED' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = createServiceClientNoCookies()
|
||||
const summary = await runInboxSweep(supabase)
|
||||
|
||||
ctx.log.info('invoice inbox sweep complete', { ...summary })
|
||||
|
||||
return NextResponse.json({ data: summary })
|
||||
})
|
||||
@@ -79,6 +79,7 @@ describe('GET /api/extensions/whatsapp-inbox/sweep/cron', () => {
|
||||
finalizedAcks: 1,
|
||||
expiredQuestions: 1,
|
||||
clearedPins: 0,
|
||||
outboundFailed24h: 3,
|
||||
})
|
||||
|
||||
const response = await GET(makeRequest())
|
||||
@@ -94,6 +95,7 @@ describe('GET /api/extensions/whatsapp-inbox/sweep/cron', () => {
|
||||
finalizedAcks: 1,
|
||||
expiredQuestions: 1,
|
||||
clearedPins: 0,
|
||||
outboundFailed24h: 3,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1040,7 +1040,8 @@ export async function POST(request: Request) {
|
||||
// commitCreateSupplierInvoiceFromInbox does an idempotency + FK lookup
|
||||
// against invoice_inbox_items by inbox_item_id before it creates anything,
|
||||
// so the "Godkänn" path can only succeed if a real inbox row exists.
|
||||
// status is constrained to 'received' | 'error' (migration 20260504180000).
|
||||
// status is constrained to 'received' | 'processing' | 'error' (migration
|
||||
// 20260813213000); seeded rows are always 'received'.
|
||||
const { data: inboxRow, error: inboxError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
|
||||
@@ -141,7 +141,10 @@ function reportUploadFailure(report: {
|
||||
|
||||
interface InboxItem {
|
||||
id: string
|
||||
status: 'received' | 'error'
|
||||
// 'processing' is the staged-upload in-flight state: the row exists (the
|
||||
// instant receipt ack) but the deferred AI extraction has not landed;
|
||||
// extracted_data is null until the realtime flip to 'received'.
|
||||
status: 'received' | 'processing' | 'error'
|
||||
source: 'email' | 'upload' | 'whatsapp'
|
||||
created_at: string
|
||||
email_from: string | null
|
||||
@@ -290,11 +293,16 @@ function countExtractedFields(data: InvoiceExtractionResult | null): number {
|
||||
// payment still needs booking (a document attached to a transaction is not
|
||||
// the same as a booked one). An extraction failure is "error"; everything
|
||||
// else needs a first action.
|
||||
type InboxStatus = 'needs_action' | 'linked' | 'booked' | 'error'
|
||||
type InboxStatus = 'needs_action' | 'processing' | 'linked' | 'booked' | 'error'
|
||||
|
||||
function deriveInboxStatus(item: InboxItem): InboxStatus {
|
||||
if (item.created_supplier_invoice_id || item.created_journal_entry_id) return 'booked'
|
||||
if (item.matched_transaction_journal_entry_id) return 'booked'
|
||||
// Staged upload mid-extraction. Outranks 'linked': a transaction-anchored
|
||||
// upload is matched from birth, but offering the booking bridge before the
|
||||
// fields exist would book from empty data. Transient (seconds): stays in
|
||||
// "Att göra" via the todo bucket rather than earning its own pill.
|
||||
if (item.status === 'processing') return 'processing'
|
||||
if (item.matched_transaction_id) return 'linked'
|
||||
if (item.status === 'error') return 'error'
|
||||
return 'needs_action'
|
||||
@@ -782,6 +790,18 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
}
|
||||
}, [toast, loadDocument])
|
||||
|
||||
// The detail pane renders from its own fetched snapshot (`selected`), so
|
||||
// the realtime refetch updates the list row but would leave a selected
|
||||
// staged upload stuck on the in-flight skeleton after the processing ->
|
||||
// received flip. Re-read the detail when the list shows the flip landed.
|
||||
useEffect(() => {
|
||||
if (!selected || selected.isPlaceholder || selected.status !== 'processing') return
|
||||
const listRow = items.find((it) => it.id === selected.id)
|
||||
if (listRow && listRow.status !== 'processing') {
|
||||
void handleSelect(selected.id)
|
||||
}
|
||||
}, [items, selected, handleSelect])
|
||||
|
||||
// ── Upload ─────────────────────────────────────────────────
|
||||
|
||||
// `autoSelect`: jump the detail pane to the new placeholder/row. Useful
|
||||
@@ -1201,11 +1221,15 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* No image/heic or image/heif here on purpose: when HEIC is absent
|
||||
from accept, iOS Safari transcodes photo-library picks to JPEG,
|
||||
which AI extraction can read. The server allowlist still accepts
|
||||
HEIC for drag-drop and the email/WhatsApp channels. */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="application/pdf,image/jpeg,image/png,image/heic,image/heif,image/webp"
|
||||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
@@ -1676,11 +1700,13 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Same accept list as the header input: HEIC left out so iOS
|
||||
delivers JPEG from the photo library. */}
|
||||
<input
|
||||
ref={purchaseFileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="application/pdf,image/jpeg,image/png,image/heic,image/heif,image/webp"
|
||||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||
onChange={async (e) => {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
if (files.length > 0) await uploadForPurchase(files, selectedPurchase.id)
|
||||
@@ -2014,6 +2040,9 @@ function InboxRow({
|
||||
const isErrored = status === 'error'
|
||||
const isBooked = status === 'booked'
|
||||
const isLinkedToTransaction = status === 'linked'
|
||||
// Staged upload: the row is real (that IS the "mottaget" ack) but the
|
||||
// deferred AI extraction is still in flight. The realtime refetch flips it.
|
||||
const isExtracting = status === 'processing'
|
||||
// A chat question the sender never answered (48h TTL hit): the missing
|
||||
// info should be completed here instead. Quiet hint, not a status: the
|
||||
// item still books normally. Booked items drop the reminder.
|
||||
@@ -2098,6 +2127,14 @@ function InboxRow({
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
{isPlaceholder ? (
|
||||
<span className="italic">Tolkar dokument med AI…</span>
|
||||
) : isExtracting ? (
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
<Badge variant="outline" className="font-normal">
|
||||
<Loader2 className="h-2.5 w-2.5 mr-1 animate-spin" />
|
||||
{t('processing_chip')}
|
||||
</Badge>
|
||||
{receivedMeta}
|
||||
</span>
|
||||
) : item.extraction_skipped || hasUnansweredQuestion ? (
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
{item.extraction_skipped && (
|
||||
@@ -2631,6 +2668,11 @@ function FieldsRail({
|
||||
// is not resolved, it's the prerequisite for booking against that tx.
|
||||
const isLinkedToTransaction = !isProcessed && !isBookedDirectly && !!item.matched_transaction_id
|
||||
const isResolved = isProcessed || isBookedDirectly
|
||||
// Staged upload mid-extraction: a real row whose deferred AI extraction has
|
||||
// not landed yet. Same disabled treatment as the optimistic placeholder
|
||||
// (skeleton fields, no actions); the realtime flip re-enables everything.
|
||||
const isExtracting = !item.isPlaceholder && item.status === 'processing'
|
||||
const inFlight = !!item.isPlaceholder || isExtracting
|
||||
const [isUnmatchingTx, setIsUnmatchingTx] = useState(false)
|
||||
const [isRetrying, setIsRetrying] = useState(false)
|
||||
// Larger edit surface for the extracted fields (the rail is deliberately
|
||||
@@ -2869,12 +2911,47 @@ function FieldsRail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extraction produced nothing (a crashed deferred worker swept back to
|
||||
'received', a swallowed failure, or a skipped run): offer the AI
|
||||
re-run right where the empty fields are. Mirrors the error-state
|
||||
retry above, which only renders when error_message is set. HEIC is
|
||||
excluded: Bedrock cannot read it, so a retry cannot succeed. */}
|
||||
{!inFlight &&
|
||||
!isResolved &&
|
||||
!item.error_message &&
|
||||
hasAi &&
|
||||
!!item.document_id &&
|
||||
!hasAnyExtractedField(data) &&
|
||||
docMime !== 'image/heic' &&
|
||||
docMime !== 'image/heif' && (
|
||||
<div className="border-b px-4 py-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs"
|
||||
onClick={handleRetry}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
{isRetrying ? (
|
||||
<Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-3 w-3 mr-1.5" />
|
||||
)}
|
||||
{t('retry_extraction')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
||||
{/* The proposed kontering comes first: it is the decision. The fields
|
||||
are the evidence you check when the decision looks wrong, so they
|
||||
fold. Reading order used to be the other way round, which meant
|
||||
scrolling past nine values to reach the one thing to approve. */}
|
||||
{isLinkedToTransaction && <ProposedBooking itemId={item.id} onLoaded={setProposal} />}
|
||||
scrolling past nine values to reach the one thing to approve.
|
||||
Suppressed while extraction is in flight: a proposal computed from
|
||||
empty fields would be an invitation to book nothing. */}
|
||||
{isLinkedToTransaction && !inFlight && (
|
||||
<ProposedBooking itemId={item.id} onLoaded={setProposal} />
|
||||
)}
|
||||
|
||||
<details className="group" open={!isLinkedToTransaction}>
|
||||
<summary className="flex items-center gap-1.5 cursor-pointer list-none text-xs uppercase tracking-wide text-muted-foreground font-medium hover:text-foreground">
|
||||
@@ -2883,7 +2960,7 @@ function FieldsRail({
|
||||
{/* A count, not a score. "5 av 12" read as a bad extraction even
|
||||
when a kvitto had given up everything a kvitto has: half those
|
||||
twelve fields only exist on an invoice. */}
|
||||
{!item.isPlaceholder && countExtractedFields(data) > 0 && (
|
||||
{!inFlight && countExtractedFields(data) > 0 && (
|
||||
<span className="tabular-nums normal-case tracking-normal">
|
||||
{t('fields_filled', { count: countExtractedFields(data) })}
|
||||
</span>
|
||||
@@ -2891,7 +2968,7 @@ function FieldsRail({
|
||||
{/* Kept from main: the fields are readable at rail width but not
|
||||
comfortable, so the expand still earns its place inside the
|
||||
fold. stopPropagation, or the summary would toggle under it. */}
|
||||
{!item.isPlaceholder && (hasAnyExtractedField(data) || hasAi) && (
|
||||
{!inFlight && (hasAnyExtractedField(data) || hasAi) && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -2910,7 +2987,7 @@ function FieldsRail({
|
||||
)}
|
||||
</summary>
|
||||
<div className="pt-3">
|
||||
{item.isPlaceholder ? (
|
||||
{inFlight ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground italic flex items-center gap-2 mb-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
@@ -2950,8 +3027,9 @@ function FieldsRail({
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Actions: hidden while AI extraction is in flight */}
|
||||
{!item.isPlaceholder && (
|
||||
{/* Actions: hidden while AI extraction is in flight (optimistic
|
||||
placeholder AND staged 'processing' rows alike). */}
|
||||
{!inFlight && (
|
||||
<div className="border-t px-4 py-3 space-y-2">
|
||||
{isProcessed && item.created_supplier_invoice_id ? (
|
||||
<Link href={`/supplier-invoices/${item.created_supplier_invoice_id}`} className="block">
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
* Unlinked: mint a one-time code (10 min TTL) + wa.me deep link; the user
|
||||
* sends the code from their phone and the webhook binds the number.
|
||||
* Linked: masked phone, default-company select (multi-company routing),
|
||||
* revoke. Muted (user sent *stopp* in chat) shows a hint: unmuting happens
|
||||
* in the chat with *start*, not here.
|
||||
* revoke. Muted (user sent *stopp* in chat) can be lifted here (or with
|
||||
* *start* in the chat); no proactive confirmation is possible afterwards
|
||||
* (24h service window, v1 sends no templates), so the hint says to just
|
||||
* send the next receipt. A nonzero 7-day failure count from the health
|
||||
* object renders as one attention line.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
@@ -50,6 +53,10 @@ interface LinkStatus {
|
||||
lastInboundAt?: string | null
|
||||
lastInboundEvent?: string | null
|
||||
lastReplyFailed?: boolean
|
||||
health?: {
|
||||
outboundFailed7d: number
|
||||
parkedInbound7d: number
|
||||
} | null
|
||||
}
|
||||
|
||||
interface MintedCode {
|
||||
@@ -154,6 +161,20 @@ export function WhatsAppLinkPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const unmute = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const response = await fetch(`${BASE}/link/unmute`, { method: 'POST' })
|
||||
if (!response.ok) throw new Error('unmute failed')
|
||||
toast({ title: t('unmuted_toast') })
|
||||
await fetchStatus()
|
||||
} catch {
|
||||
toast({ title: t('unmute_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-6 text-sm text-muted-foreground">
|
||||
@@ -174,15 +195,33 @@ export function WhatsAppLinkPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
const healthIssues =
|
||||
(status?.health?.outboundFailed7d ?? 0) + (status?.health?.parkedInbound7d ?? 0)
|
||||
|
||||
if (status?.linked) {
|
||||
return (
|
||||
<SettingsGroup label={t('group_label')}>
|
||||
{healthIssues > 0 ? (
|
||||
<div className="px-1 pt-3">
|
||||
<AttnLine>{t('health_warning', { count: healthIssues })}</AttnLine>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SettingsRow label={t('linked_number_label')}>
|
||||
<span className="font-mono text-sm">{status.phoneMasked}</span>
|
||||
</SettingsRow>
|
||||
|
||||
{status.muted ? (
|
||||
<SettingsRow label={t('muted_label')}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
onClick={() => void unmute()}
|
||||
>
|
||||
{t('unmute_button')}
|
||||
</Button>
|
||||
<SettingsRowNote>{t('muted_hint')}</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
|
||||
@@ -43,5 +43,6 @@
|
||||
* * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/webhooks/dispatch/cron
|
||||
* * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/sweep/cron
|
||||
15 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/retention/cron
|
||||
*/2 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/sweep/cron
|
||||
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
|
||||
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
|
||||
|
||||
@@ -43,5 +43,6 @@
|
||||
* * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/webhooks/dispatch/cron
|
||||
* * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/sweep/cron
|
||||
15 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/retention/cron
|
||||
*/2 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/sweep/cron
|
||||
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
|
||||
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
createNewVersion,
|
||||
deleteDocument,
|
||||
verifyIntegrity,
|
||||
detectFileMagic,
|
||||
validateDocumentMagicBytes,
|
||||
buildDocumentStoragePath,
|
||||
buildPendingDocumentStoragePath,
|
||||
@@ -273,6 +274,59 @@ describe('validateDocumentMagicBytes: PDF header offset tolerance', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateDocumentMagicBytes: HEIC/HEIF (ISO-BMFF ftyp brands)', () => {
|
||||
// Minimal ISO-BMFF head: a 16-byte ftyp box whose major brand is `brand`.
|
||||
// Real files carry compatible brands and media data after this, but the
|
||||
// detector only reads the first 12 bytes.
|
||||
const isoBmff = (brand: string): ArrayBuffer => {
|
||||
const bytes = new Uint8Array(16)
|
||||
bytes[3] = 16 // box size (big-endian 0x00000010)
|
||||
bytes.set([0x66, 0x74, 0x79, 0x70], 4) // 'ftyp'
|
||||
bytes.set(new TextEncoder().encode(brand), 8)
|
||||
return bytes.buffer as ArrayBuffer
|
||||
}
|
||||
const jpegBytes = (): ArrayBuffer =>
|
||||
new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46]).buffer as ArrayBuffer
|
||||
|
||||
it('detects HEVC-coded brands as image/heic and MIAF brands as image/heif', () => {
|
||||
for (const brand of ['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'hevm', 'hevs']) {
|
||||
expect(detectFileMagic(new Uint8Array(isoBmff(brand)))).toBe('image/heic')
|
||||
}
|
||||
for (const brand of ['mif1', 'msf1']) {
|
||||
expect(detectFileMagic(new Uint8Array(isoBmff(brand)))).toBe('image/heif')
|
||||
}
|
||||
// Other ISO-BMFF brands (video containers) stay undetected.
|
||||
expect(detectFileMagic(new Uint8Array(isoBmff('isom')))).toBeNull()
|
||||
expect(detectFileMagic(new Uint8Array(isoBmff('qt ')))).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts a heic-brand file under both declared family members', () => {
|
||||
expect(validateDocumentMagicBytes(isoBmff('heic'), 'image/heic')).toBeNull()
|
||||
expect(validateDocumentMagicBytes(isoBmff('heic'), 'image/heif')).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts a mif1-brand file under both declared family members', () => {
|
||||
expect(validateDocumentMagicBytes(isoBmff('mif1'), 'image/heif')).toBeNull()
|
||||
expect(validateDocumentMagicBytes(isoBmff('mif1'), 'image/heic')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects garbage bytes declared image/heic (formerly blanket-exempted)', () => {
|
||||
const garbage = new TextEncoder().encode('this is not an image at all')
|
||||
const buffer = garbage.buffer.slice(garbage.byteOffset, garbage.byteOffset + garbage.byteLength) as ArrayBuffer
|
||||
expect(validateDocumentMagicBytes(buffer, 'image/heic')).toMatch(/kunde inte verifieras/)
|
||||
expect(validateDocumentMagicBytes(buffer, 'image/heif')).toMatch(/kunde inte verifieras/)
|
||||
})
|
||||
|
||||
it('rejects JPEG bytes declared image/heic as a type mismatch', () => {
|
||||
expect(validateDocumentMagicBytes(jpegBytes(), 'image/heic')).toMatch(/matchar inte/)
|
||||
})
|
||||
|
||||
it('does not loosen validation for other declared types', () => {
|
||||
// HEIC bytes declared as JPEG must still be rejected as a mismatch.
|
||||
expect(validateDocumentMagicBytes(isoBmff('heic'), 'image/jpeg')).toMatch(/matchar inte/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadDocument', () => {
|
||||
it('computes SHA-256 hash, stores metadata, emits document.uploaded', async () => {
|
||||
const doc = makeDocumentAttachment({
|
||||
|
||||
@@ -249,9 +249,29 @@ export function detectFileMagic(bytes: Uint8Array): string | null {
|
||||
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
|
||||
) return 'image/webp'
|
||||
// HEIC/HEIF (ISO-BMFF): bytes 4-7 spell 'ftyp'; the brand at bytes 8-11
|
||||
// names the container flavor. Brands outside the two image families
|
||||
// (mp4, mov, ...) stay undetected on purpose.
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70
|
||||
) {
|
||||
const brand = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11])
|
||||
if (HEIC_BRANDS.has(brand)) return 'image/heic'
|
||||
if (HEIF_BRANDS.has(brand)) return 'image/heif'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ISO-BMFF ftyp brands for still images. The HEVC-coded variants (single
|
||||
// image, image sequence, and their extended forms) all read as image/heic;
|
||||
// the codec-agnostic MIAF brands read as image/heif. iOS labels the same
|
||||
// capture with either declared type, so validateDocumentMagicBytes accepts
|
||||
// the two families interchangeably.
|
||||
const HEIC_BRANDS = new Set(['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'hevm', 'hevs'])
|
||||
const HEIF_BRANDS = new Set(['mif1', 'msf1'])
|
||||
const HEIC_FAMILY = new Set(['image/heic', 'image/heif'])
|
||||
|
||||
/**
|
||||
* XHTML/XML has no binary magic number. For the declared type
|
||||
* application/xhtml+xml (system-generated iXBRL årsredovisningar) we instead
|
||||
@@ -289,12 +309,12 @@ function looksLikeJson(bytes: Uint8Array): boolean {
|
||||
|
||||
/**
|
||||
* Verify the buffer actually contains a file of the declared type.
|
||||
* Returns an error string or null if valid. HEIC has many ftyp brands so
|
||||
* we skip the check for now: the UI path doesn't allow HEIC anyway, only
|
||||
* the MCP upload tool does, and corrupted HEIC has not been observed.
|
||||
* Returns an error string or null if valid. HEIC/HEIF are verified through
|
||||
* the ISO-BMFF ftyp brand (detectFileMagic): a declared image/heic or
|
||||
* image/heif accepts a detected member of either family, because iOS labels
|
||||
* the same capture with either type. Everything else is an exact match.
|
||||
*/
|
||||
export function validateDocumentMagicBytes(buffer: ArrayBuffer, declaredMimeType: string): string | null {
|
||||
if (declaredMimeType === 'image/heic') return null
|
||||
if (declaredMimeType === 'application/xhtml+xml') {
|
||||
if (looksLikeXhtml(new Uint8Array(buffer))) return null
|
||||
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar inte vara ett XHTML/XML-dokument.`
|
||||
@@ -315,6 +335,10 @@ export function validateDocumentMagicBytes(buffer: ArrayBuffer, declaredMimeType
|
||||
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar vara skadad eller inte en riktig binärfil: vid uppladdning via API, kontrollera att file_content_base64 är base64-kodade råbytes, inte en textrepresentation.`
|
||||
}
|
||||
if (detected !== declaredMimeType) {
|
||||
// iOS labels the HEIC/HEIF container inconsistently: a file declared as
|
||||
// one family member routinely detects as the other. Same ISO-BMFF image
|
||||
// container either way, so the pair is interchangeable here.
|
||||
if (HEIC_FAMILY.has(declaredMimeType) && HEIC_FAMILY.has(detected)) return null
|
||||
return `Filinnehållet matchar inte den angivna filtypen (förväntade ${declaredMimeType}, hittade ${detected}).`
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -2017,6 +2017,40 @@ const DOCUMENT: Record<string, StructuredErrorEntry> = {
|
||||
},
|
||||
}
|
||||
|
||||
// Invoice-inbox manual upload and attach-document (extension REST routes).
|
||||
const INBOX_UPLOAD: Record<string, StructuredErrorEntry> = {
|
||||
INBOX_UPLOAD_NO_FILE: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Ingen fil bifogad.',
|
||||
message_en: 'No file attached.',
|
||||
},
|
||||
INBOX_UPLOAD_TOO_LARGE: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Filen är för stor. Maxstorlek är 10 MB.',
|
||||
message_en: 'File exceeds the 10 MB size limit.',
|
||||
},
|
||||
INBOX_UPLOAD_UNSUPPORTED_TYPE: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Filtypen stöds inte. Tillåtna format: PDF, JPEG, PNG, HEIC och WebP.',
|
||||
message_en: 'Unsupported file type. Allowed: PDF, JPEG, PNG, HEIC, WebP.',
|
||||
},
|
||||
INBOX_UPLOAD_TX_NOT_IN_COMPANY: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Den angivna transaktionen (matched_transaction_id) tillhör ett annat företag.',
|
||||
message_en: 'matched_transaction_id refers to a transaction outside this company.',
|
||||
},
|
||||
INBOX_UPLOAD_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Uppladdningen misslyckades. Försök igen.',
|
||||
message_en: 'Upload failed.',
|
||||
},
|
||||
INBOX_ATTACH_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Bilagan kunde inte kopplas. Försök igen.',
|
||||
message_en: 'Failed to attach the document.',
|
||||
},
|
||||
}
|
||||
|
||||
const CUSTOMER: Record<string, StructuredErrorEntry> = {
|
||||
CUSTOMER_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
@@ -3530,6 +3564,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
|
||||
...REGISTER_IMPORT,
|
||||
...PROVIDER_MIGRATION,
|
||||
...DOCUMENT,
|
||||
...INBOX_UPLOAD,
|
||||
...CUSTOMER,
|
||||
...ARTICLE,
|
||||
...SUPPLIER,
|
||||
|
||||
@@ -179,6 +179,18 @@ describe('bulkBookMatchedInboxItems: skip classification (never errors)', () =>
|
||||
expect(mockCreateJE).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("skips an item whose staged extraction is still in flight (status 'processing')", async () => {
|
||||
// Staged upload: the row exists with extracted_data NULL while the
|
||||
// deferred worker runs. Matched or not, it must not book from empty data.
|
||||
const supabase = queuedSupabase([
|
||||
{ data: { id: 'i1', status: 'processing', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null } },
|
||||
])
|
||||
const { booked, skipped } = await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', { ...base, item_ids: ['i1'] })
|
||||
expect(booked).toEqual([])
|
||||
expect(skipped).toEqual([{ item_id: 'i1', reason: 'extraction_in_progress' }])
|
||||
expect(mockCreateJE).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips an item already booked (created_journal_entry_id)', async () => {
|
||||
const supabase = queuedSupabase([
|
||||
{ data: { id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: 'je-x', created_supplier_invoice_id: null } },
|
||||
|
||||
@@ -427,9 +427,9 @@ export interface BulkBookInboxResult {
|
||||
/**
|
||||
* Book each selected inbox item against its matched bank transaction with one
|
||||
* shared category + VAT treatment. Items without a matched transaction, already
|
||||
* booked, or already linked to a leverantörsfaktura are skipped: never an
|
||||
* error: so one bad underlag never blocks the rest ("Bokför valda hoppar
|
||||
* över"). A per-item throw (period locked, accounts not in chart) is caught and
|
||||
* booked, already linked to a leverantörsfaktura, or still mid AI extraction
|
||||
* (staged upload, status 'processing') are skipped: never an error: so one bad
|
||||
* underlag never blocks the rest ("Bokför valda hoppar över"). A per-item throw (period locked, accounts not in chart) is caught and
|
||||
* recorded as a skip with the actionable message.
|
||||
*
|
||||
* Shared by the direct UI route (POST /items/bulk-book) and the
|
||||
@@ -457,7 +457,7 @@ export async function bulkBookMatchedInboxItems(
|
||||
for (const itemId of item_ids) {
|
||||
const { data: item, error: itemError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, matched_transaction_id, created_journal_entry_id, created_supplier_invoice_id, channel_context')
|
||||
.select('id, status, matched_transaction_id, created_journal_entry_id, created_supplier_invoice_id, channel_context')
|
||||
.eq('id', itemId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
@@ -466,6 +466,14 @@ export async function bulkBookMatchedInboxItems(
|
||||
skipped.push({ item_id: itemId, reason: 'not_found' })
|
||||
continue
|
||||
}
|
||||
if ((item as { status?: string }).status === 'processing') {
|
||||
// Staged upload: the row exists but its deferred AI extraction has not
|
||||
// landed yet (extracted_data is NULL). Booking it now would mint a
|
||||
// verifikat from an underlag nobody has read; the flip to 'received'
|
||||
// arrives within seconds, so this is a "try again in a moment" skip.
|
||||
skipped.push({ item_id: itemId, reason: 'extraction_in_progress' })
|
||||
continue
|
||||
}
|
||||
if (item.created_journal_entry_id) {
|
||||
skipped.push({ item_id: itemId, reason: 'already_booked' })
|
||||
continue
|
||||
|
||||
+8
-2
@@ -3051,7 +3051,10 @@
|
||||
"code_expired": "The code has expired. Create a new one.",
|
||||
"linked_number_label": "Linked number",
|
||||
"muted_label": "Paused",
|
||||
"muted_hint": "You paused the chat with stopp. Send start in WhatsApp to activate it again.",
|
||||
"muted_hint": "You paused the chat with stopp. Reactivate here or send start in WhatsApp. After reactivating here, just send your next receipt as usual: we cannot send a confirmation in WhatsApp until you write again.",
|
||||
"unmute_button": "Reactivate",
|
||||
"unmuted_toast": "The chat is active again.",
|
||||
"unmute_failed": "Could not reactivate the chat.",
|
||||
"default_company_label": "Default company",
|
||||
"default_company_help": "If you belong to several companies, this controls which company's Underlag your receipts land in.",
|
||||
"default_company_none": "None selected",
|
||||
@@ -3074,7 +3077,8 @@
|
||||
"last_event_muted": "ignored: the chat is paused",
|
||||
"last_event_declined": "not answered",
|
||||
"last_event_failed": "could not be received",
|
||||
"last_reply_failed": "Our latest reply could not be delivered in WhatsApp. Check that the number can receive messages from Accounted."
|
||||
"last_reply_failed": "Our latest reply could not be delivered in WhatsApp. Check that the number can receive messages from Accounted.",
|
||||
"health_warning": "{count, plural, one {1 message could not be delivered or processed in the last week.} other {# messages could not be delivered or processed in the last week.}} Check the link or contact support."
|
||||
},
|
||||
"inbox_bulk_book": {
|
||||
"total_label": "Total for the documents: {amount}",
|
||||
@@ -3168,6 +3172,8 @@
|
||||
"heic_hint": "HEIC images cannot be AI-extracted yet. Upload the receipt as JPEG or PDF, or fill in the fields manually.",
|
||||
"skipped_hint": "AI extraction did not run for this document. You can link the document to a transaction or create a supplier invoice manually.",
|
||||
"retry_overwrite_confirm": "Re-running extraction overwrites the fields, including your own edits. Continue?",
|
||||
"processing_chip": "Reading with AI…",
|
||||
"retry_extraction": "Read with AI again",
|
||||
"expand_fields": "Open the fields in a larger view",
|
||||
"wa_block_title": "From WhatsApp",
|
||||
"wa_caption_label": "Message",
|
||||
|
||||
+8
-2
@@ -3051,7 +3051,10 @@
|
||||
"code_expired": "Koden har gått ut. Skapa en ny.",
|
||||
"linked_number_label": "Kopplat nummer",
|
||||
"muted_label": "Pausad",
|
||||
"muted_hint": "Du har pausat chatten med stopp. Skicka start i WhatsApp för att aktivera den igen.",
|
||||
"muted_hint": "Du har pausat chatten med stopp. Återaktivera här eller skicka start i WhatsApp. Efter återaktivering här skickar du bara nästa kvitto som vanligt: vi kan inte skicka någon bekräftelse i WhatsApp innan du själv skriver igen.",
|
||||
"unmute_button": "Återaktivera",
|
||||
"unmuted_toast": "Chatten är aktiv igen.",
|
||||
"unmute_failed": "Kunde inte återaktivera chatten.",
|
||||
"default_company_label": "Standardföretag",
|
||||
"default_company_help": "Är du med i flera företag styr valet vilket företags Underlag dina kvitton hamnar i.",
|
||||
"default_company_none": "Inget valt",
|
||||
@@ -3074,7 +3077,8 @@
|
||||
"last_event_muted": "ignorerades: chatten är pausad",
|
||||
"last_event_declined": "besvarades inte",
|
||||
"last_event_failed": "kunde inte tas emot",
|
||||
"last_reply_failed": "Vårt senaste svar kunde inte levereras i WhatsApp. Kontrollera att numret kan ta emot meddelanden från Accounted."
|
||||
"last_reply_failed": "Vårt senaste svar kunde inte levereras i WhatsApp. Kontrollera att numret kan ta emot meddelanden från Accounted.",
|
||||
"health_warning": "{count, plural, one {1 meddelande kunde inte levereras eller behandlas den senaste veckan.} other {# meddelanden kunde inte levereras eller behandlas den senaste veckan.}} Kontrollera kopplingen eller kontakta support."
|
||||
},
|
||||
"inbox_bulk_book": {
|
||||
"total_label": "Underlagens summa: {amount}",
|
||||
@@ -3168,6 +3172,8 @@
|
||||
"heic_hint": "HEIC-bilder kan inte AI-tolkas ännu. Ladda upp kvittot som JPEG eller PDF, eller fyll i fälten manuellt.",
|
||||
"skipped_hint": "AI-tolkning kördes inte för det här dokumentet. Du kan koppla dokumentet till en transaktion eller skapa leverantörsfaktura manuellt.",
|
||||
"retry_overwrite_confirm": "Ny tolkning skriver över fälten, även ändringar du gjort själv. Fortsätta?",
|
||||
"processing_chip": "Tolkar med AI…",
|
||||
"retry_extraction": "Tolka med AI igen",
|
||||
"expand_fields": "Öppna fälten i större vy",
|
||||
"wa_block_title": "Från WhatsApp",
|
||||
"wa_caption_label": "Meddelande",
|
||||
|
||||
@@ -27,7 +27,7 @@ version: "1.0.0"
|
||||
ogImageEyebrow: "Skill"
|
||||
faq:
|
||||
- q: "Vilka filformat funkar?"
|
||||
a: "PDF, JPEG, PNG, HEIC, WebP upp till 20 MB per dokument. Text-baserade PDF:er ger bäst extraktion eftersom OCR kan läsa fältnamn (Belopp, Moms, Förfallodatum). Skannade bilder fungerar men confidence är lägre."
|
||||
a: "PDF, JPEG, PNG, WebP upp till 10 MB per dokument. Text-baserade PDF:er ger bäst extraktion eftersom OCR kan läsa fältnamn (Belopp, Moms, Förfallodatum). Skannade bilder fungerar men confidence är lägre."
|
||||
- q: "Vad händer med Peppol-fakturor?"
|
||||
a: "Peppol BIS Billing 3.0 (XML) stöds inte av `gnubok_create_supplier_invoice_from_inbox` idag. Verktyget kör OCR på dokumentet, inte XML-parsing. Native Peppol-ingestion är på roadmap i takt med EU:s VIDA-direktiv (Council Directive 2025/516, obligatoriskt 1 juli 2030 för gränsöverskridande B2B inom EU)."
|
||||
- q: "Vad händer om OCR-extraktionen är fel?"
|
||||
|
||||
@@ -1944,9 +1944,9 @@ async function seedInboxAndUncategorized(
|
||||
.single()
|
||||
if (docErr) throw new Error(`document_attachments AWS: ${docErr.message}`)
|
||||
|
||||
// status: the CHECK allows only 'received' | 'error'
|
||||
// (20260504180000_invoice_inbox_remove_ai_columns.sql, which also collapsed
|
||||
// every pre-existing 'ready' row to 'received'). This item is an arrived,
|
||||
// status: the CHECK allows 'received' | 'processing' | 'error'
|
||||
// (20260813213000_invoice_inbox_processing_status.sql; 'processing' is the
|
||||
// staged-upload in-flight state and never seeded). This item is an arrived,
|
||||
// extracted document with no supplier invoice created from it yet, which is
|
||||
// exactly what 'received' + created_supplier_invoice_id IS NULL means in the
|
||||
// inbox UI. 'error' is the failure state and belongs with error_message.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Re-admit 'processing' to the invoice_inbox_items.status CHECK.
|
||||
--
|
||||
-- Staged upload needs an in-flight state: the web upload route now inserts
|
||||
-- the inbox row BEFORE AI extraction runs (status 'processing',
|
||||
-- extracted_data NULL), answers the request immediately, and a deferred
|
||||
-- worker flips the row to 'received' once extraction lands (or the sweep
|
||||
-- cron does, after a crashed worker).
|
||||
--
|
||||
-- Supersedes the 20260504180000 tightening that removed 'processing': that
|
||||
-- removal assumed extraction always completes synchronously before the row
|
||||
-- exists, which stops being true with the staged upload. Email and WhatsApp
|
||||
-- ingestion keep the synchronous path and never write 'processing'.
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
DROP CONSTRAINT IF EXISTS invoice_inbox_items_status_check;
|
||||
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
ADD CONSTRAINT invoice_inbox_items_status_check
|
||||
CHECK (status IN ('received', 'processing', 'error'));
|
||||
|
||||
-- Unchanged, restated so the default survives the constraint swap verbatim:
|
||||
-- rows still land as 'received' unless the writer says otherwise.
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
ALTER COLUMN status SET DEFAULT 'received';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from './setup'
|
||||
import { seedCompany } from './fixtures'
|
||||
|
||||
/**
|
||||
* The staged-upload status CHECK (migration 20260813213000).
|
||||
*
|
||||
* The web upload route inserts the inbox row before AI extraction runs and
|
||||
* flips it to 'received' from a deferred worker (or the sweep cron after a
|
||||
* crash). That requires 'processing' back in the CHECK that migration
|
||||
* 20260504180000 tightened to received | error. These tests pin the widened
|
||||
* enum, that the constraint still rejects everything else, and that the
|
||||
* default stayed 'received'.
|
||||
*/
|
||||
describe('invoice_inbox_items staged-upload status (pg)', () => {
|
||||
it("accepts the re-admitted 'processing' status", async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
|
||||
const { rows } = await getPool().query<{ id: string; status: string }>(
|
||||
`INSERT INTO public.invoice_inbox_items (company_id, user_id, source, status)
|
||||
VALUES ($1, $2, 'upload', 'processing')
|
||||
RETURNING id, status`,
|
||||
[companyId, userId],
|
||||
)
|
||||
expect(rows[0].status).toBe('processing')
|
||||
})
|
||||
|
||||
it('still refuses statuses outside the enum', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
|
||||
// 'ready' was one of the pre-20260504180000 AI states: it must stay out.
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.invoice_inbox_items (company_id, user_id, source, status)
|
||||
VALUES ($1, $2, 'upload', 'ready')`,
|
||||
[companyId, userId],
|
||||
),
|
||||
).rejects.toThrow(/invoice_inbox_items_status_check|violates check constraint/i)
|
||||
})
|
||||
|
||||
it("defaults status to 'received' when the writer says nothing", async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
|
||||
const { rows } = await getPool().query<{ status: string }>(
|
||||
`INSERT INTO public.invoice_inbox_items (company_id, user_id, source)
|
||||
VALUES ($1, $2, 'upload')
|
||||
RETURNING status`,
|
||||
[companyId, userId],
|
||||
)
|
||||
expect(rows[0].status).toBe('received')
|
||||
})
|
||||
})
|
||||
+4
-1
@@ -2811,7 +2811,10 @@ export interface SIEAccountMapping {
|
||||
// Invoice Inbox Types
|
||||
// ============================================================
|
||||
|
||||
export type InboxItemStatus = 'received' | 'error'
|
||||
// 'processing' is the staged-upload in-flight state: the row exists (instant
|
||||
// receipt ack) but AI extraction has not landed yet; extracted_data is NULL
|
||||
// until the deferred worker (or the sweep cron) flips it to 'received'.
|
||||
export type InboxItemStatus = 'received' | 'processing' | 'error'
|
||||
export type InboxItemSource = 'email' | 'upload' | 'whatsapp'
|
||||
|
||||
export type CompanyInboxStatus = 'active' | 'deprecated' | 'blocked'
|
||||
|
||||
@@ -81,6 +81,10 @@
|
||||
"path": "/api/extensions/whatsapp-inbox/retention/cron",
|
||||
"schedule": "15 4 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/extensions/invoice-inbox/sweep/cron",
|
||||
"schedule": "*/2 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/bookkeeping/accruals/post-due/cron",
|
||||
"schedule": "15 5 * * *"
|
||||
|
||||
Reference in New Issue
Block a user