feat: centralize API error localization

Replace the err instanceof ApiError ? err.message : t(fallback) anti-
pattern -- which showed raw English backend text for the common case and
only used the localized fallback for the rare network-failure case -- at
all 13 call sites across 7 files.

New frontend/src/api/errorMessages.ts (describeApiError) resolves a
caught error to a localized {title, explanation, nextStep?, technical}
by checking the 32 known AppError codes first, then known HTTP statuses
(401/403/404/409/422/500), then a fully generic fallback. New
ApiErrorNotice (PageChrome.tsx) renders title/explanation/nextStep with
the raw text demoted to a "Technical details"/"Details techniques"
disclosure -- never shown as the primary message.

ApiError itself is split out of client.ts into a standalone
api/apiError.ts with no import.meta.env dependency, so errorMessages.ts
(and its tests) can be loaded outside a Vite/browser context.
This commit is contained in:
NuklearRabbit
2026-08-04 03:08:07 +02:00
parent 94cfb7bcbb
commit d17af1c52a
16 changed files with 940 additions and 82 deletions
+178 -6
View File
@@ -1,8 +1,180 @@
{
"generic": "Something went wrong. Please try again.",
"workspaceLoadFailed": "We couldn't load this workspace.",
"unauthorized": "Your session has expired. Please log in again.",
"forbidden": "You don't have access to this section.",
"notFound": "This record could not be found.",
"networkUnavailable": "The connection to the server is currently unavailable."
"generic": {
"title": "Something went wrong",
"explanation": "This action could not be completed. Please try again."
},
"codes": {
"VEHICLE_NOT_FOUND": {
"title": "Vehicle not found",
"explanation": "This vehicle could not be found. It may have been removed or the reference may be incorrect."
},
"BOOKING_NOT_FOUND": {
"title": "Booking not found",
"explanation": "This booking could not be found. It may have been removed or the reference may be incorrect."
},
"CUSTOMER_NOT_FOUND": {
"title": "Customer not found",
"explanation": "This customer record could not be found."
},
"ENTITY_NOT_FOUND": {
"title": "Record not found",
"explanation": "The underlying record for this action could not be found."
},
"EVENT_NOT_FOUND": {
"title": "Workflow event not found",
"explanation": "This automation event could not be found."
},
"ISSUE_NOT_FOUND": {
"title": "Data-quality issue not found",
"explanation": "This data-quality issue could not be found."
},
"ISSUE_NOT_OPEN": {
"title": "Issue is no longer open",
"explanation": "This issue has already been resolved, deferred or rejected.",
"nextStep": "Refresh the page to see its current state."
},
"BOOKING_NOT_ACTIVE": {
"title": "Booking is not active",
"explanation": "Only a reserved or active booking can be used for this action."
},
"INVALID_BOOKING_STATE": {
"title": "Booking is in the wrong state",
"explanation": "This booking's current state does not allow this action."
},
"NOT_RETRYABLE": {
"title": "This event cannot be retried",
"explanation": "Only a failed delivery can be retried."
},
"CONFLICT_STILL_PRESENT": {
"title": "Conflict was not resolved",
"explanation": "Applying this change did not resolve the underlying conflict.",
"nextStep": "Review the recommendation again before retrying."
},
"OVERLAP_STILL_PRESENT": {
"title": "Overlap was not resolved",
"explanation": "Blocking this booking did not remove the overlap; another commitment remains."
},
"EMPTY_VALUE": {
"title": "A required field is blank",
"explanation": "This field cannot be left blank.",
"nextStep": "Enter a value and try again."
},
"NO_FIELDS_PROVIDED": {
"title": "No changes provided",
"explanation": "At least one field must be filled in to continue."
},
"INVALID_FIELD": {
"title": "Field not allowed here",
"explanation": "One of the fields provided is not permitted for this action."
},
"INVALID_FIELD_OVERRIDE": {
"title": "Field cannot be merged",
"explanation": "One of the selected fields cannot be used in this merge."
},
"INVALID_SURVIVOR": {
"title": "Invalid selection",
"explanation": "The record you selected to keep must be one of the two records being compared."
},
"INVALID_BOOKING_REFERENCE": {
"title": "Booking reference not valid here",
"explanation": "The selected booking is not one of the bookings related to this issue."
},
"INVALID_EVENT_ID": {
"title": "Invalid event reference",
"explanation": "This automation event reference is not valid."
},
"INVALID_IDEMPOTENCY_KEY": {
"title": "Request could not be repeated safely",
"explanation": "This request's tracking key is not valid.",
"nextStep": "Reload the page and try again."
},
"IDEMPOTENCY_KEY_REUSED": {
"title": "This action was already submitted",
"explanation": "An identical request was already processed with a different outcome.",
"nextStep": "Reload the page to see the current state before retrying."
},
"CORRECTED_VALUE_REQUIRED": {
"title": "A corrected value is required",
"explanation": "Choose \"correct the reading\" requires entering the corrected value."
},
"CORRECTION_BELOW_CANONICAL": {
"title": "Correction is below the confirmed reading",
"explanation": "A corrected odometer reading can never be lower than the last confirmed reading."
},
"NOT_A_DUPLICATE_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to possible-duplicate-customer issues."
},
"NOT_A_MISSING_FIELD_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to missing-required-field issues."
},
"NOT_AN_ODOMETER_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to odometer-regression issues."
},
"NOT_AN_OVERLAP_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to booking-overlap issues."
},
"NOT_A_STATUS_CONFLICT_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to vehicle-status-conflict issues."
},
"UNSUPPORTED_ENTITY": {
"title": "Not supported for this record type",
"explanation": "This action is not available for this kind of record."
},
"MANUAL_REVIEW_REQUIRED": {
"title": "Manual review required",
"explanation": "The facts for this vehicle contradict each other, so no automatic change is safe.",
"nextStep": "Defer or reject this issue, or investigate manually."
},
"NO_CONFLICT_DETECTED": {
"title": "Nothing to apply",
"explanation": "The current state no longer conflicts, so there is nothing left to apply."
},
"RECOMMENDATION_STALE": {
"title": "The situation has changed",
"explanation": "The underlying facts changed since this recommendation was shown.",
"nextStep": "Review the recommendation again before applying it."
},
"UNAUTHORIZED_SERVICE": {
"title": "Service authorisation failed",
"explanation": "This automated request could not be authorised."
}
},
"http": {
"401": {
"title": "Session expired",
"explanation": "Your session has expired.",
"nextStep": "Please log in again."
},
"403": {
"title": "Permission denied",
"explanation": "You don't have permission to perform this action."
},
"404": {
"title": "Not found",
"explanation": "This record could not be found."
},
"409": {
"title": "This action is no longer possible",
"explanation": "The underlying state has changed since this page was loaded.",
"nextStep": "Refresh the page and try again."
},
"422": {
"title": "Validation failed",
"explanation": "The information provided is not valid."
},
"500": {
"title": "Server error",
"explanation": "Something went wrong on our side."
},
"network": {
"title": "Connection unavailable",
"explanation": "The connection to the server is currently unavailable.",
"nextStep": "Check your connection and try again."
}
}
}