Refactored UI endpoint usage

This commit is contained in:
2026-09-20 08:26:06 +02:00
parent b703b0c64d
commit 5829bffaa0
35 changed files with 17482 additions and 648 deletions
@@ -19,6 +19,7 @@ export interface FriendlyError {
category: ErrorCategory;
categoryLabel?: string;
fieldErrors?: Array<{ field: string; message: string }>;
errorsList?: string[];
technicalDetails?: string;
}
@@ -80,7 +81,7 @@ function humanizeFieldName(field: string): string {
function cleanTechnicalJargon(text: string): string {
if (!text) return '';
let cleaned = text
const cleaned = text
.replace(/^AxiosError:\s*/i, '')
.replace(/^Error:\s*/i, '')
.replace(/Request failed with status code \d+/i, '')
@@ -90,33 +91,14 @@ function cleanTechnicalJargon(text: string): string {
.replace(/at\s+[a-zA-Z0-9_.]+\s*\(.*?\)/g, '')
.trim();
// If after cleaning it's empty or trivial, return empty
if (cleaned.length < 3 || cleaned === '[object Object]') {
// In this system, ProblemDetails title is always "Error" — ignore trivial or generic "Error" strings
if (cleaned.length < 2 || cleaned === '[object Object]' || cleaned.toLowerCase() === 'error') {
return '';
}
return cleaned;
}
/**
* Checks if a title is a standard framework generic title.
*/
function isGenericFrameworkTitle(title?: string): boolean {
if (!title) return true;
const lower = title.toLowerCase().trim();
return (
lower === 'bad request' ||
lower === 'one or more validation errors occurred.' ||
lower === 'one or more validation errors occurred' ||
lower === 'forbidden' ||
lower === 'not found' ||
lower === 'internal server error' ||
lower === 'an error occurred while processing your request.' ||
lower === 'api request error' ||
lower.startsWith('request failed with')
);
}
/**
* Checks if a response object conforms to RFC 7807 / RFC 9457 ProblemDetails.
*/
@@ -127,7 +109,9 @@ export function isProblemDetailsObject(data: any): data is ProblemDetails {
'detail' in data ||
'status' in data ||
'type' in data ||
'errors' in data
'errors' in data ||
'Errors' in data ||
'errorList' in data
);
}
@@ -180,11 +164,8 @@ function detectOperationIntent(
/**
* Extracts and formats human-friendly error guidance from any error,
* prioritizing the standardised ProblemDetails model when present,
* and handling BadRequest specifically for its multiple purposes:
* - Input validation errors (field-level)
* - Creation errors (prerequisites, business rules, duplicate identifiers)
* - Update errors (lifecycle state restrictions, invalid transitions, permissions)
* accessing the ProblemDetails `errors` array directly to display the error.
* In this system the title field is always "Error", so no generic error testing is performed.
*/
export function parseFriendlyError(
error: unknown,
@@ -193,7 +174,7 @@ export function parseFriendlyError(
let statusCode: number | undefined;
let rawTitle: string | undefined;
let rawDetail: string | undefined;
let rawErrors: Record<string, string[] | string> | undefined;
let rawErrors: any = undefined;
let isProblemDetails = false;
let rawResponseData: any = null;
let technicalSummary = '';
@@ -210,25 +191,31 @@ export function parseFriendlyError(
technicalSummary = `${httpMethod || 'HTTP'} ${requestUrl} -> ${statusCode || axiosErr.code || 'ERR'}`;
if (rawResponseData && typeof rawResponseData === 'object') {
if (isProblemDetailsObject(rawResponseData)) {
const dataObj = rawResponseData as any;
if (isProblemDetailsObject(dataObj)) {
isProblemDetails = true;
rawTitle = rawResponseData.title || undefined;
rawDetail = rawResponseData.detail || undefined;
statusCode = rawResponseData.status || statusCode;
rawErrors = rawResponseData.errors;
} else if (rawResponseData.message || rawResponseData.error) {
rawDetail = rawResponseData.message || rawResponseData.error;
rawTitle = dataObj.title || dataObj.Title || undefined;
rawDetail = dataObj.detail || dataObj.Detail || undefined;
statusCode = dataObj.status || dataObj.Status || statusCode;
rawErrors = dataObj.errors ?? dataObj.Errors ?? dataObj.errorList ?? dataObj.ErrorList;
} else if (dataObj.message || dataObj.error || dataObj.errors || dataObj.Errors) {
rawDetail = dataObj.message || dataObj.error;
rawErrors = dataObj.errors ?? dataObj.Errors;
} else if (Array.isArray(dataObj)) {
rawErrors = dataObj;
}
} else if (typeof rawResponseData === 'string') {
// Check if response data is stringified JSON containing ProblemDetails
try {
const parsed = JSON.parse(rawResponseData);
const parsed = JSON.parse(rawResponseData) as any;
if (isProblemDetailsObject(parsed)) {
isProblemDetails = true;
rawTitle = parsed.title || undefined;
rawDetail = parsed.detail || undefined;
statusCode = parsed.status || statusCode;
rawErrors = parsed.errors;
rawTitle = parsed.title || parsed.Title || undefined;
rawDetail = parsed.detail || parsed.Detail || undefined;
statusCode = parsed.status || parsed.Status || statusCode;
rawErrors = parsed.errors ?? parsed.Errors ?? parsed.errorList ?? parsed.ErrorList;
} else if (Array.isArray(parsed)) {
rawErrors = parsed;
} else {
rawDetail = cleanTechnicalJargon(rawResponseData);
}
@@ -267,16 +254,19 @@ export function parseFriendlyError(
const obj = error as any;
if (isProblemDetailsObject(obj)) {
isProblemDetails = true;
rawTitle = obj.title || undefined;
rawDetail = obj.detail || undefined;
statusCode = obj.status || undefined;
rawErrors = obj.errors;
rawTitle = obj.title || obj.Title || undefined;
rawDetail = obj.detail || obj.Detail || undefined;
statusCode = obj.status || obj.Status || undefined;
rawErrors = obj.errors ?? obj.Errors ?? obj.errorList ?? obj.ErrorList;
} else if (obj.response?.data && isProblemDetailsObject(obj.response.data)) {
isProblemDetails = true;
rawTitle = obj.response.data.title || undefined;
rawDetail = obj.response.data.detail || undefined;
statusCode = obj.response.status || obj.response.data.status;
rawErrors = obj.response.data.errors;
const respData = obj.response.data as any;
rawTitle = respData.title || respData.Title || undefined;
rawDetail = respData.detail || respData.Detail || undefined;
statusCode = obj.response.status || respData.status || respData.Status;
rawErrors = respData.errors ?? respData.Errors ?? respData.errorList ?? respData.ErrorList;
} else if (Array.isArray(obj)) {
rawErrors = obj;
} else if (obj.message) {
rawDetail = obj.message;
const statusMatch = obj.message.match(/status code (\d{3})/i);
@@ -285,7 +275,7 @@ export function parseFriendlyError(
}
}
} else if (typeof error === 'string') {
// 3. String error, e.g. String(err)
// 3. String error
const statusMatch = error.match(/status code (\d{3})/i);
if (statusMatch) {
statusCode = parseInt(statusMatch[1], 10);
@@ -293,187 +283,188 @@ export function parseFriendlyError(
rawDetail = cleanTechnicalJargon(error);
}
// Parse field-level validation errors from ProblemDetails if present
// Extract errors list directly from ProblemDetails errors array / dictionary
const fieldErrors: Array<{ field: string; message: string }> = [];
if (rawErrors && typeof rawErrors === 'object') {
for (const [key, val] of Object.entries(rawErrors)) {
const fieldTitle = humanizeFieldName(key);
if (Array.isArray(val)) {
val.forEach((msg) => {
if (msg) fieldErrors.push({ field: fieldTitle, message: cleanTechnicalJargon(String(msg)) });
});
} else if (typeof val === 'string' && val) {
fieldErrors.push({ field: fieldTitle, message: cleanTechnicalJargon(val) });
const errorsList: string[] = [];
if (rawErrors) {
if (Array.isArray(rawErrors)) {
rawErrors.forEach((item) => {
if (typeof item === 'string' && item.trim()) {
const cleaned = cleanTechnicalJargon(item.trim());
if (cleaned) errorsList.push(cleaned);
} else if (item && typeof item === 'object') {
const msg =
item.message ||
item.errorMessage ||
item.detail ||
item.error ||
item.description ||
JSON.stringify(item);
const cleaned = cleanTechnicalJargon(String(msg));
if (cleaned) errorsList.push(cleaned);
}
});
} else if (typeof rawErrors === 'object') {
for (const [key, val] of Object.entries(rawErrors)) {
const isNumericKey = /^\d+$/.test(key);
const fieldTitle = isNumericKey ? '' : humanizeFieldName(key);
if (Array.isArray(val)) {
val.forEach((msg) => {
if (msg) {
const cleaned = cleanTechnicalJargon(String(msg));
if (cleaned) {
if (fieldTitle && !cleaned.toLowerCase().includes(fieldTitle.toLowerCase())) {
fieldErrors.push({ field: fieldTitle, message: cleaned });
errorsList.push(`${fieldTitle}: ${cleaned}`);
} else {
errorsList.push(cleaned);
}
}
}
});
} else if (typeof val === 'string' && val.trim()) {
const cleaned = cleanTechnicalJargon(val.trim());
if (cleaned) {
if (fieldTitle && !cleaned.toLowerCase().includes(fieldTitle.toLowerCase())) {
fieldErrors.push({ field: fieldTitle, message: cleaned });
errorsList.push(`${fieldTitle}: ${cleaned}`);
} else {
errorsList.push(cleaned);
}
}
}
}
} else if (typeof rawErrors === 'string' && rawErrors.trim()) {
const cleaned = cleanTechnicalJargon(rawErrors.trim());
if (cleaned) errorsList.push(cleaned);
}
}
// Detect whether the operation is creation, update, deletion, or general
// Detect operation intent (creation, update, deletion, general)
const operationIntent = detectOperationIntent(httpMethod, fallbackActionTitle, requestUrl);
// Build user-friendly title, message, and category based on standard HTTP codes and ProblemDetails
let friendlyTitle = fallbackActionTitle;
let friendlyMessage = '';
let category: ErrorCategory = 'general';
let categoryLabel: string | undefined = undefined;
switch (statusCode) {
case 403:
// Forbid: specifically occurs when the hashid used on a call is invalid or access denied
category = 'security';
categoryLabel = 'Invalid Identifier / Forbid';
friendlyTitle = 'Access Not Permitted or Invalid Identifier';
friendlyMessage =
cleanTechnicalJargon(rawDetail || '') ||
'The reference identifier (hash ID) is invalid, expired, or you do not have permission to access this item. Please verify your selection or contact your system administrator.';
break;
const cleanDetail = cleanTechnicalJargon(rawDetail || '');
case 404:
// NotFound: item could not be located
category = 'notFound';
categoryLabel = 'Record Not Found';
friendlyTitle = 'Record Not Found';
friendlyMessage =
cleanTechnicalJargon(rawDetail || '') ||
'The requested record could not be found. It may have been archived, removed, or the link is outdated.';
break;
// In this system, ProblemDetails allows access to the errors array to display the error directly if available
if (errorsList.length > 0) {
category = 'validation';
categoryLabel =
operationIntent === 'creation'
? 'Validation'
: operationIntent === 'update'
? 'Validation'
: 'Validation';
case 400: {
// BadRequest serves MULTIPLE purposes:
// 1) Input validation errors (field-level errors dictionary)
// 2) Creation errors (prerequisites, business rules, duplicate code, invalid entity state)
// 3) Update errors (status transition restrictions, record locked, state mismatch)
const hasSpecificTitle = rawTitle && !isGenericFrameworkTitle(rawTitle);
const cleanDetail = cleanTechnicalJargon(rawDetail || '');
if (fieldErrors.length > 0) {
// Purpose 1: Input Validation Errors
category = 'validation';
categoryLabel =
operationIntent === 'creation'
? 'Creation Validation'
: operationIntent === 'update'
? 'Update Validation'
: 'Input Validation';
if (hasSpecificTitle) {
friendlyTitle = cleanTechnicalJargon(rawTitle!);
} else if (operationIntent === 'creation') {
friendlyTitle = 'Please Check New Record Details';
} else if (operationIntent === 'update') {
friendlyTitle = 'Please Check Updated Values';
} else {
friendlyTitle = 'Please Check Submitted Details';
}
const errorList = fieldErrors.map((f) => `• ${f.field}: ${f.message}`).join('\n');
friendlyMessage = cleanDetail
? `${cleanDetail}\n\n${errorList}`
: `The submitted details could not be validated:\n${errorList}`;
} else if (operationIntent === 'creation') {
// Purpose 2: Creation Error (non-field-validation: business rule, duplicate, or prerequisite failure)
category = 'creation';
categoryLabel = 'Creation Error';
if (hasSpecificTitle) {
friendlyTitle = cleanTechnicalJargon(rawTitle!);
} else if (fallbackActionTitle && fallbackActionTitle !== 'Action Failed') {
friendlyTitle = fallbackActionTitle;
} else {
friendlyTitle = 'Unable to Create Record';
}
friendlyMessage =
cleanDetail ||
'The new record could not be created. This may be due to business rule prerequisites, existing duplicate identifiers, or missing related records.';
} else if (operationIntent === 'update') {
// Purpose 3: Update Error (non-field-validation: status transition, record locked, lifecycle restriction)
category = 'update';
categoryLabel = 'Update Error';
if (hasSpecificTitle) {
friendlyTitle = cleanTechnicalJargon(rawTitle!);
} else if (fallbackActionTitle && fallbackActionTitle !== 'Action Failed') {
friendlyTitle = fallbackActionTitle;
} else {
friendlyTitle = 'Unable to Update Record';
}
friendlyMessage =
cleanDetail ||
'The record could not be updated. It may be locked, in a status that does not allow modifications, or the requested status transition is not permitted.';
} else {
// General Bad Request
category = 'general';
categoryLabel = 'Request Error';
if (hasSpecificTitle) {
friendlyTitle = cleanTechnicalJargon(rawTitle!);
} else {
friendlyTitle = fallbackActionTitle || 'Request Could Not Be Processed';
}
friendlyMessage =
cleanDetail ||
'The request could not be processed due to invalid parameters or business rule requirements. Please review your input and try again.';
}
break;
// Display the error directly
if (errorsList.length === 1) {
friendlyMessage = errorsList[0];
} else {
friendlyMessage = errorsList.map((err) => `• ${err}`).join('\n');
}
case 409:
// Conflict
// If detail provides extra non-duplicate context, include it
if (cleanDetail && !errorsList.some((e) => e.toLowerCase() === cleanDetail.toLowerCase())) {
friendlyMessage = `${cleanDetail}\n\n${friendlyMessage}`;
}
} else if (cleanDetail) {
// If no errors array, but detail is available, display detail directly
friendlyMessage = cleanDetail;
if (statusCode === 403) {
category = 'security';
categoryLabel = 'Access Denied';
} else if (statusCode === 404) {
category = 'notFound';
categoryLabel = 'Not Found';
} else if (statusCode === 409) {
category = 'conflict';
categoryLabel = 'Conflict';
friendlyTitle = 'Update Conflict';
friendlyMessage =
cleanTechnicalJargon(rawDetail || '') ||
'This action conflicts with the current status of the record (for instance, another user may have updated it or a duplicate entry exists). Please refresh the page and try again.';
break;
case 422:
// Unprocessable Entity
category = 'validation';
categoryLabel = 'Incomplete Data';
friendlyTitle = 'Information Incomplete';
friendlyMessage =
cleanTechnicalJargon(rawDetail || '') ||
'The submitted information could not be processed by the system. Please verify that all required fields are complete.';
break;
case 429:
// Too Many Requests
category = 'server';
categoryLabel = 'Rate Limit';
friendlyTitle = 'System Busy';
friendlyMessage = 'The system received too many requests in a short time. Please pause for a moment before trying again.';
break;
case 500:
case 502:
case 503:
case 504:
// Server error
} else if (statusCode && statusCode >= 500) {
category = 'server';
categoryLabel = 'Service Issue';
friendlyTitle = 'Service Temporarily Unavailable';
friendlyMessage =
'The central service encountered an unexpected issue while processing your request. Please try again in a few moments.';
break;
}
} else {
// Fallback status-specific messaging when neither errors array nor detail is available
switch (statusCode) {
case 403:
category = 'security';
categoryLabel = 'Invalid Identifier / Forbid';
friendlyTitle = 'Access Not Permitted or Invalid Identifier';
friendlyMessage =
'The reference identifier (hash ID) is invalid, expired, or you do not have permission to access this item. Please verify your selection or contact your system administrator.';
break;
default:
if (rawTitle && isProblemDetails && !isGenericFrameworkTitle(rawTitle)) {
friendlyTitle = cleanTechnicalJargon(rawTitle) || fallbackActionTitle;
}
if (rawDetail) {
friendlyMessage = cleanTechnicalJargon(rawDetail);
} else if (fieldErrors.length > 0) {
case 404:
category = 'notFound';
categoryLabel = 'Record Not Found';
friendlyTitle = 'Record Not Found';
friendlyMessage =
'The requested record could not be found. It may have been archived, removed, or the link is outdated.';
break;
case 400:
if (operationIntent === 'creation') {
category = 'creation';
categoryLabel = 'Creation Error';
friendlyMessage =
'The new record could not be created. Please review the input parameters and prerequisites.';
} else if (operationIntent === 'update') {
category = 'update';
categoryLabel = 'Update Error';
friendlyMessage =
'The record could not be updated. It may be locked or the requested status transition is not permitted.';
} else {
category = 'general';
categoryLabel = 'Request Error';
friendlyMessage =
'The request could not be processed due to invalid parameters or business rule requirements.';
}
break;
case 409:
category = 'conflict';
categoryLabel = 'Conflict';
friendlyTitle = 'Update Conflict';
friendlyMessage =
'This action conflicts with the current status of the record. Please refresh the page and try again.';
break;
case 422:
category = 'validation';
categoryLabel = 'Validation';
friendlyMessage = fieldErrors.map((f) => `• ${f.field}: ${f.message}`).join('\n');
} else {
friendlyMessage = 'An unexpected issue occurred while processing this request. Please try again or contact support.';
}
break;
categoryLabel = 'Incomplete Data';
friendlyMessage =
'The submitted information could not be processed. Please verify that all required fields are complete.';
break;
case 429:
category = 'server';
categoryLabel = 'Rate Limit';
friendlyTitle = 'System Busy';
friendlyMessage =
'The system received too many requests in a short time. Please pause for a moment before trying again.';
break;
case 500:
case 502:
case 503:
case 504:
category = 'server';
categoryLabel = 'Service Issue';
friendlyTitle = 'Service Temporarily Unavailable';
friendlyMessage =
'The central service encountered an unexpected issue while processing your request. Please try again in a few moments.';
break;
default:
friendlyMessage =
'An unexpected issue occurred while processing this request. Please try again or contact support.';
break;
}
}
// Ensure title is friendly and not an uncaught technical string
@@ -489,6 +480,7 @@ export function parseFriendlyError(
category,
categoryLabel,
fieldErrors: fieldErrors.length > 0 ? fieldErrors : undefined,
errorsList: errorsList.length > 0 ? errorsList : undefined,
technicalDetails: technicalSummary || (statusCode ? `HTTP Status ${statusCode}` : undefined),
};
}