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