Hosted uploads larger than the 4 MB multipart ceiling (Vercel's 4.5 MB request-body cap) now go POST /upload/create (signed PUT URL, rate-limited) -> PUT to the raw Storage URL -> POST /upload/complete (server-side magic-byte and size validation, sha256, WORM move, idempotent), reusing the #1378 pending-upload primitives. uploadAndExtract is split into uploadDocument + processArchivedDocument so both paths share the inbox pipeline. Dokumentinkorgen and the supplier-invoice form use the new path only above the threshold; files that fit keep the multipart route. Cap stays at 10 MB (the issue asks for 20 MB: founder call). Refs #1551
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
// The two Storage primitives are the seam: the routes must hand the RAW
|
||||
// signed URL out, and hand the reservation back with the inbox's own
|
||||
// options. Everything after archival is processArchivedDocument, mocked so
|
||||
// this file asserts the hand-off, not the extraction pipeline (covered by
|
||||
// upload-page-count-gate / sandbox-skip-extraction).
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
uploadDocument: vi.fn(),
|
||||
createPendingDocumentUpload: vi.fn(),
|
||||
completePendingDocumentUpload: vi.fn(),
|
||||
linkToJournalEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/invoice-inbox/lib/upload-and-extract', async (importOriginal) => {
|
||||
const actual = await importOriginal<
|
||||
typeof import('@/extensions/general/invoice-inbox/lib/upload-and-extract')
|
||||
>()
|
||||
return { ...actual, processArchivedDocument: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/rate-limits/inbox', () => ({
|
||||
checkInboxUploadRateLimit: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
createPendingDocumentUpload,
|
||||
completePendingDocumentUpload,
|
||||
} from '@/lib/core/documents/document-service'
|
||||
import { processArchivedDocument } from '@/extensions/general/invoice-inbox/lib/upload-and-extract'
|
||||
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
|
||||
|
||||
function findRoute(method: string, path: string) {
|
||||
return invoiceInboxExtension.apiRoutes!.find(
|
||||
(r) => r.method === method && r.path === path,
|
||||
)!
|
||||
}
|
||||
|
||||
const createRoute = findRoute('POST', '/upload/create')
|
||||
const completeRoute = findRoute('POST', '/upload/complete')
|
||||
|
||||
const UPLOAD_ID = '33333333-3333-4333-8333-333333333333'
|
||||
const TX_ID = '44444444-4444-4444-8444-444444444444'
|
||||
const RAW_SIGNED_URL =
|
||||
'https://proj.supabase.co/storage/v1/object/upload/sign/documents/documents/company-1/user-1/pending/x.pdf?token=signed'
|
||||
|
||||
function buildCtx(supabase: unknown): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'invoice-inbox',
|
||||
requestId: 'req_test',
|
||||
supabase: supabase as ExtensionContext['supabase'],
|
||||
emit: vi.fn(),
|
||||
settings: { get: vi.fn(), set: vi.fn() },
|
||||
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
|
||||
services: {},
|
||||
} as unknown as ExtensionContext
|
||||
}
|
||||
|
||||
function createBody(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
file_name: 'faktura.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
size_bytes: 6 * 1024 * 1024,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function completeBody(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
upload_id: UPLOAD_ID,
|
||||
file_name: 'faktura.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
type Envelope = { error: { code: string; message: string; message_en?: string } }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(checkInboxUploadRateLimit).mockResolvedValue({ ok: true })
|
||||
vi.mocked(createPendingDocumentUpload).mockResolvedValue({
|
||||
uploadId: UPLOAD_ID,
|
||||
signedUrl: RAW_SIGNED_URL,
|
||||
expiresAt: '2026-08-28T12:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /upload/create (signed direct-to-storage upload)', () => {
|
||||
it('returns 401 without a context', async () => {
|
||||
const res = await createRoute.handler(createMockRequest('/upload/create', { method: 'POST', body: createBody() }))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 on an invalid body', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
const res = await createRoute.handler(
|
||||
createMockRequest('/upload/create', { method: 'POST', body: { file_name: 'x.pdf' } }),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ type: string }>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(body.type).toBe('validation_error')
|
||||
expect(createPendingDocumentUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a MIME type the inbox does not accept', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
const res = await createRoute.handler(
|
||||
createMockRequest('/upload/create', { method: 'POST', body: createBody({ mime_type: 'text/html' }) }),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Envelope>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INBOX_UPLOAD_UNSUPPORTED_TYPE')
|
||||
expect(createPendingDocumentUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a file over the inbox ceiling before minting a URL', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
const res = await createRoute.handler(
|
||||
createMockRequest('/upload/create', { method: 'POST', body: createBody({ size_bytes: 10 * 1024 * 1024 + 1 }) }),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Envelope>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INBOX_UPLOAD_TOO_LARGE')
|
||||
expect(body.error.message).toContain('10 MB')
|
||||
expect(createPendingDocumentUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is rate-limited like /upload and mints nothing when limited', async () => {
|
||||
vi.mocked(checkInboxUploadRateLimit).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
scope: 'minute',
|
||||
retryAfterSec: 42,
|
||||
})
|
||||
const mock = createQueuedMockSupabase()
|
||||
const res = await createRoute.handler(
|
||||
createMockRequest('/upload/create', { method: 'POST', body: createBody() }),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Envelope & { retry_after: number }>(res)
|
||||
expect(status).toBe(429)
|
||||
expect(res.headers.get('Retry-After')).toBe('42')
|
||||
expect(body.error.code).toBe('RATE_LIMITED')
|
||||
expect(checkInboxUploadRateLimit).toHaveBeenCalledWith(mock.supabase, 'company-1')
|
||||
expect(createPendingDocumentUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reserves a company-scoped upload with the user client and returns the RAW signed URL', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
const res = await createRoute.handler(
|
||||
createMockRequest('/upload/create', { method: 'POST', body: createBody() }),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { upload_id: string; upload_url: string; expires_at: string }
|
||||
}>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(createPendingDocumentUpload).toHaveBeenCalledTimes(1)
|
||||
const [client, companyId, userId, uploadId, fileName] =
|
||||
vi.mocked(createPendingDocumentUpload).mock.calls[0]
|
||||
// The user-scoped client: storage RLS decides whether this member may
|
||||
// write under the company prefix. A fresh UUID per reservation.
|
||||
expect(client).toBe(mock.supabase)
|
||||
expect(companyId).toBe('company-1')
|
||||
expect(userId).toBe('user-1')
|
||||
expect(uploadId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
expect(fileName).toBe('faktura.pdf')
|
||||
// Never the /api/storage proxy: it buffers the body in a function.
|
||||
expect(body.data.upload_url).toBe(RAW_SIGNED_URL)
|
||||
expect(body.data.upload_url).not.toContain('/api/storage/')
|
||||
expect(body.data.upload_id).toBe(UPLOAD_ID)
|
||||
expect(body.data.expires_at).toBe('2026-08-28T12:00:00.000Z')
|
||||
})
|
||||
|
||||
it('maps a reservation failure to the Swedish registry copy, never the raw message', async () => {
|
||||
vi.mocked(createPendingDocumentUpload).mockRejectedValueOnce(
|
||||
new Error('Failed to create document upload URL: bucket exploded'),
|
||||
)
|
||||
const mock = createQueuedMockSupabase()
|
||||
const res = await createRoute.handler(
|
||||
createMockRequest('/upload/create', { method: 'POST', body: createBody() }),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Envelope>(res)
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('INBOX_UPLOAD_FAILED')
|
||||
expect(body.error.message).toBe('Uppladdningen misslyckades. Försök igen.')
|
||||
expect(body.error.message_en).not.toContain('bucket exploded')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /upload/complete (signed direct-to-storage upload)', () => {
|
||||
const archived = { id: UPLOAD_ID, file_name: 'faktura.pdf', mime_type: 'application/pdf' }
|
||||
const pipelineResult = {
|
||||
document_id: UPLOAD_ID,
|
||||
inbox_item_id: 'inbox-1',
|
||||
status: 'processing',
|
||||
extracted_data: null,
|
||||
matched_supplier_id: null,
|
||||
matched_transaction_id: null,
|
||||
extraction_skipped: false,
|
||||
skip_reason: null,
|
||||
page_count: 3,
|
||||
}
|
||||
|
||||
it('returns 401 without a context', async () => {
|
||||
const res = await completeRoute.handler(
|
||||
createMockRequest('/upload/complete', { method: 'POST', body: completeBody() }),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when upload_id is not a UUID', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
const res = await completeRoute.handler(
|
||||
createMockRequest('/upload/complete', { method: 'POST', body: completeBody({ upload_id: 'not-a-uuid' }) }),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ type: string }>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(body.type).toBe('validation_error')
|
||||
expect(completePendingDocumentUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a MIME type the inbox does not accept', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
const res = await completeRoute.handler(
|
||||
createMockRequest('/upload/complete', { method: 'POST', body: completeBody({ mime_type: 'text/html' }) }),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Envelope>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INBOX_UPLOAD_UNSUPPORTED_TYPE')
|
||||
expect(completePendingDocumentUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a matched_transaction_id outside the company before touching Storage', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueue({ data: null }) // transactions lookup: not ours
|
||||
const res = await completeRoute.handler(
|
||||
createMockRequest('/upload/complete', {
|
||||
method: 'POST',
|
||||
body: completeBody({ matched_transaction_id: TX_ID }),
|
||||
}),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Envelope>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INBOX_UPLOAD_TX_NOT_IN_COMPANY')
|
||||
expect(completePendingDocumentUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('archives through the reservation and joins the multipart pipeline (deferred extraction)', async () => {
|
||||
const buffer = new TextEncoder().encode('%PDF-1.4\n').buffer as ArrayBuffer
|
||||
vi.mocked(completePendingDocumentUpload).mockResolvedValueOnce({
|
||||
document: archived as never,
|
||||
buffer,
|
||||
})
|
||||
vi.mocked(processArchivedDocument).mockResolvedValueOnce(pipelineResult as never)
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueue({ data: { id: TX_ID } }) // transactions lookup: ours
|
||||
mock.enqueue({ data: null }) // no inbox item yet for this document
|
||||
|
||||
const res = await completeRoute.handler(
|
||||
createMockRequest('/upload/complete', {
|
||||
method: 'POST',
|
||||
body: completeBody({ matched_transaction_id: TX_ID, skip_extraction: true }),
|
||||
}),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: typeof pipelineResult }>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(pipelineResult)
|
||||
|
||||
// The complete step never spends rate-limit quota: create already did.
|
||||
expect(checkInboxUploadRateLimit).not.toHaveBeenCalled()
|
||||
|
||||
expect(completePendingDocumentUpload).toHaveBeenCalledWith(
|
||||
mock.supabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
UPLOAD_ID,
|
||||
'faktura.pdf',
|
||||
'application/pdf',
|
||||
undefined,
|
||||
{ extractionOwner: 'invoice-inbox', uploadSource: 'file_upload', dedupeByContent: true },
|
||||
)
|
||||
expect(processArchivedDocument).toHaveBeenCalledWith(
|
||||
mock.supabase,
|
||||
'user-1',
|
||||
'company-1',
|
||||
archived,
|
||||
{ name: 'faktura.pdf', buffer, type: 'application/pdf' },
|
||||
'upload',
|
||||
undefined,
|
||||
TX_ID,
|
||||
{ skipExtraction: true, deferExtraction: true },
|
||||
)
|
||||
})
|
||||
|
||||
it('is idempotent: a second complete returns the inbox item that already exists', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueue({
|
||||
data: {
|
||||
id: 'inbox-1',
|
||||
status: 'received',
|
||||
extracted_data: { totals: { total: 125 } },
|
||||
matched_supplier_id: 'sup-1',
|
||||
matched_transaction_id: null,
|
||||
extraction_skipped: false,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await completeRoute.handler(
|
||||
createMockRequest('/upload/complete', { method: 'POST', body: completeBody() }),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual({
|
||||
document_id: UPLOAD_ID,
|
||||
inbox_item_id: 'inbox-1',
|
||||
status: 'received',
|
||||
extracted_data: { totals: { total: 125 } },
|
||||
matched_supplier_id: 'sup-1',
|
||||
matched_transaction_id: null,
|
||||
extraction_skipped: false,
|
||||
skip_reason: null,
|
||||
page_count: null,
|
||||
already_completed: true,
|
||||
})
|
||||
expect(completePendingDocumentUpload).not.toHaveBeenCalled()
|
||||
expect(processArchivedDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps a viewer-role RLS denial on the document insert to 403 in Swedish', async () => {
|
||||
vi.mocked(completePendingDocumentUpload).mockRejectedValueOnce(
|
||||
Object.assign(
|
||||
new Error(
|
||||
'Failed to create document record: new row violates row-level security policy for table "document_attachments"',
|
||||
),
|
||||
{ code: '42501' },
|
||||
),
|
||||
)
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueue({ data: null })
|
||||
|
||||
const res = await completeRoute.handler(
|
||||
createMockRequest('/upload/complete', { method: 'POST', body: completeBody() }),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Envelope>(res)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('INBOX_UPLOAD_NOT_PERMITTED')
|
||||
expect(body.error.message).toContain('behörighet')
|
||||
expect(body.error.message).not.toContain('row-level')
|
||||
expect(processArchivedDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps an expired or never-written reservation to 404 in Swedish', async () => {
|
||||
vi.mocked(completePendingDocumentUpload).mockRejectedValueOnce(
|
||||
Object.assign(
|
||||
new Error('Document upload was not found or has expired. Create a new upload URL and try again.'),
|
||||
{ code: 'DOCUMENT_UPLOAD_NOT_FOUND' },
|
||||
),
|
||||
)
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueue({ data: null })
|
||||
|
||||
const res = await completeRoute.handler(
|
||||
createMockRequest('/upload/complete', { method: 'POST', body: completeBody() }),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Envelope>(res)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('DOCUMENT_UPLOAD_NOT_FOUND')
|
||||
expect(body.error.message).toContain('Ladda upp filen igen')
|
||||
})
|
||||
|
||||
it("answers 400 with the document service's authored Swedish sentence on a magic-byte mismatch", async () => {
|
||||
const verdict =
|
||||
'Filinnehållet matchar inte den angivna filtypen (förväntade application/pdf, hittade image/png).'
|
||||
vi.mocked(completePendingDocumentUpload).mockRejectedValueOnce(
|
||||
Object.assign(new Error(verdict), { code: 'DOC_UPLOAD_INVALID_CONTENT', messageSv: verdict }),
|
||||
)
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueue({ data: null })
|
||||
|
||||
const res = await completeRoute.handler(
|
||||
createMockRequest('/upload/complete', { method: 'POST', body: completeBody() }),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Envelope>(res)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('DOC_UPLOAD_INVALID_CONTENT')
|
||||
expect(body.error.message).toBe(verdict)
|
||||
expect(body.error.message_en).toContain('could not be read')
|
||||
expect(processArchivedDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers 400 with registry copy when the PUT left an empty object', async () => {
|
||||
vi.mocked(completePendingDocumentUpload).mockRejectedValueOnce(
|
||||
Object.assign(new Error('Uploaded file is empty'), { code: 'DOC_UPLOAD_EMPTY' }),
|
||||
)
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueue({ data: null })
|
||||
|
||||
const res = await completeRoute.handler(
|
||||
createMockRequest('/upload/complete', { method: 'POST', body: completeBody() }),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Envelope>(res)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('DOC_UPLOAD_EMPTY')
|
||||
expect(body.error.message).toBe('Filen är tom. Ladda upp filen igen.')
|
||||
})
|
||||
|
||||
it('replaces an internal English failure with the registry copy', async () => {
|
||||
vi.mocked(completePendingDocumentUpload).mockRejectedValueOnce(
|
||||
new Error('Failed to finalize document upload: move failed'),
|
||||
)
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueue({ data: null })
|
||||
|
||||
const res = await completeRoute.handler(
|
||||
createMockRequest('/upload/complete', { method: 'POST', body: completeBody() }),
|
||||
buildCtx(mock.supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<Envelope>(res)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('INBOX_UPLOAD_FAILED')
|
||||
expect(body.error.message).toBe('Uppladdningen misslyckades. Försök igen.')
|
||||
expect(body.error.message_en).not.toContain('move failed')
|
||||
})
|
||||
})
|
||||
@@ -2,13 +2,21 @@ import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createServiceRoleClient } from '@/lib/supabase/service-client'
|
||||
import { z } from 'zod'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import {
|
||||
uploadDocument,
|
||||
createPendingDocumentUpload,
|
||||
completePendingDocumentUpload,
|
||||
} from '@/lib/core/documents/document-service'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { hasErrorEntry } from '@/lib/errors/structured-errors'
|
||||
import { dbError } from '@/lib/errors/db-error'
|
||||
import { matchSupplierId } from '@/lib/suppliers/match-supplier'
|
||||
import { extractInvoiceFields, ExtractionSchema, emptyResult } from './lib/extract-invoice-fields'
|
||||
import { mirrorExtractionToDocument } from './lib/mirror-extraction'
|
||||
import {
|
||||
uploadAndExtract,
|
||||
processArchivedDocument,
|
||||
sanitiseFilename,
|
||||
sanitiseMime,
|
||||
isSandboxCompany,
|
||||
@@ -50,7 +58,7 @@ import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-sugges
|
||||
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import {
|
||||
resolveSupplierInvoiceExchangeRate,
|
||||
supplierInvoiceSekAmounts,
|
||||
@@ -149,6 +157,100 @@ const ClaimDomainSchema = z.object({
|
||||
domain: z.string().trim().min(1).max(255),
|
||||
})
|
||||
|
||||
// Direct-to-storage upload (signed URL, see the /upload/create route).
|
||||
// size_bytes is the browser's own report: the server re-measures the object
|
||||
// on completion, so this only refuses the obviously oversized before a URL
|
||||
// is handed out. file_name must be sent identically to both steps: it is
|
||||
// part of the reserved storage key.
|
||||
const CreateSignedUploadSchema = z.object({
|
||||
file_name: z.string().trim().min(1).max(255),
|
||||
mime_type: z.string().trim().min(1).max(120),
|
||||
size_bytes: z.number().int().min(1),
|
||||
})
|
||||
|
||||
const CompleteSignedUploadSchema = z.object({
|
||||
upload_id: z.string().uuid(),
|
||||
file_name: z.string().trim().min(1).max(255),
|
||||
mime_type: z.string().trim().min(1).max(120),
|
||||
matched_transaction_id: z.string().uuid().nullable().optional(),
|
||||
skip_extraction: z.boolean().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The inbox item already filed for an archived document, in the /upload
|
||||
* response shape. Lets /upload/complete be retried after a lost response:
|
||||
* completePendingDocumentUpload converges on the same document row, and
|
||||
* this keeps the pipeline from filing a second item for it.
|
||||
*/
|
||||
async function findInboxItemForDocument(
|
||||
supabase: import('@supabase/supabase-js').SupabaseClient,
|
||||
companyId: string,
|
||||
documentId: string,
|
||||
) {
|
||||
const { data, error } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, status, extracted_data, matched_supplier_id, matched_transaction_id, extraction_skipped')
|
||||
.eq('company_id', companyId)
|
||||
.eq('document_id', documentId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (error) throw dbError(error, 'Completed-upload lookup failed')
|
||||
if (!data) return null
|
||||
return {
|
||||
document_id: documentId,
|
||||
inbox_item_id: data.id as string,
|
||||
status: data.status as string,
|
||||
extracted_data: data.extracted_data,
|
||||
matched_supplier_id: data.matched_supplier_id as string | null,
|
||||
matched_transaction_id: data.matched_transaction_id as string | null,
|
||||
extraction_skipped: data.extraction_skipped === true,
|
||||
skip_reason: null,
|
||||
page_count: null,
|
||||
already_completed: true as const,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a failure in the signed-upload steps to the error envelope. Coded
|
||||
* failures get their own status and copy: a viewer-role member's 42501 on
|
||||
* the document insert (the storage policy admits the bytes on membership
|
||||
* alone), an expired or never-written reservation, and the document
|
||||
* service's content verdicts (empty object, over the cap, bytes that are
|
||||
* not the declared type; that last one carries its authored Swedish
|
||||
* sentence as `messageSv`). Everything else lands on INBOX_UPLOAD_FAILED
|
||||
* with the registry copy: the raw message goes to the log only.
|
||||
*/
|
||||
function signedUploadFailureResponse(
|
||||
error: unknown,
|
||||
ctx: ExtensionContext,
|
||||
step: 'upload/create' | 'upload/complete',
|
||||
): NextResponse {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
ctx.log.error(`[invoice-inbox/${step}] Failed`, error)
|
||||
const coded = (typeof error === 'object' && error !== null ? error : {}) as {
|
||||
code?: unknown
|
||||
messageSv?: unknown
|
||||
}
|
||||
const code = typeof coded.code === 'string' ? coded.code : null
|
||||
if (code === '42501') {
|
||||
return errorResponseFromCode('INBOX_UPLOAD_NOT_PERMITTED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
if (code && hasErrorEntry(code)) {
|
||||
return errorResponseFromCode(code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
reason,
|
||||
...(typeof coded.messageSv === 'string' && coded.messageSv.trim()
|
||||
? { messageSv: coded.messageSv }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
return errorResponseFromCode('INBOX_UPLOAD_FAILED', ctx.log, { requestId: ctx.requestId, reason })
|
||||
}
|
||||
|
||||
// Custom inbound domains are fully built but deliberately not exposed,
|
||||
// product decision 2026-07-02: the default is the Fortnox-style shared
|
||||
// address (+ user-side forwarding); own-domain inbound waits for real demand.
|
||||
@@ -306,6 +408,174 @@ export const invoiceInboxExtension: Extension = {
|
||||
},
|
||||
},
|
||||
|
||||
// ── Direct-to-storage upload (signed URL, two steps) ────
|
||||
// A hosted function body is capped at 4.5 MB by the platform, before the
|
||||
// route runs, so a scanned PDF above that can never reach /upload
|
||||
// (issue #1551). The browser instead asks for a short-lived signed URL
|
||||
// here, PUTs the bytes straight to Storage, and hands the reservation to
|
||||
// /upload/complete, which reads the object back out of Storage, hashes
|
||||
// and archives it: the integrity chain stays server-computed. Two path
|
||||
// segments so the dispatcher's segment-count match never confuses these
|
||||
// with /upload. Rate-limited on create only: complete cannot mint
|
||||
// anything the create step did not already pay for.
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/upload/create',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const limit = await checkInboxUploadRateLimit(ctx.supabase, ctx.companyId)
|
||||
if (!limit.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
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) } },
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await validateBody(request, CreateSignedUploadSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { file_name: fileName, mime_type: mimeType, size_bytes: sizeBytes } = parsed.data
|
||||
|
||||
if (!UPLOAD_ALLOWED_MIME_TYPES.has(mimeType)) {
|
||||
return errorResponseFromCode('INBOX_UPLOAD_UNSUPPORTED_TYPE', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
messageSv: `Filtypen stöds inte: ${mimeType}. Tillåtna format: PDF, JPEG, PNG, HEIC och WebP.`,
|
||||
messageEn: `Unsupported file type: ${mimeType}. Allowed: PDF, JPEG, PNG, HEIC, WebP.`,
|
||||
})
|
||||
}
|
||||
if (sizeBytes > MAX_FILE_SIZE) {
|
||||
return errorResponseFromCode('INBOX_UPLOAD_TOO_LARGE', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
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.`,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const uploadId = crypto.randomUUID()
|
||||
const reservation = await createPendingDocumentUpload(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
ctx.userId,
|
||||
uploadId,
|
||||
fileName,
|
||||
)
|
||||
// The RAW Storage URL, deliberately not the same-origin proxy the
|
||||
// MCP tools hand out (toSameOriginStorageUrl): that proxy buffers
|
||||
// the PUT body inside a function and so sits under the very
|
||||
// ceiling this route exists to get around. CSP connect-src
|
||||
// already allows the Storage host.
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
upload_id: reservation.uploadId,
|
||||
upload_url: reservation.signedUrl,
|
||||
expires_at: reservation.expiresAt,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return signedUploadFailureResponse(error, ctx, 'upload/create')
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/upload/complete',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const parsed = await validateBody(request, CompleteSignedUploadSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { upload_id: uploadId, file_name: fileName, mime_type: mimeType } = parsed.data
|
||||
const matchedTransactionId = parsed.data.matched_transaction_id ?? null
|
||||
const skipExtraction = parsed.data.skip_extraction === true
|
||||
|
||||
// The declared type drives the magic-byte check on completion, so
|
||||
// it is gated exactly like the multipart route's file.type.
|
||||
if (!UPLOAD_ALLOWED_MIME_TYPES.has(mimeType)) {
|
||||
return errorResponseFromCode('INBOX_UPLOAD_UNSUPPORTED_TYPE', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
messageSv: `Filtypen stöds inte: ${mimeType}. Tillåtna format: PDF, JPEG, PNG, HEIC och WebP.`,
|
||||
messageEn: `Unsupported file type: ${mimeType}. Allowed: PDF, JPEG, PNG, HEIC, WebP.`,
|
||||
})
|
||||
}
|
||||
|
||||
// Same ownership check as /upload: fail fast with a clear code
|
||||
// rather than letting RLS reject the insert after extraction.
|
||||
if (matchedTransactionId) {
|
||||
const { data: tx, error: txErr } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.select('id')
|
||||
.eq('id', matchedTransactionId)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
if (txErr) {
|
||||
return errorResponse(txErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!tx) {
|
||||
return errorResponseFromCode('INBOX_UPLOAD_TX_NOT_IN_COMPANY', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Idempotent: a retry after a lost response (tab reloaded, network
|
||||
// dropped) must return the item that already exists, not file a
|
||||
// second one. The document id IS the upload id.
|
||||
const existing = await findInboxItemForDocument(ctx.supabase, ctx.companyId, uploadId)
|
||||
if (existing) return NextResponse.json({ data: existing })
|
||||
|
||||
const completed = await completePendingDocumentUpload(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
ctx.userId,
|
||||
uploadId,
|
||||
fileName,
|
||||
mimeType,
|
||||
undefined,
|
||||
{
|
||||
extractionOwner: 'invoice-inbox',
|
||||
uploadSource: 'file_upload',
|
||||
// Same content dedupe the multipart route gets from
|
||||
// uploadDocument: the same receipt uploaded twice must not
|
||||
// become a second archived document.
|
||||
dedupeByContent: true,
|
||||
},
|
||||
)
|
||||
// From here on this is the multipart route, byte for byte: same
|
||||
// staged extraction, same inbox row, same response shape.
|
||||
const result = await processArchivedDocument(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
completed.document,
|
||||
{ name: fileName, buffer: completed.buffer, type: mimeType },
|
||||
'upload',
|
||||
undefined,
|
||||
matchedTransactionId,
|
||||
{ skipExtraction, deferExtraction: true },
|
||||
)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (error) {
|
||||
return signedUploadFailureResponse(error, ctx, 'upload/complete')
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── List inbox items ────────────────────────────────────
|
||||
{
|
||||
method: 'GET',
|
||||
|
||||
@@ -213,6 +213,28 @@ function sanitiseCaption(raw: string | null | undefined): string | null {
|
||||
|
||||
// ── Shared helper: upload + extract + create inbox item ──────
|
||||
|
||||
export interface ArchivedDocumentProcessingOptions {
|
||||
skipExtraction?: boolean
|
||||
channelMeta?: ChannelMeta
|
||||
/** 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
|
||||
}
|
||||
|
||||
export async function uploadAndExtract(
|
||||
supabase: import('@supabase/supabase-js').SupabaseClient,
|
||||
userId: string,
|
||||
@@ -225,30 +247,8 @@ export async function uploadAndExtract(
|
||||
// VerifyAndBookOverlay opened from a transaction row's paperclip or from
|
||||
// a transaction-anchored chat). Skipped silently if missing.
|
||||
matchedTransactionId?: string | null,
|
||||
opts: {
|
||||
skipExtraction?: boolean
|
||||
channelMeta?: ChannelMeta
|
||||
/** 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
|
||||
} = {},
|
||||
opts: ArchivedDocumentProcessingOptions = {},
|
||||
) {
|
||||
const correlationId = crypto.randomUUID()
|
||||
|
||||
const doc = await uploadDocument(supabase, userId, companyId, {
|
||||
name: file.name,
|
||||
buffer: file.buffer,
|
||||
@@ -264,6 +264,46 @@ export async function uploadAndExtract(
|
||||
extractionOwner: 'invoice-inbox',
|
||||
})
|
||||
|
||||
return processArchivedDocument(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
doc,
|
||||
file,
|
||||
source,
|
||||
emailMeta,
|
||||
matchedTransactionId,
|
||||
opts,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the inbox does with a document once it sits in the archive:
|
||||
* adopt an existing inbox item for deduplicated content, record the
|
||||
* DocumentIngested history, apply the page-count gate, decide the
|
||||
* entitlement/sandbox/opt-out verdict, extract (synchronously or deferred)
|
||||
* and insert the inbox row. Split out of uploadAndExtract so the
|
||||
* direct-to-storage route (signed upload URL, bytes never in a function
|
||||
* body) can archive through completePendingDocumentUpload and then join the
|
||||
* exact same pipeline as the multipart route.
|
||||
*
|
||||
* `doc` is the archived document (or the existing one it deduplicated to),
|
||||
* `file` carries the same bytes the archive holds: the pipeline reads them
|
||||
* for page counting and extraction.
|
||||
*/
|
||||
export async function processArchivedDocument(
|
||||
supabase: import('@supabase/supabase-js').SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
doc: { id: string; deduplicated?: boolean },
|
||||
file: { name: string; buffer: ArrayBuffer; type: string },
|
||||
source: 'upload' | 'email' | 'whatsapp',
|
||||
emailMeta?: EmailMeta,
|
||||
matchedTransactionId?: string | null,
|
||||
opts: ArchivedDocumentProcessingOptions = {},
|
||||
) {
|
||||
const correlationId = crypto.randomUUID()
|
||||
|
||||
if (doc.deduplicated) {
|
||||
// The company already archived this exact content. If an inbox item
|
||||
// exists for it, adopt that item so the caller always gets a real
|
||||
|
||||
Reference in New Issue
Block a user