38a890c8d1
* 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>
73 lines
2.8 KiB
TypeScript
73 lines
2.8 KiB
TypeScript
/**
|
|
* 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.'
|
|
)
|
|
}
|