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:
co-authored by
Claude Fable 5
parent
456366fa70
commit
4a9fa5e6c5
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user