diff --git a/DECISIONS.md b/DECISIONS.md index 69ff85d9..a71529cc 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1340,6 +1340,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review). [2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from `shaped`, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring. [2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card. +[2026-08-29] Document inbox upload over the hosted body limit goes direct-to-storage through a signed URL, threshold-only (#1551): files that fit a 4.5 MB function body keep the multipart /upload route, files between HOSTED_MAX_UPLOAD_BYTES and the inbox's 10 MB cap take /upload/create -> browser PUT -> /upload/complete. Two paths to keep correct, but the multipart path carries every other channel's semantics (rate limit, dedupe, staged extraction) and the split is at uploadDocument(): everything after it is one shared processArchivedDocument(), so the two paths cannot drift in what they file. The browser PUTs to the RAW Supabase Storage URL, never the /api/storage same-origin proxy the MCP tools get: that proxy buffers the body inside a function and sits under the exact ceiling this exists to get around. The cap stays 10 MB (MAX_FILE_SIZE, MAX_DOCUMENT_SIZE) not the issue's 20 MB: raising it is a founder call that touches the archive contract and the MCP tools. Content dedupe on completePendingDocumentUpload is opt-in (the inbox passes it; the MCP tools do not) because the MCP tools key idempotency on document id === upload id and a dedupe hit returns a different id. No cron sweeps abandoned pending objects: cleanupExpiredPendingDocumentUploads runs lazily per company+user prefix on the next create/complete, same as the MCP path, and an abandoned reservation never has a document row. [2026-08-29] SMTP From builder (extensions/general/email/lib/smtp-service.ts) follows the 2026-08-05 no-"via " rule and honors fromAddress exactly like resend-service.ts (name alone as display name; a verified brand address rides fromAddress; the retry-as-platform-sender path drops both the company sender and the brand address so it always differs): the PR predated #1956, and a self-host must show a customer the same sender shape whichever provider the operator picked. nodemailer transport gets requireTLS by default (SMTP_SECURE=false path) with an explicit SMTP_REQUIRE_TLS=false opt-out: nodemailer's own STARTTLS is opportunistic, so a STARTTLS-stripping on-path attacker would otherwise receive AUTH credentials and every invoice PDF in cleartext; the opt-out exists only for the documented plaintext relay on a trusted Docker/LAN network. [2026-08-28] Company invite accept link returned in-band to the inviter (always, not only under NODE_ENV=development) rather than printed to the server log as #1710 suggested: tokens are SHA-256 hashed at rest (lib/auth/invite-tokens.ts), so the raw link exists exactly once, in the create response, and the inviter is the person who needs it; a log line would put a bearer credential in log storage and still require shell access to the container. Same contract POST /api/team/invite already ships (email_sent + inviteUrl, TeamPanel shareInvite). Acceptance stays email-bound (app/api/team/accept/route.ts requires the signed-in user's email to match the invitation, per the 2026-07-24 line above: invite recovery stays token/cookie based with mailbox possession), so sharing the link over another channel does not widen who can claim the membership. No re-send endpoint added for company invites (revoke + re-invite gives a fresh link; a re-send would be a separate surface). SMTP as a delivery path is deferred to #1746. [2026-08-30] Company invite toast title branches on whether mail actually went out ("Inbjudan skapad" when no provider is configured or the send failed, "Inbjudan skickad" otherwise) instead of the inherited TeamPanel wording that says sent next to a not-sent description; the share-link live region in CompanyMembersSection is always mounted (empty until a link exists) so screen readers announce the line when it appears, same reason as the roster block. TeamPanel keeps its copied wording and conditionally mounted region on purpose: the diff stays scoped to the company path (#1710), parity is a small follow-up. diff --git a/app/api/storage/[...path]/route.ts b/app/api/storage/[...path]/route.ts index c5fe4602..08ffee5d 100644 --- a/app/api/storage/[...path]/route.ts +++ b/app/api/storage/[...path]/route.ts @@ -33,7 +33,13 @@ export const maxDuration = 60 const log = createLogger('api/storage-proxy') -/** Above every caller's own cap (MCP upload 10 MB, document max 20 MB). */ +/** + * Above every caller's own cap (MCP upload 10 MB, document max 10 MB). The + * browser inbox path does not come through here at all: this handler buffers + * the PUT body inside a function, so on hosted it sits under the same 4.5 MB + * platform ceiling, and the browser PUTs to the raw signed Storage URL + * instead (lib/documents/direct-upload.ts). + */ const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 const REQUEST_HEADERS_FORWARDED = [ diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index bdd7668c..7a0c740e 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -77,8 +77,13 @@ import { getResponseErrorMessage, } from '@/lib/errors/get-error-message' import { notifySessionExpired } from '@/lib/auth/session-timeout-shared' -import { exceedsHostedUploadLimit, tooLargeMessage } from '@/lib/documents/upload-size' +import { + exceedsHostedUploadLimit, + exceedsInboxUploadLimit, + inboxTooLargeMessage, +} from '@/lib/documents/upload-size' import { shrinkImageForUpload } from '@/lib/documents/shrink-image' +import { uploadViaSignedUrl } from '@/lib/documents/direct-upload' type AccountingMethod = 'accrual' | 'cash' @@ -879,25 +884,28 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { ) => { // A phone photo is routinely larger than the request body the platform // will carry, and it rejects the upload itself, before the route can say - // anything useful about it. Shrink what can be shrunk, and refuse the rest - // here, where we can name the size instead of letting the transfer fail. + // anything useful about it. Shrink what can be shrunk; what cannot be (a + // scanned PDF) goes straight to Storage through a signed URL instead of a + // multipart body. Only the inbox's own ceiling refuses anything now, here, + // where we can name the size instead of letting the transfer fail. const file = exceedsHostedUploadLimit(original.size) ? await shrinkImageForUpload(original) : original - if (exceedsHostedUploadLimit(file.size)) { + if (exceedsInboxUploadLimit(file.size)) { reportUploadFailure({ status: 0, size: file.size, type: file.type || 'unknown', - reason: 'over hosted body limit, refused client-side', + reason: 'over inbox ceiling, refused client-side', }) toast({ title: 'Uppladdning misslyckades', - description: tooLargeMessage(file.size), + description: inboxTooLargeMessage(file.size), variant: 'destructive', }) return undefined } + const directToStorage = exceedsHostedUploadLimit(file.size) // Optimistic placeholder: gives the user an immediate visual response // for the 3-8s while extraction runs. Removed once the real row arrives. @@ -929,12 +937,17 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { } setIsUploading(true) try { - const fd = new FormData() - fd.append('file', file) - const res = await fetch('/api/extensions/ext/invoice-inbox/upload', { - method: 'POST', - body: fd, - }) + let res: Response + if (directToStorage) { + res = await uploadViaSignedUrl(file) + } else { + const fd = new FormData() + fd.append('file', file) + res = await fetch('/api/extensions/ext/invoice-inbox/upload', { + method: 'POST', + body: fd, + }) + } if (!res.ok) throw await resolveFailure(res) const json = await res.json() if (json.data?.extraction_skipped) { diff --git a/components/supplier-invoices/NewSupplierInvoiceForm.tsx b/components/supplier-invoices/NewSupplierInvoiceForm.tsx index cd24416b..c086781d 100644 --- a/components/supplier-invoices/NewSupplierInvoiceForm.tsx +++ b/components/supplier-invoices/NewSupplierInvoiceForm.tsx @@ -29,6 +29,8 @@ import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions' import { useBasReference } from '@/lib/bookkeeping/use-bas-reference' import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates' import { getErrorMessage } from '@/lib/errors/get-error-message' +import { exceedsHostedUploadLimit } from '@/lib/documents/upload-size' +import { uploadViaSignedUrl } from '@/lib/documents/direct-upload' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { getDisplayTotal } from '@/lib/invoices/rounding' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' @@ -1248,13 +1250,23 @@ export default function NewSupplierInvoiceForm({ setExtractionPhase('idle') setPendingExtraction(null) + // Above the hosted request-body limit the platform refuses a multipart + // body before the route runs (a plain 413, nothing in the logs), so the + // bytes go straight to Storage through a signed URL instead. Same + // response shape either way. + const directToStorage = exceedsHostedUploadLimit(file.size) try { - const formData = new FormData() - formData.append('file', file) - const res = await fetch('/api/extensions/ext/invoice-inbox/upload', { - method: 'POST', - body: formData, - }) + let res: Response + if (directToStorage) { + res = await uploadViaSignedUrl(file) + } else { + const formData = new FormData() + formData.append('file', file) + res = await fetch('/api/extensions/ext/invoice-inbox/upload', { + method: 'POST', + body: formData, + }) + } if (res.ok) { const json = await res.json().catch(() => null) const data = json?.data as @@ -1286,6 +1298,12 @@ export default function NewSupplierInvoiceForm({ } catch { // Extension unreachable: fall through to the plain upload below. } + if (directToStorage) { + // The plain fallback posts a multipart body to /api/documents, which + // the platform would refuse the same way: nothing left to try. + setDocumentFiles([{ ...entry, status: 'error', error: t('underlag_upload_failed') }]) + return + } await uploadPlainDocument(entry) } diff --git a/extensions/general/invoice-inbox/__tests__/upload-signed-route.test.ts b/extensions/general/invoice-inbox/__tests__/upload-signed-route.test.ts new file mode 100644 index 00000000..13a1fde5 --- /dev/null +++ b/extensions/general/invoice-inbox/__tests__/upload-signed-route.test.ts @@ -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 = {}) { + return { + file_name: 'faktura.pdf', + mime_type: 'application/pdf', + size_bytes: 6 * 1024 * 1024, + ...overrides, + } +} + +function completeBody(overrides: Record = {}) { + 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(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(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(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(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(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(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 }>(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(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(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(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(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(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') + }) +}) diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index 800ba52e..3ac77001 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -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', diff --git a/extensions/general/invoice-inbox/lib/upload-and-extract.ts b/extensions/general/invoice-inbox/lib/upload-and-extract.ts index e864aacb..62533743 100644 --- a/extensions/general/invoice-inbox/lib/upload-and-extract.ts +++ b/extensions/general/invoice-inbox/lib/upload-and-extract.ts @@ -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 diff --git a/lib/core/documents/__tests__/document-service.test.ts b/lib/core/documents/__tests__/document-service.test.ts index dc51a4b0..00016e7e 100644 --- a/lib/core/documents/__tests__/document-service.test.ts +++ b/lib/core/documents/__tests__/document-service.test.ts @@ -696,10 +696,158 @@ describe('model-free signed document uploads', () => { 'invoice.pdf', 'application/pdf', ), - ).rejects.toThrow(/kunde inte verifieras/) + ).rejects.toMatchObject({ + code: 'DOC_UPLOAD_INVALID_CONTENT', + message: expect.stringMatching(/kunde inte verifieras/), + messageSv: expect.stringMatching(/kunde inte verifieras/), + }) expect(remove).toHaveBeenCalledWith([pendingPath]) }) + it('codes an empty pending object as DOC_UPLOAD_EMPTY and removes it', async () => { + results = [{ data: null, error: null }] + const pendingPath = buildPendingDocumentStoragePath(company, user, uploadId, 'invoice.pdf') + const remove = vi.fn().mockResolvedValue({ data: [], error: null }) + serviceClientOverride = makeClient({ + download: vi.fn().mockResolvedValue({ data: new Blob([]), error: null }), + remove, + }) + + await expect( + completePendingDocumentUpload(makeClient() as never, company, user, uploadId, 'invoice.pdf', 'application/pdf'), + ).rejects.toMatchObject({ code: 'DOC_UPLOAD_EMPTY' }) + expect(remove).toHaveBeenCalledWith([pendingPath]) + }) + + // The insert payload of a completion: from() call #0 is findReservedDocument, + // #1 the insert (no dedupe lookup in between unless opted in). + function insertPayloadOf(client: ReturnType, fromIndex: number) { + const builder = client.from.mock.results[fromIndex]?.value as { insert: ReturnType } + return builder.insert.mock.calls[0]?.[0] as Record | undefined + } + + it("stamps upload_source 'api' by default (the MCP tools' provenance)", async () => { + const buffer = pdfBuffer('api upload') + const document = makeDocumentAttachment({ id: uploadId, sha256_hash: await computeSHA256(buffer) }) + results = [ + { data: null, error: null }, + { data: document, error: null }, + ] + serviceClientOverride = makeClient({ + download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }), + }) + const client = makeClient() + + await completePendingDocumentUpload(client as never, company, user, uploadId, 'invoice.pdf', 'application/pdf') + + expect(insertPayloadOf(client, 1)?.upload_source).toBe('api') + }) + + it("stamps upload_source 'file_upload' for the browser direct-to-storage path", async () => { + const buffer = pdfBuffer('browser upload') + const document = makeDocumentAttachment({ id: uploadId, sha256_hash: await computeSHA256(buffer) }) + results = [ + { data: null, error: null }, + { data: document, error: null }, + ] + serviceClientOverride = makeClient({ + download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }), + }) + const client = makeClient() + + await completePendingDocumentUpload( + client as never, + company, + user, + uploadId, + 'invoice.pdf', + 'application/pdf', + undefined, + { uploadSource: 'file_upload' }, + ) + + expect(insertPayloadOf(client, 1)?.upload_source).toBe('file_upload') + // No content-dedupe lookup unless asked for: exactly two from() calls. + expect(client.from).toHaveBeenCalledTimes(2) + }) + + it('opt-in content dedupe returns the existing document and removes the pending object', async () => { + const buffer = pdfBuffer('already archived') + const existingId = '55555555-5555-4555-8555-555555555555' + const existing = makeDocumentAttachment({ + id: existingId, + company_id: company, + sha256_hash: await computeSHA256(buffer), + }) + results = [ + { data: null, error: null }, // findReservedDocument + { data: [existing], error: null }, // dedupe lookup + ] + const move = vi.fn().mockResolvedValue({ data: {}, error: null }) + const remove = vi.fn().mockResolvedValue({ data: [], error: null }) + serviceClientOverride = makeClient({ + download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }), + move, + remove, + }) + const client = makeClient() + + const completed = await completePendingDocumentUpload( + client as never, + company, + user, + uploadId, + 'invoice.pdf', + 'application/pdf', + undefined, + { dedupeByContent: true }, + ) + + expect(completed.document.id).toBe(existingId) + expect(completed.document.deduplicated).toBe(true) + expect(remove).toHaveBeenCalledWith([buildPendingDocumentStoragePath(company, user, uploadId, 'invoice.pdf')]) + expect(move).not.toHaveBeenCalled() + // findReservedDocument + dedupe lookup, and never an insert + expect(client.from).toHaveBeenCalledTimes(2) + }) + + it('keeps the SQLSTATE on a rejected document insert so callers can map an RLS denial', async () => { + const buffer = pdfBuffer('viewer upload') + results = [ + { data: null, error: null }, // findReservedDocument + { + data: null, + error: { + code: '42501', + message: 'new row violates row-level security policy for table "document_attachments"', + }, + }, + { data: null, error: null }, // concurrent-completion check + ] + const remove = vi.fn().mockResolvedValue({ data: [], error: null }) + serviceClientOverride = makeClient({ + download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }), + remove, + }) + + await expect( + completePendingDocumentUpload(makeClient() as never, company, user, uploadId, 'invoice.pdf', 'application/pdf'), + ).rejects.toMatchObject({ code: '42501' }) + // The moved object is taken back out: no row, no orphan. + expect(remove).toHaveBeenCalledWith([buildReservedDocumentStoragePath(company, user, uploadId, 'invoice.pdf')]) + }) + + it('codes a missing or expired reservation as DOCUMENT_UPLOAD_NOT_FOUND', async () => { + results = [{ data: null, error: null }] + serviceClientOverride = makeClient({ + download: vi.fn().mockResolvedValue({ data: null, error: { message: 'Object not found' } }), + }) + + await expect( + completePendingDocumentUpload(makeClient() as never, company, user, uploadId, 'invoice.pdf', 'application/pdf'), + ).rejects.toMatchObject({ code: 'DOCUMENT_UPLOAD_NOT_FOUND' }) + }) + it('cleans only expired pending objects in a bounded company and user prefix', async () => { const now = Date.parse('2026-08-03T10:00:00.000Z') const remove = vi.fn().mockResolvedValue({ data: [], error: null }) diff --git a/lib/core/documents/document-service.ts b/lib/core/documents/document-service.ts index 29f86b4d..5ae0db4a 100644 --- a/lib/core/documents/document-service.ts +++ b/lib/core/documents/document-service.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { dbError } from '@/lib/errors/db-error' import { eventBus } from '@/lib/events' import type { DocumentExtractionOwner } from '@/lib/events/types' import type { DocumentAttachment, DocumentUploadSource } from '@/types' @@ -464,10 +465,32 @@ export async function createPendingDocumentUpload( } export interface CompletedPendingDocumentUpload { - document: DocumentAttachment + /** `deduplicated` is set only when the caller opted into content dedupe + * and the company had already archived these exact bytes. */ + document: DocumentAttachment & { deduplicated?: boolean } buffer: ArrayBuffer } +export interface CompletePendingDocumentUploadOptions { + extractionOwner?: DocumentExtractionOwner + /** + * Provenance stamped on the document row. Default 'api': the signed-URL + * primitives were built for MCP agents. The browser direct-to-storage path + * (files too large for a hosted function body) passes 'file_upload' so the + * archive tells the same story as the multipart route it replaces. + */ + uploadSource?: Extract + /** + * Content dedupe, same contract as uploadDocument({ dedupeByContent }): + * after hashing, a current-version document in the same company with the + * same SHA-256 wins, the pending object is removed and the existing row is + * returned with `deduplicated: true`. Opt-in (default false) because the + * MCP tools key their idempotency on document id === upload id: a dedupe + * hit would return a different id and trip their collision guard. + */ + dedupeByContent?: boolean +} + async function findReservedDocument( supabase: SupabaseClient, companyId: string, @@ -495,16 +518,32 @@ function validateReservedDocumentMetadata( } } +/** + * Each verdict carries a registry code (structured-errors.ts) so a REST + * caller can answer with the right status and copy instead of a generic + * failure. The magic-byte sentence is authored Swedish user copy naming the + * expected and detected types: it rides along as `messageSv` so the route + * can show it without forwarding a raw error message. + */ async function validatePendingDocumentBytes( buffer: ArrayBuffer, mimeType: string ): Promise { - if (buffer.byteLength === 0) throw new Error('Uploaded file is empty') + if (buffer.byteLength === 0) { + throw Object.assign(new Error('Uploaded file is empty'), { code: 'DOC_UPLOAD_EMPTY' }) + } if (buffer.byteLength > MAX_DOCUMENT_SIZE) { - throw new Error(`File too large (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`) + throw Object.assign(new Error(`File too large (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`), { + code: 'DOC_UPLOAD_TOO_LARGE', + }) } const magicError = validateDocumentMagicBytes(buffer, mimeType) - if (magicError) throw new Error(magicError) + if (magicError) { + throw Object.assign(new Error(magicError), { + code: 'DOC_UPLOAD_INVALID_CONTENT', + messageSv: magicError, + }) + } return computeSHA256(buffer) } @@ -521,7 +560,7 @@ export async function completePendingDocumentUpload( fileName: string, mimeType: string, now: number = Date.now(), - options: { extractionOwner?: DocumentExtractionOwner } = {} + options: CompletePendingDocumentUploadOptions = {} ): Promise { const serviceClient = createServiceClientNoCookies() const storage = serviceClient.storage.from(DOCUMENTS_BUCKET) @@ -552,7 +591,12 @@ export async function completePendingDocumentUpload( sourcePath = permanentPath } if (downloadError || !blob) { - throw new Error('Document upload was not found or has expired. Create a new upload URL and try again.') + // Coded so REST callers can answer 404 with the registry copy: the + // browser PUT never landed, or the reservation outlived its TTL. + throw 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 buffer = await blob.arrayBuffer() @@ -564,6 +608,25 @@ export async function completePendingDocumentUpload( throw error } + if (options.dedupeByContent) { + // Same lookup as uploadDocument: oldest current-version match wins, and + // a broken lookup fails closed rather than archiving the duplicate. + const { data: existingByContent, error: dedupeError } = await supabase + .from('document_attachments') + .select('*') + .eq('company_id', companyId) + .eq('sha256_hash', sha256Hash) + .eq('is_current_version', true) + .order('created_at', { ascending: true }) + .limit(1) + if (dedupeError) throw dbError(dedupeError, 'Content dedupe lookup failed') + const hit = (existingByContent as DocumentAttachment[] | null)?.[0] + if (hit) { + await storage.remove([sourcePath]) + return { document: { ...hit, deduplicated: true }, buffer } + } + } + if (sourcePath === pendingPath) { const { error: moveError } = await storage.move(pendingPath, permanentPath) if (moveError) { @@ -588,7 +651,7 @@ export async function completePendingDocumentUpload( version: 1, is_current_version: true, uploaded_by: userId, - upload_source: 'api', + upload_source: options.uploadSource ?? 'api', digitization_date: new Date(now).toISOString(), journal_entry_id: null, journal_entry_line_id: null, @@ -606,7 +669,12 @@ export async function completePendingDocumentUpload( return { document: concurrent, buffer } } await storage.remove([permanentPath]) - throw new Error(`Failed to create document record: ${error.message}`) + // dbError keeps the SQLSTATE on the thrown error: a viewer-role member + // passes the storage policy (membership only) but not the + // document_attachments insert policy (writers only), and 42501 is what + // lets the caller answer "no permission" in Swedish instead of a generic + // failure. + throw dbError(error, 'Failed to create document record') } const document = data as DocumentAttachment diff --git a/lib/documents/__tests__/direct-upload.test.ts b/lib/documents/__tests__/direct-upload.test.ts new file mode 100644 index 00000000..deb1b15d --- /dev/null +++ b/lib/documents/__tests__/direct-upload.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi } from 'vitest' +import { + INBOX_UPLOAD_COMPLETE_URL, + INBOX_UPLOAD_CREATE_URL, + uploadViaSignedUrl, +} from '../direct-upload' + +const UPLOAD_ID = '33333333-3333-4333-8333-333333333333' +const SIGNED_URL = + 'https://proj.supabase.co/storage/v1/object/upload/sign/documents/documents/c/u/pending/x.pdf?token=signed' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function reservationResponse(): Response { + return jsonResponse({ + data: { upload_id: UPLOAD_ID, upload_url: SIGNED_URL, expires_at: '2026-08-28T12:00:00.000Z' }, + }) +} + +function fakeFile(): File { + return new File([new Uint8Array(16)], 'faktura.pdf', { type: 'application/pdf' }) +} + +type Call = { url: string; init: RequestInit | undefined } + +function fetchSequence(responses: Array Response)>) { + const calls: Call[] = [] + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(input), init }) + const next = responses.shift() + if (!next) throw new Error('unexpected fetch') + return typeof next === 'function' ? next() : next + }) as unknown as typeof fetch + return { fetchImpl, calls } +} + +describe('uploadViaSignedUrl', () => { + it('runs create, PUT to the raw signed URL, then complete, and resolves to the complete response', async () => { + const completeRes = jsonResponse({ data: { inbox_item_id: 'inbox-1' } }) + const { fetchImpl, calls } = fetchSequence([ + reservationResponse(), + new Response(null, { status: 200 }), + completeRes, + ]) + const file = fakeFile() + + const res = await uploadViaSignedUrl(file, { + fetchImpl, + matchedTransactionId: 'tx-1', + skipExtraction: true, + }) + + expect(res).toBe(completeRes) + expect(calls.map((c) => c.url)).toEqual([INBOX_UPLOAD_CREATE_URL, SIGNED_URL, INBOX_UPLOAD_COMPLETE_URL]) + + // 1. create: JSON metadata only, never the bytes + expect(calls[0].init?.method).toBe('POST') + expect(JSON.parse(String(calls[0].init?.body))).toEqual({ + file_name: 'faktura.pdf', + mime_type: 'application/pdf', + size_bytes: 16, + }) + + // 2. PUT: the file itself, typed, no upsert onto an existing key + expect(calls[1].init?.method).toBe('PUT') + expect(calls[1].init?.headers).toEqual({ 'content-type': 'application/pdf', 'x-upsert': 'false' }) + expect(calls[1].init?.body).toBe(file) + + // 3. complete: the reservation plus the same options /upload takes + expect(calls[2].init?.method).toBe('POST') + expect(JSON.parse(String(calls[2].init?.body))).toEqual({ + upload_id: UPLOAD_ID, + file_name: 'faktura.pdf', + mime_type: 'application/pdf', + matched_transaction_id: 'tx-1', + skip_extraction: true, + }) + }) + + it('defaults to no matched transaction and extraction on', async () => { + const { fetchImpl, calls } = fetchSequence([ + reservationResponse(), + new Response(null, { status: 200 }), + jsonResponse({ data: {} }), + ]) + + await uploadViaSignedUrl(fakeFile(), { fetchImpl }) + + expect(JSON.parse(String(calls[2].init?.body))).toMatchObject({ + matched_transaction_id: null, + skip_extraction: false, + }) + }) + + it('returns the create response unchanged when the reservation is refused, and sends nothing else', async () => { + const limited = jsonResponse({ error: { code: 'RATE_LIMITED', message: 'För många' } }, 429) + const { fetchImpl, calls } = fetchSequence([limited]) + + const res = await uploadViaSignedUrl(fakeFile(), { fetchImpl }) + + expect(res).toBe(limited) + expect(calls).toHaveLength(1) + }) + + it('aborts before complete when Storage rejects the PUT, surfacing the status in an envelope', async () => { + const { fetchImpl, calls } = fetchSequence([ + reservationResponse(), + new Response('token expired', { status: 403 }), + ]) + + const res = await uploadViaSignedUrl(fakeFile(), { fetchImpl }) + + expect(calls).toHaveLength(2) + expect(res.ok).toBe(false) + expect(res.status).toBe(403) + const body = (await res.json()) as { error: { code: string; message: string } } + expect(body.error.code).toBe('INBOX_UPLOAD_STORAGE_REJECTED') + expect(body.error.message).toContain('403') + expect(body.error.message).toContain('Försök igen') + }) + + it('throws when the reservation is malformed rather than PUTting to nowhere', async () => { + const { fetchImpl, calls } = fetchSequence([jsonResponse({ data: { upload_id: UPLOAD_ID } })]) + + await expect(uploadViaSignedUrl(fakeFile(), { fetchImpl })).rejects.toThrow(/upload_url/) + expect(calls).toHaveLength(1) + }) +}) diff --git a/lib/documents/__tests__/upload-size.test.ts b/lib/documents/__tests__/upload-size.test.ts index a282d40d..7fd80ff0 100644 --- a/lib/documents/__tests__/upload-size.test.ts +++ b/lib/documents/__tests__/upload-size.test.ts @@ -2,8 +2,11 @@ import { describe, it, expect, afterEach, vi } from 'vitest' import { HOSTED_MAX_UPLOAD_BYTES, HOSTED_REQUEST_BODY_LIMIT_BYTES, + INBOX_MAX_UPLOAD_BYTES, exceedsHostedUploadLimit, + exceedsInboxUploadLimit, formatMegabytes, + inboxTooLargeMessage, isShrinkableImage, tooLargeMessage, } from '../upload-size' @@ -46,6 +49,29 @@ describe('upload size limits', () => { }) }) +// The inbox ceiling is the route's own MAX_FILE_SIZE (10 MB), mirrored here +// because core components cannot import from the extension. Files between +// the hosted body limit and this one take the direct-to-storage path. +describe('inbox upload ceiling', () => { + it('sits above the hosted body limit and matches the route promise of 10 MB', () => { + expect(INBOX_MAX_UPLOAD_BYTES).toBe(10 * 1024 * 1024) + expect(INBOX_MAX_UPLOAD_BYTES).toBeGreaterThan(HOSTED_REQUEST_BODY_LIMIT_BYTES) + }) + + it('applies on every deployment: self-hosted has the same route cap', () => { + vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true') + expect(exceedsInboxUploadLimit(INBOX_MAX_UPLOAD_BYTES + 1)).toBe(true) + expect(exceedsInboxUploadLimit(INBOX_MAX_UPLOAD_BYTES)).toBe(false) + }) + + it('names the actual size and the inbox ceiling, not the hosted one', () => { + const message = inboxTooLargeMessage(12 * 1024 * 1024) + expect(message).toContain('12,0 MB') + expect(message).toContain(formatMegabytes(INBOX_MAX_UPLOAD_BYTES)) + expect(message).not.toContain(formatMegabytes(HOSTED_MAX_UPLOAD_BYTES)) + }) +}) + describe('shrinkImageForUpload', () => { function fakeFile(size: number, type: string): File { return { size, type, name: 'kvitto.heic', lastModified: 0 } as File diff --git a/lib/documents/direct-upload.ts b/lib/documents/direct-upload.ts new file mode 100644 index 00000000..182d6fa9 --- /dev/null +++ b/lib/documents/direct-upload.ts @@ -0,0 +1,109 @@ +/** + * Direct-to-storage upload for the document inbox (issue #1551). + * + * The platform rejects a request body over 4.5 MB before the route runs + * (see upload-size.ts), and a PDF cannot be shrunk the way a photo can. This + * path keeps the bytes out of the function body altogether: + * + * 1. POST /upload/create mints a short-lived signed Storage URL + * 2. PUT the browser sends the bytes straight to Storage + * 3. POST /upload/complete the server reads the object back out of Storage, + * hashes it, archives it and runs the normal + * inbox pipeline (extraction, inbox row) + * + * The hash is computed server-side from the stored object in step 3; nothing + * the browser says about the content is trusted. The URL from step 1 is the + * raw Storage URL on purpose: the same-origin /api/storage proxy the MCP + * tools use buffers the body inside a function and would hit the same + * ceiling. + * + * Resolves to the Response that ended the sequence: the /upload/complete + * response on success (the same `{ data }` shape as the multipart /upload), + * or the first failing step's response, so callers keep their existing + * `if (!res.ok)` handling. A Storage rejection in step 2 is surfaced as a + * synthesized error envelope carrying Storage's status, and step 3 is never + * attempted after it: an abandoned reservation leaves no document row (the + * pending object is swept on a later create). + */ + +const INBOX_ROUTE_BASE = '/api/extensions/ext/invoice-inbox' +export const INBOX_UPLOAD_CREATE_URL = `${INBOX_ROUTE_BASE}/upload/create` +export const INBOX_UPLOAD_COMPLETE_URL = `${INBOX_ROUTE_BASE}/upload/complete` + +export interface DirectUploadOptions { + /** Pre-match the new inbox item to a bank transaction (same as /upload). */ + matchedTransactionId?: string | null + /** Bring-your-own-extraction opt-out (same as /upload's skip_extraction). */ + skipExtraction?: boolean + /** Injectable for tests; defaults to the global fetch. */ + fetchImpl?: typeof fetch +} + +interface SignedUploadReservation { + upload_id: string + upload_url: string + expires_at: string +} + +/** + * Storage answered the PUT with a failure. Wrapped in the standard envelope + * so getResponseErrorMessage() reads it like any route failure. Never a + * session-expiry false positive: notifySessionExpired keys on a header only + * the app's own 401 carries. + */ +function storageRejectedResponse(status: number): Response { + return new Response( + JSON.stringify({ + error: { + code: 'INBOX_UPLOAD_STORAGE_REJECTED', + message: `Lagringstjänsten tog inte emot filen (HTTP ${status}). Försök igen.`, + message_en: `The storage service did not accept the file (HTTP ${status}). Try again.`, + }, + }), + { status, headers: { 'content-type': 'application/json' } }, + ) +} + +export async function uploadViaSignedUrl( + file: File, + options: DirectUploadOptions = {}, +): Promise { + // Wrapped rather than referenced: a detached `fetch` loses its receiver. + const fetchImpl: typeof fetch = options.fetchImpl ?? ((input, init) => fetch(input, init)) + + const createRes = await fetchImpl(INBOX_UPLOAD_CREATE_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + file_name: file.name, + mime_type: file.type, + size_bytes: file.size, + }), + }) + if (!createRes.ok) return createRes + + const created = (await createRes.json()) as { data?: Partial } + const reservation = created.data + if (!reservation?.upload_id || !reservation.upload_url) { + throw new Error('Signed upload reservation is missing upload_id or upload_url') + } + + const put = await fetchImpl(reservation.upload_url, { + method: 'PUT', + headers: { 'content-type': file.type, 'x-upsert': 'false' }, + body: file, + }) + if (!put.ok) return storageRejectedResponse(put.status) + + return fetchImpl(INBOX_UPLOAD_COMPLETE_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + upload_id: reservation.upload_id, + file_name: file.name, + mime_type: file.type, + matched_transaction_id: options.matchedTransactionId ?? null, + skip_extraction: options.skipExtraction === true, + }), + }) +} diff --git a/lib/documents/upload-size.ts b/lib/documents/upload-size.ts index 84ee4319..fd23acb7 100644 --- a/lib/documents/upload-size.ts +++ b/lib/documents/upload-size.ts @@ -65,6 +65,10 @@ export function formatMegabytes(bytes: number): string { * or an image the browser would not decode). Names both the actual size and * the ceiling: "too large" without either is the kind of message that sends a * user back to support rather than to a solution. + * + * Still the right message for the surfaces that post a multipart body + * (ReconciliationUnderlag, support attachments). The document inbox no + * longer refuses at this ceiling: see inboxTooLargeMessage. */ export function tooLargeMessage(size: number): string { return ( @@ -72,3 +76,32 @@ export function tooLargeMessage(size: number): string { 'Fotografera om kvittot, eller komprimera PDF:en, och försök igen.' ) } + +/** + * The document inbox's own ceiling: MAX_FILE_SIZE in the invoice-inbox + * extension and MAX_DOCUMENT_SIZE in the document service, both 10 MB. + * Mirrored here because core components must not import from extensions. + * + * Between HOSTED_MAX_UPLOAD_BYTES and this one, the inbox sends the bytes + * straight to Storage through a signed URL (direct-upload.ts) so the + * platform's body cap no longer decides what can be filed; this one still + * does, on hosted and self-hosted alike. + */ +export const INBOX_MAX_UPLOAD_BYTES = 10 * 1024 * 1024 + +/** True when the file is over the inbox ceiling on any deployment. */ +export function exceedsInboxUploadLimit(size: number): boolean { + return size > INBOX_MAX_UPLOAD_BYTES +} + +/** + * The inbox's sentence for a file over its ceiling. Same shape as + * tooLargeMessage (actual size, then the limit), but the limit it names is + * the one that actually applies on that surface now. + */ +export function inboxTooLargeMessage(size: number): string { + return ( + `Filen är ${formatMegabytes(size)} och gränsen för dokumentinkorgen är ${formatMegabytes(INBOX_MAX_UPLOAD_BYTES)}. ` + + 'Komprimera PDF:en, eller dela upp den, och försök igen.' + ) +} diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 9ea2b6cd..8cdd2664 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -2286,6 +2286,20 @@ const PROVIDER_MIGRATION: Record = { // ───────────────────────────────────────────────────────────────── const DOCUMENT: Record = { + // Signed-URL (direct-to-storage) upload: completion found no object under + // the reservation. The bytes never landed, or the reservation expired. + DOCUMENT_UPLOAD_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Den uppladdade filen hittades inte eller har gått ut. Ladda upp filen igen.', + message_en: 'The uploaded file was not found or the upload has expired. Upload the file again.', + }, + // Signed-URL upload completed against an empty object: the PUT sent no + // bytes, or sent them somewhere else. + DOC_UPLOAD_EMPTY: { + httpStatus: 400, + message_sv: 'Filen är tom. Ladda upp filen igen.', + message_en: 'The uploaded file is empty. Upload the file again.', + }, DOC_UPLOAD_NO_FILE: { httpStatus: 400, message_sv: 'Ingen fil bifogad.', @@ -2394,6 +2408,13 @@ const INBOX_UPLOAD: Record = { message_sv: 'Uppladdningen misslyckades. Försök igen.', message_en: 'Upload failed.', }, + // A read-only (viewer) member: the storage policy admits the bytes on + // membership alone, the document_attachments insert policy does not. + INBOX_UPLOAD_NOT_PERMITTED: { + httpStatus: 403, + message_sv: 'Du har inte behörighet att ladda upp underlag i det här företaget. Medlemmar med läsbehörighet kan inte lägga till dokument.', + message_en: 'You do not have permission to upload documents to this company. Read-only members cannot add documents.', + }, INBOX_ATTACH_FAILED: { httpStatus: 500, message_sv: 'Bilagan kunde inte kopplas. Försök igen.',