import { ApiError } from "./apiError"; export interface ApiErrorInfo { title: string; explanation: string; nextStep?: string; technical: string; } type TFn = (key: string, options?: Record) => string; // Backend AppError codes this frontend knows how to present with a localized title, // explanation and (where useful) a next step -- see // backend/app/core/errors.py::AppError and every `raise AppError("CODE", ...)` site. // Anything not in this list still gets a sensible HTTP-status-based fallback below, so // a newly-introduced backend code never regresses to raw English -- it just falls back // to a generic-but-localized message until this list is extended. export const KNOWN_CODES = new Set([ "VEHICLE_NOT_FOUND", "BOOKING_NOT_FOUND", "CUSTOMER_NOT_FOUND", "CUSTOMER_ALREADY_MERGED", "ENTITY_NOT_FOUND", "EVENT_NOT_FOUND", "ISSUE_NOT_FOUND", "ISSUE_NOT_OPEN", "ISSUE_CHANGED", "BOOKING_NOT_ACTIVE", "INVALID_BOOKING_STATE", "NOT_RETRYABLE", "CONFLICT_STILL_PRESENT", "OVERLAP_STILL_PRESENT", "EMPTY_VALUE", "NO_FIELDS_PROVIDED", "INVALID_FIELD", "INVALID_FIELD_OVERRIDE", "INVALID_SURVIVOR", "INVALID_BOOKING_REFERENCE", "INVALID_EVENT_ID", "INVALID_EVENT_CORRELATION", "CALLBACK_EVENT_MISMATCH", "CALLBACK_CORRELATION_MISMATCH", "INVALID_IDEMPOTENCY_KEY", "IDEMPOTENCY_KEY_REUSED", "CORRECTED_VALUE_REQUIRED", "CORRECTION_BELOW_CANONICAL", "NOT_A_DUPLICATE_ISSUE", "NOT_A_MISSING_FIELD_ISSUE", "NOT_AN_ODOMETER_ISSUE", "NOT_AN_OVERLAP_ISSUE", "NOT_A_STATUS_CONFLICT_ISSUE", "UNSUPPORTED_ENTITY", "MANUAL_REVIEW_REQUIRED", "NO_CONFLICT_DETECTED", "RECOMMENDATION_STALE", "UNAUTHORIZED_SERVICE", "UNKNOWN_WORKFLOW", ]); const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]); /** * Turns a caught error into a localized {title, explanation, nextStep?, technical} * for display. The raw backend/network text is only ever exposed as `technical` * (shown under "Technical details" by ApiErrorNotice) -- never as the primary message. * * `fallbackKey` is an existing, already-localized `t()` key used as the explanation * when the error isn't an ApiError at all (e.g. the fetch failed before a response * existed) and errors:generic doesn't fit the specific action being attempted. */ export function describeApiError(t: TFn, err: unknown, fallbackKey?: string): ApiErrorInfo { if (!(err instanceof ApiError)) { return { title: t("errors:generic.title"), explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"), technical: err instanceof Error ? err.message : String(err), }; } if (KNOWN_CODES.has(err.code)) { const nextStep = t(`errors:codes.${err.code}.nextStep`, { defaultValue: "" }); return { title: t(`errors:codes.${err.code}.title`), explanation: t(`errors:codes.${err.code}.explanation`), nextStep: nextStep || undefined, technical: err.message, }; } const httpKey = KNOWN_HTTP_STATUSES.has(err.code) ? err.code : String(err.status); if (KNOWN_HTTP_STATUSES.has(httpKey)) { const nextStep = t(`errors:http.${httpKey}.nextStep`, { defaultValue: "" }); return { title: t(`errors:http.${httpKey}.title`), explanation: t(`errors:http.${httpKey}.explanation`), nextStep: nextStep || undefined, technical: err.message, }; } return { title: t("errors:generic.title"), explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"), technical: err.message, }; }