diff --git a/DECISIONS.md b/DECISIONS.md index 83dceb00..efaa7e8a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -891,3 +891,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-12] Content dedupe on document ingest is an opt-in uploadDocument flag wired into the intake funnel (uploadAndExtract + mail-hunt ingest), NOT a unique index on (company_id, sha256_hash): archival callers (sent invoices, filings, bank exports) legitimately store repeating bytes and a blanket constraint would break them; the SELECT-then-insert race is accepted exactly as in the WhatsApp precedent. On a hit the funnel adopts the existing inbox item (callers always get a real inbox_item_id) or files an item against the existing document; the mail hunt skips outright since a second item would only duplicate work in Underlag. [2026-08-12] Inbox "booked" state is derived server-side from the matched transaction (GET /items enrichment) in ADDITION to write-side created_journal_entry_id stamps, not stamps alone: created_journal_entry_id is UNIQUE (migration 20260515090000), so on a bulk-book samlingsverifikat only one of N matched items can ever carry the stamp; a stamp-only fix could not clear the reported flood of matched items on bulk-booked transactions. The constraint stays (it guards the book-direct double-fire race); the stamp becomes a fast path and the derivation the source of truth. [2026-08-13] Pulled archive/connectfile out of Fortnox DEFAULT_SCOPES (reverting the #1541 addition) instead of enabling them in the Fortnox Developer Portal: requesting a scope the registered app lacks makes Fortnox reject authorize with invalid_scope before login, which broke EVERY Fortnox connect in prod within minutes of the #1541 deploy (verified in Vercel logs, user willemduplessis999/TETTET). The attachment-import feature itself stays: it already degrades via 403 -> PROVIDER_DOCUMENT_SCOPES_REQUIRED with a reconnect follow-up. Re-add the scopes only after the portal registration has them approved. +[2026-08-13] ChannelQuestionAsked/Answered/Expired registered in processing_event_types rather than dropping the appends: appendQuestionHistory swallows the FK violation by design so the WhatsApp reply still goes out, which turned a missing catalog row into a per-question production error nobody saw, and the question exchange is part of how the underlag was obtained (BFNAR 2013:2 kap 8). +[2026-08-13] Underlag failures resolve the response where it fails (getResponseErrorMessage + status) and an expired session announces itself on the existing session-timeout BroadcastChannel, instead of a global authenticated-fetch wrapper: `throw new Error(json.error)` printed "[object Object]" for the structured envelope, so the middleware 401 on a backgrounded mobile tab surfaced as the generic "Något gick fel" with no way back; a wrapper would have to be threaded through every call site to buy the same thing here. Upload failures also post metadata (status, size, mime, resolved reason) to /api/log, the one API path exempt from the timeout gate, because a request answered before the route runs leaves nothing in the function logs and the user-reported failures were invisible there. +[2026-08-13] Oversized phone photos are re-encoded in the browser (2400px long edge, JPEG q0.85 stepping down) rather than raising a platform limit or streaming straight to storage: hosted rejects any request body over 4.5 MB itself (measured against prod: 4.4 MB reaches the route, 4.6 MB returns a plain-text FUNCTION_PAYLOAD_TOO_LARGE), before the route runs and therefore invisibly in the function logs, while the route advertises 10 MB it can never receive. A downscaled photo is still a faithful, durably readable reproduction (BFL 7 kap), which a refusal is not. What cannot be shrunk (PDF, or HEIC where the browser will not decode it) is refused client-side with its actual size named, and 413 was added to the HTTP status map so a rejection in transit still says what happened. Direct-to-storage upload, which would remove the ceiling for PDFs too, is the follow-up, not this fix: it moves sha256/WORM integrity off the server. diff --git a/components/auth/SessionTimeoutController.tsx b/components/auth/SessionTimeoutController.tsx index 74cb5e2e..d182d160 100644 --- a/components/auth/SessionTimeoutController.tsx +++ b/components/auth/SessionTimeoutController.tsx @@ -5,6 +5,7 @@ import { createClient } from '@/lib/supabase/client' import { resetAnalyticsIdentity } from '@/lib/analytics/reset' import { SESSION_TIMEOUT_CHANNEL, + SESSION_TIMEOUT_REASON_HEADER, type SessionTimeoutClientState, type SessionTimeoutReason, } from '@/lib/auth/session-timeout-shared' @@ -83,7 +84,7 @@ export function SessionTimeoutController() { }, []) const handleExpiredResponse = useCallback((response: Response) => { - const reason = response.headers.get('x-session-timeout-reason') === 'idle' + const reason = response.headers.get(SESSION_TIMEOUT_REASON_HEADER) === 'idle' ? 'idle' : 'absolute' void expire(reason) @@ -161,11 +162,17 @@ export function SessionTimeoutController() { channelRef.current = channel if (channel) { channel.onmessage = (event: MessageEvent<{ - type: 'activity' | 'heartbeat' + type: 'activity' | 'heartbeat' | 'expired' at?: number + reason?: SessionTimeoutReason state?: SessionTimeoutClientState }>) => { - if (event.data.type === 'heartbeat' && event.data.state) { + // 'expired' comes from notifySessionExpired(): a data request hit the + // middleware 401 before our own timers noticed, which is the normal + // order of events in a backgrounded tab where they are throttled. + if (event.data.type === 'expired') { + void expire(event.data.reason === 'idle' ? 'idle' : 'absolute') + } else if (event.data.type === 'heartbeat' && event.data.state) { applyServerState(event.data.state) lastHeartbeatAtRef.current = Date.now() warningOpenRef.current = false diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 22411e46..5e084901 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -70,10 +70,74 @@ import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDi // INBOX_CUSTOM_DOMAINS_ENABLED in extensions/general/invoice-inbox/index.ts. import TransactionMatchPicker from '@/components/inbox/TransactionMatchPicker' import { useAgentSheet } from '@/components/agent/AgentSheetProvider' -import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { + getErrorMessage as getUserErrorMessage, + getResponseErrorMessage, +} from '@/lib/errors/get-error-message' +import { notifySessionExpired } from '@/lib/auth/session-timeout-shared' +import { exceedsHostedUploadLimit, tooLargeMessage } from '@/lib/documents/upload-size' +import { shrinkImageForUpload } from '@/lib/documents/shrink-image' type AccountingMethod = 'accrual' | 'cash' +/** + * A failure whose message is already the sentence to show the user, resolved + * where the response was still in hand. + * + * The old `throw new Error(json.error ?? '…')` lost two things. A body that + * is not JSON (an HTML error page, an empty 502, a request rejected before it + * reached the route) made `res.json()` itself throw, and `error` is an object + * on the structured envelope, which stringified to "[object Object]". Both + * ended at the generic "Något gick fel. Försök igen." An expired session on a + * mobile tab is exactly the second shape, so the one failure the user could + * have fixed in a tap was also the one that said the least. + */ +class ResolvedFailure extends Error { + constructor(message: string, readonly status: number) { + super(message) + } +} + +/** + * Read a failed response into a displayable message, and let the session + * controller know if the reason was an expired session (it redirects to + * /login; the toast below is what the user sees on the way there). + */ +async function resolveFailure(response: Response): Promise { + notifySessionExpired(response) + return new ResolvedFailure(await getResponseErrorMessage(response), response.status) +} + +function failureText(err: unknown): string { + return err instanceof ResolvedFailure ? err.message : getUserErrorMessage(err) +} + +/** + * Leave a server-side trace when an upload fails. + * + * A request the middleware or the platform answers before the route runs + * leaves nothing behind in the function logs, so "uploading from my phone + * just fails" was untraceable: the successful uploads were all we could see. + * /api/log is the existing rate-limited, PII-redacting client sink, and it is + * the one API path exempt from the session-timeout gate, so it still records + * the report when an expired session is the very thing being reported. + * Metadata only, never the document. + */ +function reportUploadFailure(report: { + status: number + size: number + type: string + reason: string +}): void { + void fetch('/api/log', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ message: 'underlag upload failed', extra: report }), + }).catch(() => { + // Reporting the failure must never become a second failure. + }) +} + // ── Types ──────────────────────────────────────────────────── interface InboxItem { @@ -749,15 +813,15 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { try { const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${id}`) + if (!res.ok) throw await resolveFailure(res) const json = await res.json() - if (!res.ok) throw new Error(json.error ?? 'Kunde inte hämta posten') const item = json.data as InboxItem setSelected(item) await loadDocument(id, item.document_id) } catch (err) { toast({ title: 'Kunde inte ladda dokumentet', - description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.', + description: failureText(err), variant: 'destructive', }) } @@ -769,9 +833,31 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { // for a one-off drop (user expects to see what just landed). Harmful in // a multi-file queue (selection yanks around as each file processes). const uploadFile = useCallback(async ( - file: File, + original: File, options: { autoSelect: boolean } = { autoSelect: true }, ) => { + // 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. + const file = exceedsHostedUploadLimit(original.size) + ? await shrinkImageForUpload(original) + : original + if (exceedsHostedUploadLimit(file.size)) { + reportUploadFailure({ + status: 0, + size: file.size, + type: file.type || 'unknown', + reason: 'over hosted body limit, refused client-side', + }) + toast({ + title: 'Uppladdning misslyckades', + description: tooLargeMessage(file.size), + variant: 'destructive', + }) + return undefined + } + // Optimistic placeholder: gives the user an immediate visual response // for the 3-8s while extraction runs. Removed once the real row arrives. const tempId = `temp-${crypto.randomUUID()}` @@ -808,8 +894,8 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { method: 'POST', body: fd, }) + if (!res.ok) throw await resolveFailure(res) const json = await res.json() - if (!res.ok) throw new Error(json.error ?? 'Uppladdning misslyckades') if (json.data?.extraction_skipped) { const pages = json.data?.page_count toast({ @@ -833,9 +919,16 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { setSelectedId((prev) => (prev === tempId ? null : prev)) setSelected((prev) => (prev?.id === tempId ? null : prev)) } + const reason = failureText(err) + reportUploadFailure({ + status: err instanceof ResolvedFailure ? err.status : 0, + size: file.size, + type: file.type || 'unknown', + reason, + }) toast({ title: 'Uppladdning misslyckades', - description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.', + description: reason, variant: 'destructive', }) } finally { @@ -875,19 +968,21 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { body: JSON.stringify({ transaction_id: transactionId }), }, ) - if (!res.ok) throw new Error(String(res.status)) + if (!res.ok) throw await resolveFailure(res) toast({ title: 'Underlag kopplat', description: rest.length ? `${file.name}. ${rest.length} till lades i inkorgen.` : file.name, }) setSelectedPurchaseId(null) await Promise.all([fetchItems(), fetchPurchases()]) - } catch { + } catch (err) { // The document is safely filed either way; only the link failed, and // the user can still make it by hand from the inbox. toast({ title: 'Uppladdat, men inte kopplat', - description: 'Dokumentet ligger i inkorgen. Koppla det till köpet därifrån.', + description: err instanceof ResolvedFailure + ? `${failureText(err)} Dokumentet ligger i inkorgen, koppla det till köpet därifrån.` + : 'Dokumentet ligger i inkorgen. Koppla det till köpet därifrån.', variant: 'destructive', }) await fetchItems() @@ -944,8 +1039,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${id}`, { method: 'DELETE', }) - const json = await res.json() - if (!res.ok) throw new Error(json.error ?? 'Kunde inte ta bort') + if (!res.ok) throw await resolveFailure(res) toast({ title: 'Borttagen' }) if (selectedId === id) { setSelectedId(null) @@ -955,7 +1049,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { } catch (err) { toast({ title: 'Kunde inte ta bort', - description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.', + description: failureText(err), variant: 'destructive', }) } finally { @@ -1057,15 +1151,15 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { const res = await fetch('/api/extensions/ext/invoice-inbox/inbox/rotate', { method: 'POST', }) + if (!res.ok) throw await resolveFailure(res) const json = await res.json() - if (!res.ok) throw new Error(json.error ?? 'Rotation misslyckades') setInboxAddress(json.data) setAddressLoadFailed(false) toast({ title: 'Ny adress skapad', description: json.data.address }) } catch (err) { toast({ title: 'Rotation misslyckades', - description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.', + description: failureText(err), variant: 'destructive', }) } finally { @@ -2796,11 +2890,10 @@ function FieldsRail({ `/api/extensions/ext/invoice-inbox/items/${item.id}/retry-extraction`, { method: 'POST' }, ) - const json = await res.json().catch(() => ({})) if (!res.ok) { toast({ title: 'Tolkning misslyckades', - description: json.error || 'Försök igen om en stund.', + description: (await resolveFailure(res)).message, variant: 'destructive', }) return @@ -3471,21 +3564,21 @@ export function EditableFieldsList({ body: JSON.stringify(body), } ) - const json = await res.json() if (!res.ok) { // 409 means the item is already linked to a supplier invoice and - // the server has rejected the edit. Surface the specific Swedish - // message ("Posten är redan kopplad…") instead of the generic - // fallback so the user understands why the field locked. - const isConflict = res.status === 409 + // the server has rejected the edit. Name that in the title; the + // description carries the specific Swedish message ("Posten är + // redan kopplad…") for every status. + const failure = await resolveFailure(res) toast({ variant: 'destructive', - title: isConflict ? 'Posten är låst' : 'Kunde inte spara', - description: json.error ?? 'Försök igen', + title: res.status === 409 ? 'Posten är låst' : 'Kunde inte spara', + description: failure.message, }) setDrafts((prev) => ({ ...prev, [key]: readField(data, key) })) return } + const json = await res.json() if (json.data?.extracted_data) { onUpdated(json.data.extracted_data as InvoiceExtractionResult) } diff --git a/lib/auth/__tests__/session-timeout-shared.test.ts b/lib/auth/__tests__/session-timeout-shared.test.ts new file mode 100644 index 00000000..66656b05 --- /dev/null +++ b/lib/auth/__tests__/session-timeout-shared.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + notifySessionExpired, + SESSION_TIMEOUT_CHANNEL, + SESSION_TIMEOUT_REASON_HEADER, +} from '../session-timeout-shared' + +const posted: Array<{ name: string; message: unknown }> = [] + +class FakeBroadcastChannel { + constructor(public readonly name: string) {} + postMessage(message: unknown) { + posted.push({ name: this.name, message }) + } + close() {} +} + +function expiredResponse(reason: string, status = 401): Response { + return new Response( + JSON.stringify({ error: { code: 'SESSION_EXPIRED', message: 'Sessionen har upphört.' } }), + { status, headers: { [SESSION_TIMEOUT_REASON_HEADER]: reason } }, + ) +} + +describe('notifySessionExpired', () => { + beforeEach(() => { + posted.length = 0 + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('announces an idle timeout on the session channel', () => { + expect(notifySessionExpired(expiredResponse('idle'))).toBe(true) + expect(posted).toEqual([ + { name: SESSION_TIMEOUT_CHANNEL, message: { type: 'expired', reason: 'idle' } }, + ]) + }) + + it('announces an absolute timeout on the session channel', () => { + expect(notifySessionExpired(expiredResponse('absolute'))).toBe(true) + expect(posted).toEqual([ + { name: SESSION_TIMEOUT_CHANNEL, message: { type: 'expired', reason: 'absolute' } }, + ]) + }) + + // A 401 from a route's own auth check is not a timeout: signing the user out + // and redirecting on one would turn a single failed call into a logout. + it('ignores a 401 that carries no timeout reason', () => { + expect(notifySessionExpired(new Response('{}', { status: 401 }))).toBe(false) + expect(posted).toEqual([]) + }) + + it('ignores an unknown reason', () => { + expect(notifySessionExpired(expiredResponse('whatever'))).toBe(false) + expect(posted).toEqual([]) + }) + + it('ignores a successful response that happens to carry the header', () => { + expect(notifySessionExpired(expiredResponse('idle', 200))).toBe(false) + expect(posted).toEqual([]) + }) + + it('still reports the timeout where BroadcastChannel is unavailable', () => { + vi.stubGlobal('BroadcastChannel', undefined) + expect(notifySessionExpired(expiredResponse('idle'))).toBe(true) + expect(posted).toEqual([]) + }) +}) diff --git a/lib/auth/session-timeout-shared.ts b/lib/auth/session-timeout-shared.ts index f04584fe..8e3c09d2 100644 --- a/lib/auth/session-timeout-shared.ts +++ b/lib/auth/session-timeout-shared.ts @@ -1,6 +1,8 @@ export const SESSION_TIMEOUT_COOKIE = 'gnubok-session-timeout' export const SESSION_AUTH_METHOD_HINT_COOKIE = 'gnubok-auth-method' export const SESSION_TIMEOUT_CHANNEL = 'gnubok-session-timeout' +/** Set by middleware on the 401 it answers an expired cookie session with. */ +export const SESSION_TIMEOUT_REASON_HEADER = 'x-session-timeout-reason' export type SessionAuthMethod = 'password' | 'bankid' export type SessionTimeoutReason = 'idle' | 'absolute' @@ -20,6 +22,34 @@ export function isSessionAuthMethod(value: unknown): value is SessionAuthMethod return value === 'password' || value === 'bankid' } +/** + * Announce an expired session that a plain data request ran into, so the + * SessionTimeoutController signs out and routes to /login exactly as it does + * for an expired heartbeat. + * + * The controller's own timers are the normal detector, but a backgrounded + * mobile tab has them throttled: the first thing that notices is often the + * request the user just made. Without this the page stays up while every + * action fails, and the failure surfaces as a message about that action + * (an upload that "misslyckades") rather than as the re-login it really is. + * + * Returns whether the response was in fact a session timeout, so callers can + * skip their own error toast when the redirect is already on its way. + */ +export function notifySessionExpired(response: Response): boolean { + const raw = response.status === 401 + ? response.headers.get(SESSION_TIMEOUT_REASON_HEADER) + : null + if (raw !== 'idle' && raw !== 'absolute') return false + + if (typeof BroadcastChannel !== 'undefined') { + const channel = new BroadcastChannel(SESSION_TIMEOUT_CHANNEL) + channel.postMessage({ type: 'expired', reason: raw }) + channel.close() + } + return true +} + export function setSessionAuthMethodHint(method: SessionAuthMethod): void { if (typeof document === 'undefined') return diff --git a/lib/documents/__tests__/upload-size.test.ts b/lib/documents/__tests__/upload-size.test.ts new file mode 100644 index 00000000..a282d40d --- /dev/null +++ b/lib/documents/__tests__/upload-size.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { + HOSTED_MAX_UPLOAD_BYTES, + HOSTED_REQUEST_BODY_LIMIT_BYTES, + exceedsHostedUploadLimit, + formatMegabytes, + isShrinkableImage, + tooLargeMessage, +} from '../upload-size' +import { shrinkImageForUpload } from '../shrink-image' + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('upload size limits', () => { + it('stays under the platform ceiling so the multipart envelope fits', () => { + expect(HOSTED_MAX_UPLOAD_BYTES).toBeLessThan(HOSTED_REQUEST_BODY_LIMIT_BYTES) + }) + + it('flags a file over the limit on hosted', () => { + vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '') + expect(exceedsHostedUploadLimit(HOSTED_MAX_UPLOAD_BYTES + 1)).toBe(true) + expect(exceedsHostedUploadLimit(HOSTED_MAX_UPLOAD_BYTES)).toBe(false) + }) + + // Docker self-hosting has no proxy in front of the app, so the route's own + // MAX_FILE_SIZE governs and nothing should be refused or re-encoded here. + it('never flags a file on self-hosted', () => { + vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true') + expect(exceedsHostedUploadLimit(50 * 1024 * 1024)).toBe(false) + }) + + it('recognises the image types a canvas can re-encode', () => { + expect(isShrinkableImage('image/jpeg')).toBe(true) + expect(isShrinkableImage('image/HEIC')).toBe(true) + expect(isShrinkableImage('application/pdf')).toBe(false) + expect(isShrinkableImage('')).toBe(false) + expect(isShrinkableImage(null)).toBe(false) + }) + + it('names both the actual size and the ceiling', () => { + const message = tooLargeMessage(6 * 1024 * 1024) + expect(message).toContain('6,0 MB') + expect(message).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 + } + + it('returns a file that already fits untouched', async () => { + const file = fakeFile(1024, 'image/jpeg') + expect(await shrinkImageForUpload(file)).toBe(file) + }) + + it('returns a PDF untouched: there is nothing a canvas can do with it', async () => { + const file = fakeFile(9 * 1024 * 1024, 'application/pdf') + expect(await shrinkImageForUpload(file)).toBe(file) + }) + + // No createImageBitmap outside a browser (and none for HEIC outside Safari): + // the caller falls back to the size message rather than a silent failure. + it('returns the original where the browser cannot decode it', async () => { + const file = fakeFile(9 * 1024 * 1024, 'image/heic') + expect(await shrinkImageForUpload(file)).toBe(file) + }) +}) diff --git a/lib/documents/shrink-image.ts b/lib/documents/shrink-image.ts new file mode 100644 index 00000000..8e32adf8 --- /dev/null +++ b/lib/documents/shrink-image.ts @@ -0,0 +1,72 @@ +import { HOSTED_MAX_UPLOAD_BYTES, isShrinkableImage } from './upload-size' + +/** + * Re-encode an oversized photo in the browser so it fits the platform's + * request-body ceiling (see upload-size.ts for why that ceiling exists). + * + * Only reached for files that cannot be sent as they are. The archived + * document is whatever comes back from here, so the goal is a faithful, + * durably readable reproduction of the receipt (BFL 7 kap: "varaktigt läsbart + * skick"), not the smallest possible file. 2400px on the long edge keeps the + * small print on a receipt legible while taking a 12 MP phone photo well under + * the limit; quality steps down only if that is not enough. + * + * Returns the original file untouched when the browser cannot decode it (HEIC + * outside Safari, a corrupt image) or when re-encoding would not help. The + * caller then reports the honest size message rather than uploading something + * that is going to be rejected in transit. + */ + +const MAX_EDGE_PX = 2400 +const QUALITY_STEPS = [0.85, 0.7, 0.55] + +export async function shrinkImageForUpload( + file: File, + maxBytes: number = HOSTED_MAX_UPLOAD_BYTES, +): Promise { + if (file.size <= maxBytes) return file + if (!isShrinkableImage(file.type)) return file + if (typeof createImageBitmap !== 'function' || typeof document === 'undefined') return file + + try { + const bitmap = await createImageBitmap(file) + const scale = Math.min(1, MAX_EDGE_PX / Math.max(bitmap.width, bitmap.height)) + const width = Math.max(1, Math.round(bitmap.width * scale)) + const height = Math.max(1, Math.round(bitmap.height * scale)) + + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d') + if (!context) { + bitmap.close() + return file + } + context.drawImage(bitmap, 0, 0, width, height) + bitmap.close() + + for (const quality of QUALITY_STEPS) { + const blob = await new Promise((resolve) => { + canvas.toBlob(resolve, 'image/jpeg', quality) + }) + if (!blob) return file + if (blob.size <= maxBytes) { + return new File([blob], jpegName(file.name), { + type: 'image/jpeg', + lastModified: file.lastModified, + }) + } + } + return file + } catch { + // A decode the browser refuses (HEIC off Safari) is not an error worth + // surfacing on its own: the size message the caller falls back to is the + // one the user can act on. + return file + } +} + +function jpegName(name: string): string { + const base = name.replace(/\.[^.]+$/, '') || 'underlag' + return `${base}.jpg` +} diff --git a/lib/documents/upload-size.ts b/lib/documents/upload-size.ts new file mode 100644 index 00000000..99645c0d --- /dev/null +++ b/lib/documents/upload-size.ts @@ -0,0 +1,72 @@ +/** + * What the hosting platform will actually carry, as opposed to what the + * upload routes say they accept. + * + * Vercel rejects a request body over 4.5 MB itself, before the function runs: + * the caller gets a plain-text 413 (FUNCTION_PAYLOAD_TOO_LARGE) and nothing + * reaches the route, so nothing lands in the function logs either. That is why + * a user reporting "uploading from my phone just fails" was invisible in + * production while every upload that did arrive returned 200. A phone photo + * routinely exceeds it: iPhone JPEG capture ("Most Compatible") lands at + * 4-12 MB, well over the route's own 10 MB promise that can never be reached + * on hosted. + * + * Self-hosted Docker has no such proxy limit, so the route's own MAX_FILE_SIZE + * governs there and none of this applies. + */ + +/** The platform's hard ceiling on a request body. */ +export const HOSTED_REQUEST_BODY_LIMIT_BYTES = Math.round(4.5 * 1024 * 1024) + +/** + * The largest file we will put in a multipart body. Below the hard ceiling by + * enough to cover the multipart envelope (boundaries, part headers, the file + * name) so a file that just fits does not fail on the framing around it. + */ +export const HOSTED_MAX_UPLOAD_BYTES = 4 * 1024 * 1024 + +export function isHostedDeployment(): boolean { + return process.env.NEXT_PUBLIC_SELF_HOSTED !== 'true' +} + +/** + * Image types a browser canvas can decode and re-encode. HEIC/HEIF are + * included deliberately: Safari on iOS decodes them natively, and iOS is + * exactly where the oversized photos come from. Elsewhere the decode throws + * and the caller keeps the original, which then gets the honest size message + * instead of a silent failure. + */ +const SHRINKABLE_IMAGE_TYPES = new Set([ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/heic', + 'image/heif', + 'image/webp', +]) + +export function isShrinkableImage(type: string | null | undefined): boolean { + return SHRINKABLE_IMAGE_TYPES.has(String(type ?? '').toLowerCase()) +} + +/** True when this file cannot be sent as-is on a hosted deployment. */ +export function exceedsHostedUploadLimit(size: number): boolean { + return isHostedDeployment() && size > HOSTED_MAX_UPLOAD_BYTES +} + +export function formatMegabytes(bytes: number): string { + return `${(bytes / 1024 / 1024).toFixed(1).replace('.', ',')} MB` +} + +/** + * The sentence for a file that is over the limit and cannot be shrunk (a PDF, + * 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. + */ +export function tooLargeMessage(size: number): string { + return ( + `Filen är ${formatMegabytes(size)} och gränsen är ${formatMegabytes(HOSTED_MAX_UPLOAD_BYTES)}. ` + + 'Fotografera om kvittot, eller komprimera PDF:en, och försök igen.' + ) +} diff --git a/lib/errors/__tests__/get-error-message.test.ts b/lib/errors/__tests__/get-error-message.test.ts index eccdc82e..9a8e114a 100644 --- a/lib/errors/__tests__/get-error-message.test.ts +++ b/lib/errors/__tests__/get-error-message.test.ts @@ -374,6 +374,17 @@ describe('getErrorMessage: API response body vs new Error(body.error)', () => { expect(msg).not.toBe('Något gick fel. Försök igen.') }) + // A body the platform rejects never reaches a route, and the 413 it answers + // with is plain text: the status is the only thing left to translate. + it('a payload-too-large rejection says so, with no body to read', () => { + expect(getErrorMessage(null, { statusCode: 413 })).toBe( + 'Filen är för stor för att skickas. Försök igen med en mindre fil.', + ) + expect(getErrorMessage(null, { statusCode: 413, locale: 'en' })).toBe( + 'The file is too large to send. Try again with a smaller file.', + ) + }) + it('new Error(body.error) stringifies the envelope to "[object Object]"', () => { // The defect in one line: the Error constructor calls String() on the object. expect(new Error(envelope.error as unknown as string).message).toBe('[object Object]') diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index dd19fdb9..e123fa59 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -69,6 +69,10 @@ const HTTP_STATUS_MAP: Record = { 403: { sv: 'Du har inte behörighet att utföra denna åtgärd.', en: 'You do not have permission to perform this action.' }, 404: { sv: 'Resursen kunde inte hittas.', en: 'The resource could not be found.' }, 409: { sv: 'En konflikt uppstod. Ladda om sidan och försök igen.', en: 'A conflict occurred. Reload the page and try again.' }, + // 413 is answered by the hosting platform, before any route runs, with a + // plain-text body: the status is the only thing a caller has to go on. + 413: { sv: 'Filen är för stor för att skickas. Försök igen med en mindre fil.', en: 'The file is too large to send. Try again with a smaller file.' }, + 415: { sv: 'Filtypen stöds inte.', en: 'That file type is not supported.' }, 422: { sv: 'Uppgifterna kunde inte bearbetas. Kontrollera fälten och försök igen.', en: 'The data could not be processed. Check the fields and try again.' }, 429: { sv: 'För många förfrågningar. Vänta en stund och försök igen.', en: 'Too many requests. Wait a moment and try again.' }, 500: { sv: 'Ett oväntat serverfel uppstod. Försök igen senare.', en: 'An unexpected server error occurred. Please try again later.' }, diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts index b8114e10..487703bd 100644 --- a/lib/supabase/middleware.ts +++ b/lib/supabase/middleware.ts @@ -22,6 +22,7 @@ import { isSessionAuthMethod, SESSION_AUTH_METHOD_HINT_COOKIE, SESSION_TIMEOUT_COOKIE, + SESSION_TIMEOUT_REASON_HEADER, type SessionAuthMethod, type SessionTimeoutReason, } from '@/lib/auth/session-timeout-shared' @@ -504,7 +505,7 @@ function sessionTimeoutResponse( }, { status: 401 }, ) - response.headers.set('X-Session-Timeout-Reason', reason) + response.headers.set(SESSION_TIMEOUT_REASON_HEADER, reason) response.headers.set('Cache-Control', 'no-store') copyResponseCookies(authResponse, response) return response diff --git a/supabase/migrations/20260813033506_register_channel_question_event_types.sql b/supabase/migrations/20260813033506_register_channel_question_event_types.sql new file mode 100644 index 00000000..7b280ed8 --- /dev/null +++ b/supabase/migrations/20260813033506_register_channel_question_event_types.sql @@ -0,0 +1,22 @@ +-- Register the behandlingshistorik events emitted when the WhatsApp intake +-- asks the sender a follow-up question, gets an answer, or lets the question +-- expire (extensions/general/whatsapp-inbox/lib/item-context.ts). +-- +-- processing_history.event_type has an FK to processing_event_types, so an +-- unregistered type fails the insert. appendQuestionHistory() swallows that +-- failure by design (the reply to the sender must go out either way), which +-- is why this went unnoticed: every question asked over WhatsApp has been +-- logging "question history append failed" in production instead of leaving +-- the durable record. The conversation with the sender IS part of how the +-- underlag was obtained, so it belongs in the history (BFNAR 2013:2 kap 8). +-- +-- Catalog rows only: aggregate_type 'System' is already permitted by the +-- aggregate_type CHECK, so no constraint change is needed. + +INSERT INTO public.processing_event_types (event_type) VALUES + ('ChannelQuestionAsked'), + ('ChannelQuestionAnswered'), + ('ChannelQuestionExpired') +ON CONFLICT (event_type) DO NOTHING; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/production-error-regressions.pg.test.ts b/tests/pg/production-error-regressions.pg.test.ts index 2330c767..bc68fb8b 100644 --- a/tests/pg/production-error-regressions.pg.test.ts +++ b/tests/pg/production-error-regressions.pg.test.ts @@ -54,6 +54,24 @@ describe('production error regressions', () => { expect(rows).toEqual([{ event_type: 'PendingOperationApproved' }]) }) + it('registers the WhatsApp channel question events in the processing history catalog', async () => { + // appendQuestionHistory() swallows FK failures so the reply to the sender + // still goes out, so an unregistered type is invisible outside the logs. + const { rows } = await getPool().query( + `SELECT event_type + FROM public.processing_event_types + WHERE event_type = ANY($1::text[]) + ORDER BY event_type`, + [['ChannelQuestionAsked', 'ChannelQuestionAnswered', 'ChannelQuestionExpired']], + ) + + expect(rows.map((row) => row.event_type)).toEqual([ + 'ChannelQuestionAnswered', + 'ChannelQuestionAsked', + 'ChannelQuestionExpired', + ]) + }) + it('aggregates period activity and excludes a specified opening entry', async () => { const ctx = await seedCompany() const openingId = await insertEntry({