feat(inbox): direct-to-storage upload for files over the hosted body limit (#1551) (#2030)

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:
Jakob Wennberg
2026-08-30 11:55:42 +02:00
committed by GitHub
parent 523fba0419
commit 749f90fe62
14 changed files with 1391 additions and 53 deletions
@@ -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')
})
})
+272 -2
View File
@@ -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