Files
accounted/lib/browser/panel-request.ts
T
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* 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>
2026-09-02 11:51:16 +02:00

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 }) }
}
}