f266c386f3
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
113 lines
4.0 KiB
TypeScript
113 lines
4.0 KiB
TypeScript
/**
|
|
* The settings panels' server calls (Stripe, Shopify, WooCommerce), each
|
|
* classified into exactly one outcome. Never throws: every arm resolves to a
|
|
* member of the union, so a call site cannot have a silent path by forgetting
|
|
* a `catch`. One toast sentence per click; the classification lives outside
|
|
* the component because component logic has no tests in this repo.
|
|
*/
|
|
|
|
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
|
|
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
|
import type { ActionFailure } from '@/lib/browser/action-failure'
|
|
|
|
/** Deadline for the quick calls (status, toggle, disconnect). */
|
|
export const PANEL_ACTION_TIMEOUT_MS = 15_000
|
|
|
|
export type PanelRequestResult<T> =
|
|
/** 2xx. `data` is null when the body was not readable JSON. */
|
|
| { ok: true; data: T | null }
|
|
| ActionFailure
|
|
|
|
export interface PanelRequestOptions {
|
|
url: string
|
|
/** Defaults to POST: most of the panel calls are mutations. */
|
|
method?: 'GET' | 'POST' | 'DELETE'
|
|
/** JSON request body. Omitted entirely for the routes that ignore it. */
|
|
body?: unknown
|
|
/** UI locale, so a server error is reported in the language the user reads. */
|
|
locale?: ErrorLocale
|
|
timeoutMs?: number
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null
|
|
}
|
|
|
|
/**
|
|
* The one sentence for a non-2xx.
|
|
*
|
|
* The extension routes refuse with a hand-written Swedish sentence in
|
|
* `{ error }`, and that sentence is the most specific thing anyone can say:
|
|
* "Inget anslutet Stripe-konto." tells the user to reconnect, where the status
|
|
* map's "Resursen kunde inte hittas." tells them nothing. `getErrorMessage`
|
|
* keeps such a sentence only when it happens to carry one of its Swedish
|
|
* trigger words, which this route copy does not, so the string is preferred
|
|
* explicitly.
|
|
*
|
|
* `error_en` is honoured first for an English UI because the shared capability
|
|
* guard emits both (`capabilityBlockedResponse` in
|
|
* lib/entitlements/has-capability.ts), and `getErrorMessage` only reads
|
|
* `message_en` inside a structured envelope, not a top-level `error_en`.
|
|
*
|
|
* Everything else is `getErrorMessage`'s: a structured envelope, an HTML 502
|
|
* from the platform, a body that never parsed.
|
|
*/
|
|
export function serverErrorMessage(
|
|
body: unknown,
|
|
status: number,
|
|
locale: ErrorLocale,
|
|
): string {
|
|
if (isRecord(body)) {
|
|
if (locale === 'en' && typeof body.error_en === 'string' && body.error_en.trim()) {
|
|
return body.error_en.trim()
|
|
}
|
|
if (typeof body.error === 'string' && body.error.trim()) {
|
|
return body.error.trim()
|
|
}
|
|
}
|
|
return getErrorMessage(body, { statusCode: status, locale })
|
|
}
|
|
|
|
/** Call one of a panel's endpoints and report exactly why it failed. */
|
|
export async function panelRequest<T>({
|
|
url,
|
|
method = 'POST',
|
|
body,
|
|
locale = 'sv',
|
|
timeoutMs = PANEL_ACTION_TIMEOUT_MS,
|
|
}: PanelRequestOptions): Promise<PanelRequestResult<T>> {
|
|
try {
|
|
const res = await fetchWithTimeout(
|
|
url,
|
|
body === undefined
|
|
? { method }
|
|
: {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
},
|
|
{ timeoutMs, description: `${method} ${url}` },
|
|
)
|
|
|
|
// Read the body on both arms: the failure arm needs the route's own
|
|
// sentence, the success arm needs the sync counts. A body that is not JSON
|
|
// (an HTML error page, an empty 502, a response truncated mid-stream) leaves
|
|
// null, and neither arm then claims anything it cannot support.
|
|
const payload = await res.json().catch(() => null)
|
|
|
|
if (!res.ok) {
|
|
return {
|
|
ok: false,
|
|
reason: 'server',
|
|
status: res.status,
|
|
message: serverErrorMessage(payload, res.status, locale),
|
|
}
|
|
}
|
|
|
|
return { ok: true, data: payload as T | null }
|
|
} catch (err) {
|
|
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
|
|
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
|
|
}
|
|
}
|