473 lines
13 KiB
TypeScript
Raw Normal View History

/**
* Unified Error Handling for Customer Portal
*
* Single source of truth for error codes and user-friendly messages.
* Used by both BFF (for generating responses) and Portal (for parsing responses).
*/
import { z } from "zod";
// ============================================================================
// Error Categories and Severity
// ============================================================================
export type ErrorCategory =
| "authentication"
| "authorization"
| "validation"
| "business"
| "system"
| "network";
export type ErrorSeverity = "low" | "medium" | "high" | "critical";
// ============================================================================
// Error Code Registry
// ============================================================================
/**
* All error codes used in the application.
* Format: CATEGORY_NUMBER (e.g., AUTH_001, VAL_001)
*/
export const ErrorCode = {
// Authentication Errors (AUTH_*)
INVALID_CREDENTIALS: "AUTH_001",
ACCOUNT_LOCKED: "AUTH_002",
SESSION_EXPIRED: "AUTH_003",
TOKEN_INVALID: "AUTH_004",
TOKEN_REVOKED: "AUTH_005",
REFRESH_TOKEN_INVALID: "AUTH_006",
// Authorization Errors (AUTHZ_*)
FORBIDDEN: "AUTHZ_001",
ADMIN_REQUIRED: "AUTHZ_002",
RESOURCE_ACCESS_DENIED: "AUTHZ_003",
// Validation Errors (VAL_*)
VALIDATION_FAILED: "VAL_001",
REQUIRED_FIELD_MISSING: "VAL_002",
INVALID_FORMAT: "VAL_003",
NOT_FOUND: "VAL_004",
// Business Logic Errors (BIZ_*)
ACCOUNT_EXISTS: "BIZ_001",
ACCOUNT_ALREADY_LINKED: "BIZ_002",
CUSTOMER_NOT_FOUND: "BIZ_003",
ORDER_ALREADY_PROCESSED: "BIZ_004",
INSUFFICIENT_BALANCE: "BIZ_005",
SERVICE_UNAVAILABLE: "BIZ_006",
LEGACY_ACCOUNT_EXISTS: "BIZ_007",
ACCOUNT_MAPPING_MISSING: "BIZ_008",
// System Errors (SYS_*)
INTERNAL_ERROR: "SYS_001",
EXTERNAL_SERVICE_ERROR: "SYS_002",
DATABASE_ERROR: "SYS_003",
CONFIGURATION_ERROR: "SYS_004",
// Network/Rate Limiting (NET_*)
NETWORK_ERROR: "NET_001",
TIMEOUT: "NET_002",
RATE_LIMITED: "NET_003",
// Generic
UNKNOWN: "GEN_001",
} as const;
export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode];
// ============================================================================
// Error Messages (User-Friendly)
// ============================================================================
/**
* User-friendly error messages for each error code.
* These are safe to display to end users.
*/
export const ErrorMessages: Record<ErrorCodeType, string> = {
// Authentication
[ErrorCode.INVALID_CREDENTIALS]: "Invalid email or password. Please try again.",
[ErrorCode.ACCOUNT_LOCKED]:
"Your account has been temporarily locked due to multiple failed login attempts. Please try again later.",
[ErrorCode.SESSION_EXPIRED]: "Your session has expired. Please log in again.",
[ErrorCode.TOKEN_INVALID]: "Your session is invalid. Please log in again.",
[ErrorCode.TOKEN_REVOKED]: "Your session has been revoked. Please log in again.",
[ErrorCode.REFRESH_TOKEN_INVALID]: "Your session could not be refreshed. Please log in again.",
// Authorization
[ErrorCode.FORBIDDEN]: "You do not have permission to perform this action.",
[ErrorCode.ADMIN_REQUIRED]: "Administrator access is required for this action.",
[ErrorCode.RESOURCE_ACCESS_DENIED]: "You do not have access to this resource.",
// Validation
[ErrorCode.VALIDATION_FAILED]: "The provided data is invalid. Please check your input.",
[ErrorCode.REQUIRED_FIELD_MISSING]: "Required information is missing.",
[ErrorCode.INVALID_FORMAT]: "The data format is invalid.",
[ErrorCode.NOT_FOUND]: "The requested resource was not found.",
// Business Logic
[ErrorCode.ACCOUNT_EXISTS]:
"We couldn't create a new account with these details. Please sign in or contact support.",
[ErrorCode.ACCOUNT_ALREADY_LINKED]:
"This billing account is already linked to a portal account. Please sign in.",
[ErrorCode.LEGACY_ACCOUNT_EXISTS]:
"We couldn't create a new account with these details. Please transfer your account or contact support.",
[ErrorCode.CUSTOMER_NOT_FOUND]: "Customer account not found. Please contact support.",
[ErrorCode.ORDER_ALREADY_PROCESSED]: "This order has already been processed.",
[ErrorCode.INSUFFICIENT_BALANCE]: "Insufficient account balance.",
[ErrorCode.SERVICE_UNAVAILABLE]:
"This service is temporarily unavailable. Please try again later.",
[ErrorCode.ACCOUNT_MAPPING_MISSING]:
"Your account isnt fully set up yet. Please contact support or try again later.",
// System
[ErrorCode.INTERNAL_ERROR]: "An unexpected error occurred. Please try again later.",
[ErrorCode.EXTERNAL_SERVICE_ERROR]:
"An external service is temporarily unavailable. Please try again later.",
[ErrorCode.DATABASE_ERROR]: "A system error occurred. Please try again later.",
[ErrorCode.CONFIGURATION_ERROR]: "A system configuration error occurred. Please contact support.",
// Network
[ErrorCode.NETWORK_ERROR]:
"Unable to connect to the server. Please check your internet connection.",
[ErrorCode.TIMEOUT]: "The request timed out. Please try again.",
[ErrorCode.RATE_LIMITED]: "Too many requests. Please wait a moment and try again.",
// Generic
[ErrorCode.UNKNOWN]: "An unexpected error occurred. Please try again.",
};
// ============================================================================
// Error Metadata
// ============================================================================
interface ErrorMetadata {
category: ErrorCategory;
severity: ErrorSeverity;
shouldLogout: boolean;
shouldRetry: boolean;
logLevel: "error" | "warn" | "info" | "debug";
}
/**
* Metadata for each error code defining behavior and classification.
*/
export const ErrorMetadata: Record<ErrorCodeType, ErrorMetadata> = {
// Authentication - mostly medium severity, some trigger logout
[ErrorCode.INVALID_CREDENTIALS]: {
category: "authentication",
severity: "medium",
shouldLogout: false,
shouldRetry: false,
logLevel: "warn",
},
[ErrorCode.ACCOUNT_LOCKED]: {
category: "authentication",
severity: "medium",
shouldLogout: false,
shouldRetry: false,
logLevel: "warn",
},
[ErrorCode.SESSION_EXPIRED]: {
category: "authentication",
severity: "low",
shouldLogout: true,
shouldRetry: false,
// Session expiry is an expected flow (browser tabs, refresh loops, etc.) and can be extremely noisy.
// Keep it available for debugging but avoid spamming production logs at info level.
logLevel: "debug",
},
[ErrorCode.TOKEN_INVALID]: {
category: "authentication",
severity: "medium",
shouldLogout: true,
shouldRetry: false,
logLevel: "warn",
},
[ErrorCode.TOKEN_REVOKED]: {
category: "authentication",
severity: "medium",
shouldLogout: true,
shouldRetry: false,
logLevel: "warn",
},
[ErrorCode.REFRESH_TOKEN_INVALID]: {
category: "authentication",
severity: "medium",
shouldLogout: true,
shouldRetry: false,
logLevel: "warn",
},
// Authorization
[ErrorCode.FORBIDDEN]: {
category: "authorization",
severity: "medium",
shouldLogout: false,
shouldRetry: false,
logLevel: "warn",
},
[ErrorCode.ADMIN_REQUIRED]: {
category: "authorization",
severity: "medium",
shouldLogout: false,
shouldRetry: false,
logLevel: "warn",
},
[ErrorCode.RESOURCE_ACCESS_DENIED]: {
category: "authorization",
severity: "medium",
shouldLogout: false,
shouldRetry: false,
logLevel: "warn",
},
// Validation - low severity, informational
[ErrorCode.VALIDATION_FAILED]: {
category: "validation",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.REQUIRED_FIELD_MISSING]: {
category: "validation",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.INVALID_FORMAT]: {
category: "validation",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.NOT_FOUND]: {
category: "validation",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
// Business Logic
[ErrorCode.ACCOUNT_EXISTS]: {
category: "business",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.ACCOUNT_ALREADY_LINKED]: {
category: "business",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.LEGACY_ACCOUNT_EXISTS]: {
category: "business",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.CUSTOMER_NOT_FOUND]: {
category: "business",
severity: "medium",
shouldLogout: false,
shouldRetry: false,
logLevel: "warn",
},
[ErrorCode.ORDER_ALREADY_PROCESSED]: {
category: "business",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.INSUFFICIENT_BALANCE]: {
category: "business",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.SERVICE_UNAVAILABLE]: {
category: "business",
severity: "medium",
shouldLogout: false,
shouldRetry: true,
logLevel: "warn",
},
[ErrorCode.ACCOUNT_MAPPING_MISSING]: {
category: "business",
severity: "medium",
shouldLogout: false,
shouldRetry: false,
logLevel: "warn",
},
// System - high severity
[ErrorCode.INTERNAL_ERROR]: {
category: "system",
severity: "high",
shouldLogout: false,
shouldRetry: true,
logLevel: "error",
},
[ErrorCode.EXTERNAL_SERVICE_ERROR]: {
category: "system",
severity: "medium",
shouldLogout: false,
shouldRetry: true,
logLevel: "error",
},
[ErrorCode.DATABASE_ERROR]: {
category: "system",
severity: "critical",
shouldLogout: false,
shouldRetry: true,
logLevel: "error",
},
[ErrorCode.CONFIGURATION_ERROR]: {
category: "system",
severity: "critical",
shouldLogout: false,
shouldRetry: false,
logLevel: "error",
},
// Network
[ErrorCode.NETWORK_ERROR]: {
category: "network",
severity: "medium",
shouldLogout: false,
shouldRetry: true,
logLevel: "warn",
},
[ErrorCode.TIMEOUT]: {
category: "network",
severity: "medium",
shouldLogout: false,
shouldRetry: true,
logLevel: "warn",
},
[ErrorCode.RATE_LIMITED]: {
category: "network",
severity: "low",
shouldLogout: false,
shouldRetry: true,
logLevel: "warn",
},
// Generic
[ErrorCode.UNKNOWN]: {
category: "system",
severity: "medium",
shouldLogout: false,
shouldRetry: true,
logLevel: "error",
},
};
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Map HTTP status codes to domain error codes.
* Shared between BFF (for responses) and Portal (for parsing).
*/
export function mapHttpStatusToErrorCode(status?: number): ErrorCodeType {
if (!status) return ErrorCode.UNKNOWN;
switch (status) {
case 401:
return ErrorCode.SESSION_EXPIRED;
case 403:
return ErrorCode.FORBIDDEN;
case 404:
return ErrorCode.NOT_FOUND;
case 409:
return ErrorCode.ACCOUNT_EXISTS;
case 400:
return ErrorCode.VALIDATION_FAILED;
case 429:
return ErrorCode.RATE_LIMITED;
case 503:
return ErrorCode.SERVICE_UNAVAILABLE;
default:
return status >= 500 ? ErrorCode.INTERNAL_ERROR : ErrorCode.UNKNOWN;
}
}
/**
* Get user-friendly message for an error code
*/
export function getMessageForErrorCode(code: string): string {
return ErrorMessages[code as ErrorCodeType] ?? ErrorMessages[ErrorCode.UNKNOWN];
}
/**
* Get metadata for an error code
*/
export function getErrorMetadata(code: string): ErrorMetadata {
return ErrorMetadata[code as ErrorCodeType] ?? ErrorMetadata[ErrorCode.UNKNOWN];
}
/**
* Check if error code should trigger logout
*/
export function shouldLogoutForCode(code: string): boolean {
return getErrorMetadata(code).shouldLogout;
}
/**
* Check if error can be retried
*/
export function canRetryError(code: string): boolean {
return getErrorMetadata(code).shouldRetry;
}
// NOTE: We intentionally do NOT support matching error messages to codes.
// Error codes must be explicit and stable (returned from the API or thrown by server code).
// ============================================================================
// Zod Schema for Error Response
// ============================================================================
/**
* Schema for standard API error response
*/
export const apiErrorSchema = z.object({
success: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
details: z.record(z.string(), z.unknown()).optional(),
}),
});
export type ApiError = z.infer<typeof apiErrorSchema>;
/**
* Create a standard error response object
*/
export function createErrorResponse(
code: ErrorCodeType,
customMessage?: string,
details?: Record<string, unknown>
): ApiError {
return {
success: false,
error: {
code,
message: customMessage ?? getMessageForErrorCode(code),
details,
},
};
}