Implemented partnerui
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { ProblemDetails } from '../types';
|
||||
|
||||
export type ErrorCategory =
|
||||
| 'validation'
|
||||
| 'creation'
|
||||
| 'update'
|
||||
| 'security'
|
||||
| 'notFound'
|
||||
| 'conflict'
|
||||
| 'server'
|
||||
| 'general';
|
||||
|
||||
export interface FriendlyError {
|
||||
title: string;
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
hasReturnObject: boolean;
|
||||
isProblemDetails: boolean;
|
||||
category: ErrorCategory;
|
||||
categoryLabel?: string;
|
||||
errorsList?: string[];
|
||||
fieldErrors?: Array<{ field: string; message: string }>;
|
||||
technicalDetails?: string;
|
||||
}
|
||||
|
||||
export interface ProblemDetailsResponse {
|
||||
type?: string | null;
|
||||
title?: string | null;
|
||||
status?: number | null;
|
||||
detail?: string | null;
|
||||
details?: string | null;
|
||||
Details?: string | null;
|
||||
instance?: string | null;
|
||||
errors?: Record<string, string[] | string> | any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string contains raw HTTP request/response headers or dump.
|
||||
*/
|
||||
export function isRawHttpOutput(text: string): boolean {
|
||||
if (!text || typeof text !== 'string') return false;
|
||||
const headerPatterns = [
|
||||
/^(?:accept|authorization|content-type|content-length|origin|referer|user-agent|sec-ch-ua|sec-fetch|x-forwarded|x-real-ip|host):/im,
|
||||
/(?:authorization:\s*bearer|content-type:\s*application\/)/i,
|
||||
/HTTP\/\d(?:\.\d)?\s+\d{3}/i,
|
||||
];
|
||||
|
||||
let matches = 0;
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (/^[A-Za-z0-9-]+:\s*.+$/.test(trimmed)) {
|
||||
if (headerPatterns.some((p) => p.test(trimmed))) {
|
||||
matches++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches >= 2 || (matches >= 1 && /(?:authorization:\s*bearer|content-type:\s*application)/i.test(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Redacts sensitive tokens and credentials from technical traces.
|
||||
*/
|
||||
export function sanitizeTechnicalDetails(text: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/(authorization:\s*bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]')
|
||||
.replace(/(bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]')
|
||||
.replace(/("?(?:token|access_token|password|secret|apiKey)"?\s*[:=]\s*)"[^"]+"/gi, '$1"[REDACTED]"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans technical jargon, stack traces, and framework namespaces from text strings.
|
||||
*/
|
||||
export function cleanTechnicalJargon(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
if (isRawHttpOutput(text)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const cleaned = text
|
||||
.replace(/^AxiosError:\s*/i, '')
|
||||
.replace(/^Error:\s*/i, '')
|
||||
.replace(/Request failed with status code \d+/i, '')
|
||||
.replace(/https?:\/\/[^\s]+/g, '')
|
||||
.replace(/System\.[a-zA-Z0-9_.]+/g, '')
|
||||
.replace(/Microsoft\.[a-zA-Z0-9_.]+/g, '')
|
||||
.replace(/at\s+[a-zA-Z0-9_.]+\s*\(.*?\)/g, '')
|
||||
.trim();
|
||||
|
||||
if (cleaned.length < 2 || cleaned === '[object Object]' || cleaned.toLowerCase() === 'error') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely inspects whether an error contains a valid response body object.
|
||||
*/
|
||||
export function inspectErrorResponseObject(error: unknown): ProblemDetailsResponse | null {
|
||||
if (!error) return null;
|
||||
|
||||
const anyErr = error as any;
|
||||
const data = anyErr?.response?.data;
|
||||
if (data && typeof data === 'object') {
|
||||
return data as ProblemDetailsResponse;
|
||||
}
|
||||
if (typeof data === 'string' && data.trim().startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(data.trim());
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return parsed as ProblemDetailsResponse;
|
||||
}
|
||||
} catch {
|
||||
// Not a JSON object
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
if (
|
||||
'details' in anyErr ||
|
||||
'Details' in anyErr ||
|
||||
'detail' in anyErr ||
|
||||
'Detail' in anyErr ||
|
||||
'status' in anyErr ||
|
||||
'title' in anyErr ||
|
||||
'type' in anyErr ||
|
||||
'errors' in anyErr ||
|
||||
'Errors' in anyErr
|
||||
) {
|
||||
return anyErr as ProblemDetailsResponse;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a response object conforms to RFC 7807 / RFC 9110 ProblemDetails.
|
||||
*/
|
||||
export function isProblemDetailsObject(data: any): data is ProblemDetails {
|
||||
if (!data || typeof data !== 'object') return false;
|
||||
return (
|
||||
'details' in data ||
|
||||
'Details' in data ||
|
||||
'title' in data ||
|
||||
'detail' in data ||
|
||||
'Detail' in data ||
|
||||
'status' in data ||
|
||||
'type' in data ||
|
||||
'errors' in data ||
|
||||
'Errors' in data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a comma-delimited error list from the `detail` property.
|
||||
*/
|
||||
export function parseCommaDelimitedErrors(detail?: string | null): string[] {
|
||||
if (!detail || typeof detail !== 'string') return [];
|
||||
const trimmed = detail.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const rawParts = trimmed.split(',');
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const part of rawParts) {
|
||||
const pTrim = part.trim();
|
||||
if (!pTrim) continue;
|
||||
const cleaned = cleanTechnicalJargon(pTrim);
|
||||
if (cleaned && cleaned.length > 0) {
|
||||
errors.push(cleaned);
|
||||
} else if (pTrim.length > 0 && pTrim.toLowerCase() !== 'error') {
|
||||
errors.push(pTrim);
|
||||
}
|
||||
}
|
||||
|
||||
return errors.length > 0 ? errors : [trimmed];
|
||||
}
|
||||
|
||||
function detectOperationIntent(
|
||||
method?: string,
|
||||
actionTitle?: string,
|
||||
url?: string
|
||||
): 'creation' | 'update' | 'deletion' | 'general' {
|
||||
const normMethod = (method || '').toUpperCase();
|
||||
const text = `${actionTitle || ''} ${url || ''}`.toLowerCase();
|
||||
|
||||
if (
|
||||
normMethod === 'POST' ||
|
||||
text.includes('create') ||
|
||||
text.includes('registration') ||
|
||||
text.includes('register') ||
|
||||
text.includes('dispatch') ||
|
||||
text.includes('add ') ||
|
||||
text.includes('new ') ||
|
||||
text.includes('upload') ||
|
||||
text.includes('record actual') ||
|
||||
text.includes('log risk')
|
||||
) {
|
||||
return 'creation';
|
||||
}
|
||||
|
||||
if (
|
||||
normMethod === 'PUT' ||
|
||||
normMethod === 'PATCH' ||
|
||||
text.includes('update') ||
|
||||
text.includes('edit') ||
|
||||
text.includes('save') ||
|
||||
text.includes('status') ||
|
||||
text.includes('approve') ||
|
||||
text.includes('execute') ||
|
||||
text.includes('modify')
|
||||
) {
|
||||
return 'update';
|
||||
}
|
||||
|
||||
if (normMethod === 'DELETE' || text.includes('delete') || text.includes('remove')) {
|
||||
return 'deletion';
|
||||
}
|
||||
|
||||
return 'general';
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive common error handling function across the application.
|
||||
* Handles RFC 9110 / RFC 7807 ProblemDetails and non-technical language.
|
||||
*/
|
||||
export function parseFriendlyError(
|
||||
error: unknown,
|
||||
fallbackActionTitle = 'Action Failed'
|
||||
): FriendlyError {
|
||||
let statusCode: number | undefined;
|
||||
let technicalSummary = '';
|
||||
let httpMethod = '';
|
||||
let requestUrl = '';
|
||||
|
||||
const anyErr = error as any;
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosErr = error as AxiosError<any>;
|
||||
statusCode = axiosErr.response?.status;
|
||||
httpMethod = axiosErr.config?.method?.toUpperCase() || '';
|
||||
requestUrl = axiosErr.config?.url || '';
|
||||
technicalSummary = `${httpMethod || 'HTTP'} ${requestUrl} -> ${statusCode || axiosErr.code || 'ERR'}`;
|
||||
|
||||
if (!axiosErr.response) {
|
||||
if (axiosErr.code === 'ECONNABORTED' || axiosErr.message.includes('timeout')) {
|
||||
return {
|
||||
title: 'Request Timed Out',
|
||||
message: 'The server took too long to respond. Please check your network connection and try again.',
|
||||
statusCode: 408,
|
||||
hasReturnObject: false,
|
||||
isProblemDetails: false,
|
||||
category: 'server',
|
||||
categoryLabel: 'Timeout',
|
||||
technicalDetails: technicalSummary || axiosErr.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'Connection Unavailable',
|
||||
message: 'Unable to reach the system service. Please verify your internet connection or try again in a few moments.',
|
||||
statusCode: 0,
|
||||
hasReturnObject: false,
|
||||
isProblemDetails: false,
|
||||
category: 'server',
|
||||
categoryLabel: 'Network',
|
||||
technicalDetails: technicalSummary || axiosErr.message,
|
||||
};
|
||||
}
|
||||
} else if (anyErr && typeof anyErr === 'object') {
|
||||
statusCode = anyErr.response?.status ?? anyErr.status;
|
||||
httpMethod = anyErr.config?.method?.toUpperCase() || '';
|
||||
requestUrl = anyErr.config?.url || '';
|
||||
if (statusCode) {
|
||||
technicalSummary = `HTTP ${statusCode}`;
|
||||
}
|
||||
}
|
||||
|
||||
const returnObject = inspectErrorResponseObject(error);
|
||||
const hasReturnObject = returnObject !== null;
|
||||
|
||||
if (returnObject) {
|
||||
if (returnObject.status && typeof returnObject.status === 'number') {
|
||||
statusCode = returnObject.status;
|
||||
}
|
||||
}
|
||||
|
||||
const operationIntent = detectOperationIntent(httpMethod, fallbackActionTitle, requestUrl);
|
||||
|
||||
if (statusCode === 401) {
|
||||
const rawDetail =
|
||||
returnObject?.details ??
|
||||
returnObject?.Details ??
|
||||
returnObject?.detail ??
|
||||
returnObject?.Detail ??
|
||||
anyErr?.response?.data?.details ??
|
||||
anyErr?.response?.data?.Details ??
|
||||
anyErr?.response?.data?.detail ??
|
||||
anyErr?.details ??
|
||||
anyErr?.message;
|
||||
const cleanDetail = cleanTechnicalJargon(rawDetail || '');
|
||||
return {
|
||||
title: 'Authentication Required',
|
||||
message: cleanDetail || 'Your authentication session has expired or is unauthorized (HTTP 401). Please sign in again.',
|
||||
statusCode: 401,
|
||||
hasReturnObject,
|
||||
isProblemDetails: hasReturnObject,
|
||||
category: 'security',
|
||||
categoryLabel: 'Unauthorized',
|
||||
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : 'HTTP 401 Unauthorized',
|
||||
};
|
||||
}
|
||||
|
||||
if (statusCode === 403) {
|
||||
const rawDetail =
|
||||
returnObject?.details ??
|
||||
returnObject?.Details ??
|
||||
returnObject?.detail ??
|
||||
returnObject?.Detail ??
|
||||
anyErr?.response?.data?.details ??
|
||||
anyErr?.response?.data?.Details ??
|
||||
anyErr?.response?.data?.detail ??
|
||||
anyErr?.details ??
|
||||
anyErr?.message;
|
||||
const detailErrors = parseCommaDelimitedErrors(rawDetail);
|
||||
const friendlyMessage =
|
||||
detailErrors.length > 0
|
||||
? detailErrors.length === 1
|
||||
? detailErrors[0]
|
||||
: detailErrors.map((err) => `• ${err}`).join('\n')
|
||||
: 'You do not have permission to access this resource or perform this action (HTTP 403).';
|
||||
|
||||
return {
|
||||
title: fallbackActionTitle !== 'Action Failed' ? fallbackActionTitle : 'Access Forbidden',
|
||||
message: friendlyMessage,
|
||||
statusCode: 403,
|
||||
hasReturnObject,
|
||||
isProblemDetails: hasReturnObject,
|
||||
category: 'security',
|
||||
categoryLabel: 'Forbidden',
|
||||
errorsList: detailErrors.length > 0 ? detailErrors : undefined,
|
||||
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : 'HTTP 403 Forbidden',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasReturnObject && returnObject) {
|
||||
const rawDetail =
|
||||
returnObject.detail ??
|
||||
returnObject.Detail ??
|
||||
returnObject.details ??
|
||||
returnObject.Details ??
|
||||
anyErr?.response?.data?.detail ??
|
||||
anyErr?.response?.data?.Detail ??
|
||||
anyErr?.response?.data?.details ??
|
||||
anyErr?.response?.data?.Details ??
|
||||
anyErr?.detail ??
|
||||
anyErr?.Detail ??
|
||||
anyErr?.details ??
|
||||
anyErr?.Details ??
|
||||
returnObject.message ??
|
||||
returnObject.Message ??
|
||||
returnObject.error ??
|
||||
returnObject.Error;
|
||||
|
||||
const errorsList: string[] = [];
|
||||
|
||||
if (Array.isArray(rawDetail)) {
|
||||
rawDetail.forEach((e: any) => {
|
||||
const cleaned = cleanTechnicalJargon(typeof e === 'string' ? e : e?.message || JSON.stringify(e));
|
||||
if (cleaned) errorsList.push(cleaned);
|
||||
});
|
||||
} else if (typeof rawDetail === 'string' && rawDetail.trim()) {
|
||||
errorsList.push(...parseCommaDelimitedErrors(rawDetail));
|
||||
}
|
||||
|
||||
if (errorsList.length === 0 && returnObject.errors) {
|
||||
if (Array.isArray(returnObject.errors)) {
|
||||
returnObject.errors.forEach((e: any) => {
|
||||
const cleaned = cleanTechnicalJargon(typeof e === 'string' ? e : e?.message || JSON.stringify(e));
|
||||
if (cleaned) errorsList.push(cleaned);
|
||||
});
|
||||
} else if (typeof returnObject.errors === 'object') {
|
||||
for (const [key, val] of Object.entries(returnObject.errors)) {
|
||||
if (Array.isArray(val)) {
|
||||
val.forEach((m) => {
|
||||
const cleaned = cleanTechnicalJargon(String(m));
|
||||
if (cleaned) errorsList.push(`${key}: ${cleaned}`);
|
||||
});
|
||||
} else if (typeof val === 'string' && val.trim()) {
|
||||
const cleaned = cleanTechnicalJargon(val);
|
||||
if (cleaned) errorsList.push(`${key}: ${cleaned}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let category: ErrorCategory = 'general';
|
||||
let categoryLabel: string = 'Error';
|
||||
|
||||
if (statusCode === 400) {
|
||||
if (operationIntent === 'creation') {
|
||||
category = 'creation';
|
||||
categoryLabel = 'Creation Error';
|
||||
} else if (operationIntent === 'update') {
|
||||
category = 'update';
|
||||
categoryLabel = 'Update Error';
|
||||
} else {
|
||||
category = 'validation';
|
||||
categoryLabel = 'Validation Error';
|
||||
}
|
||||
} else if (statusCode === 404) {
|
||||
category = 'notFound';
|
||||
categoryLabel = 'Not Found';
|
||||
} else if (statusCode === 409) {
|
||||
category = 'conflict';
|
||||
categoryLabel = 'Conflict';
|
||||
} else if (statusCode === 422) {
|
||||
category = 'validation';
|
||||
categoryLabel = 'Validation Error';
|
||||
} else if (statusCode && statusCode >= 500) {
|
||||
category = 'server';
|
||||
categoryLabel = 'Server Error';
|
||||
}
|
||||
|
||||
let friendlyMessage = '';
|
||||
if (errorsList.length > 1) {
|
||||
friendlyMessage = errorsList.map((err) => `• ${err}`).join('\n');
|
||||
} else if (errorsList.length === 1) {
|
||||
friendlyMessage = errorsList[0];
|
||||
} else {
|
||||
friendlyMessage =
|
||||
operationIntent === 'creation'
|
||||
? 'Failed to create record. Please verify the submitted parameters.'
|
||||
: operationIntent === 'update'
|
||||
? 'Failed to update record. Please verify the submitted parameters.'
|
||||
: 'Request could not be processed due to invalid parameters.';
|
||||
}
|
||||
|
||||
let displayTitle = fallbackActionTitle;
|
||||
if (displayTitle === 'Action Failed' && returnObject.title && returnObject.title.toLowerCase() !== 'error') {
|
||||
displayTitle = returnObject.title;
|
||||
}
|
||||
|
||||
return {
|
||||
title: displayTitle,
|
||||
message: friendlyMessage,
|
||||
statusCode,
|
||||
hasReturnObject: true,
|
||||
isProblemDetails: true,
|
||||
category,
|
||||
categoryLabel,
|
||||
errorsList: errorsList.length > 0 ? errorsList : undefined,
|
||||
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
let title = fallbackActionTitle || 'Action Failed';
|
||||
let message = '';
|
||||
let category: ErrorCategory = 'general';
|
||||
let categoryLabel: string = 'Error';
|
||||
|
||||
const directDetail =
|
||||
anyErr?.response?.data?.detail ??
|
||||
anyErr?.response?.data?.Detail ??
|
||||
anyErr?.response?.data?.details ??
|
||||
anyErr?.response?.data?.Details ??
|
||||
anyErr?.detail ??
|
||||
anyErr?.Detail ??
|
||||
anyErr?.details ??
|
||||
anyErr?.Details;
|
||||
|
||||
const fallbackErrorsList: string[] = [];
|
||||
|
||||
if (directDetail) {
|
||||
if (typeof directDetail === 'string' && directDetail.trim()) {
|
||||
fallbackErrorsList.push(...parseCommaDelimitedErrors(directDetail.trim()));
|
||||
message = fallbackErrorsList.join('\n');
|
||||
} else if (Array.isArray(directDetail)) {
|
||||
fallbackErrorsList.push(...directDetail.map(String));
|
||||
message = fallbackErrorsList.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
category = operationIntent === 'creation' ? 'creation' : operationIntent === 'update' ? 'update' : 'validation';
|
||||
categoryLabel = 'Bad Request';
|
||||
message =
|
||||
operationIntent === 'creation'
|
||||
? 'The new record could not be created. Please review the submitted parameters.'
|
||||
: operationIntent === 'update'
|
||||
? 'The record could not be updated. Please verify the submitted parameters.'
|
||||
: 'The request could not be processed due to invalid parameters.';
|
||||
break;
|
||||
|
||||
case 404:
|
||||
title = 'Record Not Found';
|
||||
category = 'notFound';
|
||||
categoryLabel = 'Not Found';
|
||||
message = 'The requested resource could not be found (HTTP 404). It may have been archived, removed, or the link is outdated.';
|
||||
break;
|
||||
|
||||
case 409:
|
||||
title = 'Resource Conflict';
|
||||
category = 'conflict';
|
||||
categoryLabel = 'Conflict';
|
||||
message = 'This operation conflicts with the current status of the resource (HTTP 409). Please refresh the page and try again.';
|
||||
break;
|
||||
|
||||
case 422:
|
||||
category = 'validation';
|
||||
categoryLabel = 'Incomplete Data';
|
||||
message = 'The submitted information could not be processed by the server (HTTP 422). Please verify all required fields.';
|
||||
break;
|
||||
|
||||
case 429:
|
||||
title = 'Rate Limit Exceeded';
|
||||
category = 'server';
|
||||
categoryLabel = 'Rate Limit';
|
||||
message = 'The system received too many requests in a short time (HTTP 429). Please pause for a moment before trying again.';
|
||||
break;
|
||||
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
title = 'Service Temporarily Unavailable';
|
||||
category = 'server';
|
||||
categoryLabel = 'Service Issue';
|
||||
message = `The server encountered an error while processing your request (HTTP ${statusCode}). Please try again in a few moments.`;
|
||||
break;
|
||||
|
||||
default:
|
||||
if (typeof error === 'string' && error.trim()) {
|
||||
message = cleanTechnicalJargon(error);
|
||||
} else if (error instanceof Error && error.message) {
|
||||
message = cleanTechnicalJargon(error.message);
|
||||
}
|
||||
if (!message) {
|
||||
message = statusCode
|
||||
? `An unexpected server issue occurred (HTTP ${statusCode}). Please try again or contact support.`
|
||||
: 'An unexpected issue occurred while processing this request. Please try again.';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
message,
|
||||
statusCode,
|
||||
hasReturnObject: fallbackErrorsList.length > 0,
|
||||
isProblemDetails: false,
|
||||
category,
|
||||
categoryLabel,
|
||||
errorsList: fallbackErrorsList.length > 0 ? fallbackErrorsList : undefined,
|
||||
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import Hashids from 'hashids';
|
||||
|
||||
const env = (typeof import.meta !== 'undefined' && import.meta.env
|
||||
? import.meta.env
|
||||
: {}) as Record<string, string | undefined>;
|
||||
|
||||
export const HASHER_SETTINGS = {
|
||||
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
||||
salt:
|
||||
(env.VITE_HASHER_SALT as string) ||
|
||||
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||
};
|
||||
|
||||
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||
|
||||
export function updateHasherSettings(salt?: string, minLength?: number | string): void {
|
||||
let updated = false;
|
||||
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
||||
HASHER_SETTINGS.salt = salt.trim();
|
||||
updated = true;
|
||||
}
|
||||
if (minLength !== undefined && minLength !== null && minLength !== '') {
|
||||
const parsed = Number(minLength);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
HASHER_SETTINGS.minHashLength = parsed;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (updated) {
|
||||
hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if candidate is already a HashID generated by this hasher.
|
||||
*/
|
||||
export function isHashId(candidate: string | null | undefined): boolean {
|
||||
if (!candidate || typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const decodedNumbers = hasher.decode(candidate);
|
||||
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) return true;
|
||||
const decodedHex = hasher.decodeHex(candidate);
|
||||
if (decodedHex && decodedHex.length > 0) return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a user idNumber using Hashids Hex encoding.
|
||||
* Supports South African national IDs (13 digits), passports, and alphanumeric keys.
|
||||
*/
|
||||
export function encodeIdNumberHex(idNumber: string | number | null | undefined): string {
|
||||
if (idNumber === undefined || idNumber === null) return '';
|
||||
const str = String(idNumber).trim();
|
||||
if (!str) return '';
|
||||
|
||||
if (isHashId(str)) {
|
||||
return str;
|
||||
}
|
||||
|
||||
if (/^[0-9a-fA-F]+$/.test(str)) {
|
||||
try {
|
||||
const encoded = hasher.encodeHex(str.toLowerCase());
|
||||
if (encoded) return encoded;
|
||||
} catch {
|
||||
// Fallback below
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const hex = Array.from(str)
|
||||
.map((c) => c.charCodeAt(0).toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
const encoded = hasher.encodeHex(hex);
|
||||
return encoded || str;
|
||||
} catch {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a Hashids Hex encoded string back to original idNumber.
|
||||
*/
|
||||
export function decodeIdNumberHex(hash: string | null | undefined): string {
|
||||
if (!hash || typeof hash !== 'string') return '';
|
||||
const trimmed = hash.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
try {
|
||||
const decodedHex = hasher.decodeHex(trimmed);
|
||||
if (!decodedHex) return trimmed;
|
||||
|
||||
if (/^[0-9a-fA-F]{4,}$/.test(decodedHex) && decodedHex.length % 2 === 0) {
|
||||
let isText = true;
|
||||
let text = '';
|
||||
for (let i = 0; i < decodedHex.length; i += 2) {
|
||||
const code = parseInt(decodedHex.slice(i, i + 2), 16);
|
||||
if (code < 32 || code > 126) {
|
||||
isText = false;
|
||||
break;
|
||||
}
|
||||
text += String.fromCharCode(code);
|
||||
}
|
||||
if (isText && text.length > 0) return text;
|
||||
}
|
||||
|
||||
return decodedHex;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts any numeric or string ID to a safe 64-bit integer string.
|
||||
* Preserves precision beyond 2^53 - 1 by avoiding standard float coercion.
|
||||
*/
|
||||
export function toSafeInt64String(val: string | number | bigint | null | undefined): string {
|
||||
if (val === undefined || val === null || val === '') return '';
|
||||
if (typeof val === 'bigint') return val.toString();
|
||||
if (typeof val === 'number') {
|
||||
if (isNaN(val) || !isFinite(val)) return '';
|
||||
return Math.trunc(val).toString();
|
||||
}
|
||||
const str = String(val).trim();
|
||||
if (/^-?\d+$/.test(str)) {
|
||||
return str;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically encodes a numeric ID or 64-bit integer into a standard HashID string.
|
||||
*/
|
||||
export function encodeEntityId(id: string | number | bigint | null | undefined): string {
|
||||
if (id === undefined || id === null || id === '') return '';
|
||||
|
||||
const str = typeof id === 'bigint' ? id.toString() : String(id).trim();
|
||||
if (!str) return '';
|
||||
|
||||
if (isHashId(str)) return str;
|
||||
|
||||
if (/^\d+$/.test(str)) {
|
||||
try {
|
||||
const num = Number(str);
|
||||
if (num <= Number.MAX_SAFE_INTEGER && num >= 0) {
|
||||
return hasher.encode(num);
|
||||
}
|
||||
const bigHex = BigInt(str).toString(16);
|
||||
return hasher.encodeHex(bigHex);
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically decodes a standard HashID string to its canonical ID representation as string.
|
||||
*/
|
||||
export function decodeEntityId(hashId: string | number | null | undefined): string {
|
||||
if (hashId === undefined || hashId === null || hashId === '') return '';
|
||||
const str = String(hashId).trim();
|
||||
if (!str) return '';
|
||||
|
||||
if (/^-?\d+$/.test(str)) return str;
|
||||
|
||||
try {
|
||||
const decoded = hasher.decode(str);
|
||||
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||
return decoded[0].toString();
|
||||
}
|
||||
const decodedHex = hasher.decodeHex(str);
|
||||
if (decodedHex) {
|
||||
try {
|
||||
return BigInt('0x' + decodedHex).toString();
|
||||
} catch {
|
||||
return decodedHex;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Return raw
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user