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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user