fix(underlag): carry the phone photo that is too big to send, and say why when we cannot (#1550)
* fix(whatsapp-inbox): register the channel question event types Every follow-up question the WhatsApp intake asks has been failing its processing_history append in production: ChannelQuestionAsked, ChannelQuestionAnswered and ChannelQuestionExpired were never added to the processing_event_types catalog the event_type FK points at. appendQuestionHistory() catches and logs that failure by design, so the reply to the sender still goes out and nothing looked broken from the outside. What was lost is the durable record of the exchange, which is part of how the underlag was obtained (BFNAR 2013:2 kap 8). Catalog rows only: aggregate_type 'System' already passes the CHECK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(underlag): say why an upload failed, and get out of an expired session A user reported that none of the three ways to add a receipt from a phone worked, all of them answering "Uppladdning misslyckades. Nagot gick fel, forsok igen" immediately. Production told us nothing: every upload request that reached the route in the same 24 hours returned 200. Both halves of that are the same bug. The workspace read failures as `throw new Error(json.error)`, which loses a body that is not JSON (the res.json() call throws first) and stringifies the structured envelope to "[object Object]", so anything the route did not answer with a plain string arrived as the generic fallback. The middleware 401 for an expired cookie session is exactly that envelope shape, and a phone tab left open is exactly where the session expires unnoticed: the controller's timers are throttled in the background, so the request the user just made is what finds out. Now the response is resolved where it fails, through the house helper that already knows the status map, and an expired session is announced on the session-timeout BroadcastChannel so the controller signs out and routes to /login the same way it does for an expired heartbeat. Failed uploads also post metadata (status, size, mime type, resolved reason) to /api/log, the one API path exempt from the timeout gate, so a request answered before the route runs stops being invisible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(underlag): carry the phone photo that is too big to send The reported failure was not the account and not the session: hosted rejects any request body over 4.5 MB itself, before the function runs. Measured against production, 4.4 MB reaches the route and 4.6 MB comes back as a plain-text FUNCTION_PAYLOAD_TOO_LARGE. Nothing invokes the function, so nothing lands in the logs, which is why one user's failing uploads were invisible while every upload that arrived returned 200. An iPhone photo in "Most Compatible" mode is 4-12 MB, so whether it worked depended on whose phone took the picture. Meanwhile the route advertises a 10 MB limit it can never be handed. Photos are now re-encoded in the browser when they exceed what the platform will carry: 2400px on the long edge at JPEG q0.85, stepping the quality down only if that is not enough. That keeps the small print on a receipt legible, which is what BFL 7 kap asks of an archived underlag ("varaktigt läsbart skick", a faithful reproduction), and a refusal is not. What cannot be shrunk (a PDF, or HEIC where the browser will not decode it) is refused before the upload starts, naming its actual size and the limit rather than failing in transit. 413 joins the HTTP status map so a rejection we cannot pre-empt still says what happened: the platform's body is plain text, so the status is the only thing there is to translate. Self-hosted Docker has no proxy in front of the app, so none of this applies there and the route's own MAX_FILE_SIZE keeps governing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
98612fb0ac
commit
38a890c8d1
@@ -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
|
||||
|
||||
@@ -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<ResolvedFailure> {
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user