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:
@@ -891,3 +891,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<File> {
|
||||
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<Blob | null>((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`
|
||||
}
|
||||
@@ -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.'
|
||||
)
|
||||
}
|
||||
@@ -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]')
|
||||
|
||||
@@ -69,6 +69,10 @@ const HTTP_STATUS_MAP: Record<number, Bilingual> = {
|
||||
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.' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user