// Shared server/network error surfacing for declarative mutations & actions. // // Kernel/host handlers answer a failed write with a consistent envelope: // { success: false, message: "", details: "" } // where `details` carries what actually went wrong (a Postgres error, a // declarative guard failure, a validation reason). The historical toast pattern // toast.error(err?.response?.data?.message || t('common.error')) // threw `details` away and showed only the generic headline ("Error creating // record") — so a user/operator saw "something failed" with no way to know WHY // or report it. This module keeps the headline but ALSO surfaces the cause as // the toast description, in ONE place so every call site behaves identically. import { toast } from 'sonner' import { validationCatalog, validationMessageKey } from './validation-catalog' /** Structured, display-ready view of an error: a headline + an optional cause. */ export interface ExtractedError { /** Primary line — the server's `message` (may be an i18n key) or a fallback. */ title: string /** Secondary line — the specific cause: `details`, `error`, joined validation * `errors`, or a raw network/thrown message. Undefined when nothing more * specific than the title is available. */ description?: string } function formatIssueEntry(v: unknown): string { if (v == null) return '' if (typeof v === 'string') return v if (typeof v === 'object' && 'code' in (v as object)) { const e = v as { code?: unknown; message?: unknown } if (typeof e.message === 'string' && e.message.trim()) return e.message.trim() if (typeof e.code === 'string' && e.code) return e.code } return String(v) } /** Flattens a validation `errors` payload (string | string[] | field→msgs map) * into a single newline-joined string, or undefined when empty. Object entries * `{code, params}` render as the code (never `[object Object]`). */ function joinErrors(errors: unknown): string | undefined { if (!errors) return undefined if (typeof errors === 'string') return errors || undefined if (Array.isArray(errors)) return errors.map(formatIssueEntry).filter(Boolean).join('\n') || undefined if (typeof errors === 'object') { const parts = Object.entries(errors as Record).map(([k, v]) => { const body = Array.isArray(v) ? v.map(formatIssueEntry).filter(Boolean).join(', ') : formatIssueEntry(v) return body ? `${k}: ${body}` : '' }).filter(Boolean) return parts.join('\n') || undefined } return undefined } /** * Pull the best available headline + cause out of an axios error, a raw * `{ success:false, ... }` response body, or a thrown Error/string. Pure — no * i18n, no toast — so it is unit-testable and reusable (dialogs, inline errors). * * Resolution: * title ← data.message (else the specific cause, so the toast is never * empty; else `fallbackTitle`) * description ← data.details → data.error → joined data.errors → raw err.message */ export function extractServerError(err: unknown, fallbackTitle: string): ExtractedError { // Accept either an axios error (`err.response.data`) or a bare response body // (`{ success, message, details }`) passed straight in. const maybeAxios = (err as { response?: { data?: unknown } } | undefined)?.response?.data const data = maybeAxios ?? err if (data && typeof data === 'object' && ('message' in data || 'details' in data || 'errors' in data || 'error' in data)) { const d = data as { message?: unknown; details?: unknown; error?: unknown; errors?: unknown } const message = typeof d.message === 'string' ? d.message.trim() : '' const details = (typeof d.details === 'string' && d.details.trim()) || (typeof d.error === 'string' && d.error.trim()) || joinErrors(d.errors) || '' if (message && details && message !== details) return { title: message, description: details } if (message || details) return { title: message || details } } // Non-HTTP failure (network down, CORS, a thrown string/Error): show it as // the cause under the generic fallback headline. const raw = typeof err === 'string' ? err : (err as { message?: unknown } | undefined)?.message if (typeof raw === 'string' && raw.trim()) return { title: fallbackTitle, description: raw.trim() } return { title: fallbackTitle } } /** i18n translator, tolerant of non-keys (returns `defaultValue`/the input). * Accepts arbitrary interpolation values (e.g. `label`, plus a code's `params`) * so `localizeFieldIssue` can pass `{{label}}` and friends through i18next. */ export type Translate = (key: string, opts?: { defaultValue?: string; [k: string]: unknown }) => string // ── Per-field validation errors ──────────────────────────────────────────── // The kernel answers a failed create/edit with HTTP 422 and a per-field map: // { success:false, message:"validation failed", // errors: { "": [ { code, params } ] } } // Codes are locale-agnostic; the SDK localizes them to Spanish using the field // label. Some endpoints (7leguas-style) instead send pre-localized STRINGS — // so a value entry may be an object `{code,params}` OR a plain string. /** A single normalized validation issue for one field: either a machine `code` * (+ optional `params`) to localize, or a ready-to-show `message` string. */ export interface FieldIssue { code?: string params?: Record message?: string } /** Spanish/English catalogs live in `validation-catalog.ts`. Hosts override any * key via i18next `validation.`. `{{label}}` and code params (min/max/ * allowed/ref/expected) interpolate through i18next. */ function humanizeKey(k: string): string { return k.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) } /** Normalize one raw `errors` value entry into a `FieldIssue`. * A string → `{message}` (pre-localized, shown verbatim); an object → `{code,params}`. */ function toFieldIssue(entry: unknown): FieldIssue | undefined { if (typeof entry === 'string') { const s = entry.trim() return s ? { message: s } : undefined } if (entry && typeof entry === 'object') { const e = entry as { code?: unknown; params?: unknown; message?: unknown } if (typeof e.message === 'string' && e.message.trim()) return { message: e.message.trim() } if (typeof e.code === 'string' && e.code) { return { code: e.code, params: e.params && typeof e.params === 'object' ? (e.params as Record) : undefined, } } } return undefined } /** * Pull the per-field `errors` map out of an axios error (`err.response.data.errors`) * or a bare response body (`{ errors }`), normalizing each field's value to a * `FieldIssue[]`. A value may be a single entry or an array; string entries become * `{message}`, object entries `{code,params}`. Returns `undefined` when there is no * usable map (no `errors`, or nothing normalized). Pure — no i18n, no toast. */ export function extractFieldErrors(err: unknown): Record | undefined { const maybeAxios = (err as { response?: { data?: unknown } } | undefined)?.response?.data const data = maybeAxios ?? err const errors = (data as { errors?: unknown } | undefined)?.errors if (!errors || typeof errors !== 'object' || Array.isArray(errors)) return undefined const out: Record = {} for (const [key, raw] of Object.entries(errors as Record)) { const entries = Array.isArray(raw) ? raw : [raw] const issues = entries.map(toFieldIssue).filter((i): i is FieldIssue => !!i) if (issues.length) out[key] = issues } return Object.keys(out).length ? out : undefined } /** * Localize a single `FieldIssue` to a human string using the field `label` and * the operator's language. A pre-localized `message` passes through verbatim. * Otherwise `code` is translated via `t('validation.'+key)` with a catalog * default (es unless `language` is `en` / `en-*`). */ export function localizeFieldIssue( issue: FieldIssue, label: string, t: Translate, language?: string, ): string { if (issue.message) return issue.message const key = validationMessageKey(issue.code ?? '', issue.params) const cat = validationCatalog(language) const defaultValue = cat[key] ?? cat.fallback ?? '{{label}}: valor inválido' return t(`validation.${key}`, { defaultValue, label, ...(issue.params ?? {}) }) } /** Localize a whole 422 `errors` map into `{ [field]: firstMessage }` using * optional per-key labels (already translated) and the current language. */ export function localizeFieldErrorMap( map: Record, t: Translate, opts?: { labels?: Record; language?: string }, ): Record { const out: Record = {} for (const [k, issues] of Object.entries(map)) { const label = opts?.labels?.[k] ?? humanizeKey(k) out[k] = localizeFieldIssue(issues[0]!, label, t, opts?.language) } return out } /** A dotted, space-free token (e.g. "pos.rate.created") — the shape of an i18n * key, as opposed to human prose ("Record created successfully"). Used to * decide whether a server-sent message is safe to translate or should be * replaced by a localized fallback. */ function looksLikeI18nKey(s: string): boolean { return /^[a-z0-9_-]+(\.[a-z0-9_-]+)+$/i.test(s) } /** * Toast a successful mutation/action response, LOCALIZED. The kernel/host often * returns a hardcoded English `message` ("Record created successfully"); echoing * it verbatim leaks English into a Spanish UI. So we only translate the server * message when it is an i18n KEY (dotted, e.g. "pos.rate.created"); a prose * message is dropped in favour of the localized `fallbackKey` (default * "common.success"). Pass the app's `t`. */ export function toastServerSuccess( data: unknown, opts?: { t?: Translate; fallbackKey?: string }, ): void { const t = opts?.t const fallbackKey = opts?.fallbackKey ?? 'common.success' const fallback = t ? t(fallbackKey, { defaultValue: 'Success' }) : 'Success' const msg = (data as { message?: unknown } | undefined)?.message if (t && typeof msg === 'string' && msg && looksLikeI18nKey(msg)) { toast.success(t(msg, { defaultValue: fallback })) return } toast.success(fallback) } /** * Toast a server/network error, surfacing the REAL cause as the description * instead of a bare generic line. A 422 `{errors:{field:[{code}]}}` bag is * localized per-field (never `[object Object]` / English "validation failed"). * Pass `language` (i18n.language) so catalogs match the operator's lang; * pass `labels` so field keys map to translated headers. */ export function toastServerError( err: unknown, opts?: { t?: Translate; fallback?: string; language?: string; labels?: Record }, ): void { const t = opts?.t const lang = opts?.language const cat = validationCatalog(lang) const map = extractFieldErrors(err) if (map) { const localized = t ? localizeFieldErrorMap(map, t, { labels: opts?.labels, language: lang }) : undefined const title = t ? t('validation.failed', { defaultValue: cat.failed }) : cat.failed const description = localized ? Object.values(localized).join('\n') : Object.entries(map) .map(([k, issues]) => `${k}: ${issues[0]?.code ?? issues[0]?.message ?? ''}`) .join('\n') toast.error(title, description ? { description } : undefined) return } const fallback = opts?.fallback ?? (t ? t('common.error', { defaultValue: 'Something went wrong' }) : 'Something went wrong') const extracted = extractServerError(err, fallback) let shownTitle = t ? t(extracted.title, { defaultValue: extracted.title }) : extracted.title if (extracted.title === 'validation failed' || extracted.title === 'validation.failed') { shownTitle = t ? t('validation.failed', { defaultValue: cat.failed }) : cat.failed } toast.error(shownTitle, extracted.description ? { description: extracted.description } : undefined) }