moallem all endpoints working

This commit is contained in:
2026-06-14 16:42:52 +03:30
parent 7a6e482586
commit 2212f6da41
18 changed files with 833 additions and 234 deletions

55
.local.env Normal file
View File

@@ -0,0 +1,55 @@
# Application
PORT=8085
NODE_ENV=development
# MongoDB
MONGODB_URI=mongodb://localhost:27017/inquiry-gateway
# Authentication
API_KEY=your-secure-api-key-here
JWT_SECRET=change-me-use-long-random-string
JWT_REFRESH_SECRET=change-me-refresh-secret
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
JWT_ENABLED=true
BCRYPT_SALT_ROUNDS=12
# Rate limiting
THROTTLE_TTL=60
THROTTLE_LIMIT=100
# Provider routing (per inquiry type)
PERSON_DEFAULT_PROVIDER=TEJARATNOU
PERSON_FALLBACK_ENABLED=false
PERSON_FALLBACK_PROVIDERS=
CAR_PLATE_DEFAULT_PROVIDER=HAMTA
CAR_PLATE_FALLBACK_ENABLED=false
CAR_PLATE_FALLBACK_PROVIDERS=
# Hamta provider
HAMTA_BASE_URL=https://api.hamta.example.com
HAMTA_USERNAME=
HAMTA_PASSWORD=
HAMTA_SECRET_KEY=
HAMTA_TIMEOUT=10000
HAMTA_ENABLED=true
HAMTA_MAX_RETRIES=2
# Moallem provider (identical API structure to Hamta)
MOALLEM_BASE_URL=https://api.moallem.example.com
MOALLEM_USERNAME=
MOALLEM_PASSWORD=
MOALLEM_SECRET_KEY=
MOALLEM_TIMEOUT=10000
MOALLEM_ENABLED=true
MOALLEM_MAX_RETRIES=2
# TejaratNou provider
TEJARATNOU_BASE_URL=https://accounts.tejaratnoins.ir
TEJARATNOU_INQUIRY_BASE_URL=https://gateway.tejaratnoins.ir
TEJARATNOU_CLIENT_ID=api-gateway
TEJARATNOU_CLIENT_SECRET=hkld@ork123T
TEJARATNOU_USERNAME=thirdparty-silcogroup
TEJARATNOU_PASSWORD=FDHG87sdf787l764iuo
TEJARATNOU_TIMEOUT=15000
TEJARATNOU_ENABLED=true

48
package-lock.json generated
View File

@@ -41,6 +41,7 @@
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socks-proxy-agent": "^7.0.0",
"uuid": "^11.0.3"
},
"devDependencies": {
@@ -7847,6 +7848,15 @@
"node": ">=12.0.0"
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -10927,6 +10937,44 @@
"node": ">=8"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks-proxy-agent": {
"version": "7.0.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz",
"integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==",
"license": "MIT",
"dependencies": {
"agent-base": "^6.0.2",
"debug": "^4.3.3",
"socks": "^2.6.2"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/source-map": {
"version": "0.7.4",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/source-map/-/source-map-0.7.4.tgz",

View File

@@ -48,6 +48,7 @@
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socks-proxy-agent": "^7.0.0",
"uuid": "^11.0.3"
},
"devDependencies": {

View File

@@ -0,0 +1,222 @@
import axios, {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
CreateAxiosDefaults,
InternalAxiosRequestConfig,
} from 'axios';
import { Logger } from '@nestjs/common';
import type { Agent as HttpAgent } from 'http';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { SocksProxyAgent } = require('socks-proxy-agent');
const outboundHttpLogger = new Logger('OutboundHTTP');
let cachedSocksAgents: { httpAgent: HttpAgent; httpsAgent: HttpAgent } | undefined;
let outboundHttpDebugInstalled = false;
function getOutboundProxyUrl(): string | undefined {
const value = process.env.OUTBOUND_PROXY?.trim();
return value || undefined;
}
export function isOutboundHttpDebugEnabled(): boolean {
return process.env.OUTBOUND_HTTP_DEBUG === 'true';
}
function isSocksProxy(proxyUrl: string): boolean {
return /^socks/i.test(proxyUrl);
}
function getSocksAgents(proxyUrl: string): { httpAgent: HttpAgent; httpsAgent: HttpAgent } {
if (!cachedSocksAgents) {
const agent = new SocksProxyAgent(proxyUrl);
cachedSocksAgents = { httpAgent: agent, httpsAgent: agent };
}
return cachedSocksAgents;
}
function truncate(value: string, max = 800): string {
return value.length <= max ? value : `${value.slice(0, max)}...(truncated)`;
}
function sanitizeForLog(data: unknown): string {
if (data === undefined || data === null) {
return String(data);
}
if (typeof data === 'string') {
return truncate(
data
.replace(/(<(?:\w+:)?Password[^>]*>)[^<]*(<\/(?:\w+:)?Password>)/gi, '$1***$2')
.replace(/password=[^&]*/gi, 'password=***')
.replace(/("password"\s*:\s*")[^"]*"/gi, '$1***"'),
);
}
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) {
return truncate(data.toString('utf8').replace(/password=[^&\r\n]*/gi, 'password=***'));
}
try {
const serialized = JSON.stringify(data, (key, value) => {
if (/password|secret|authorization|token|refresh/i.test(key)) {
return '***';
}
return value;
});
return truncate(serialized);
} catch {
return truncate(String(data));
}
}
function resolveRequestUrl(config: InternalAxiosRequestConfig): string {
const url = config.url ?? '';
if (/^https?:\/\//i.test(url)) {
return url;
}
const base = config.baseURL ?? '';
if (!base) {
return url;
}
return `${base.replace(/\/$/, '')}/${url.replace(/^\//, '')}`;
}
function logOutboundRequest(config: InternalAxiosRequestConfig): void {
const method = (config.method ?? 'GET').toUpperCase();
const url = resolveRequestUrl(config);
outboundHttpLogger.log(`${method} ${url}`);
if (config.params) {
outboundHttpLogger.log(` query=${sanitizeForLog(config.params)}`);
}
if (config.data !== undefined) {
outboundHttpLogger.log(` body=${sanitizeForLog(config.data)}`);
}
if (config.headers?.SOAPAction) {
outboundHttpLogger.log(` SOAPAction=${String(config.headers.SOAPAction)}`);
}
}
function logOutboundResponse(response: {
config: InternalAxiosRequestConfig;
status: number;
data: unknown;
}): void {
const start = (response.config as InternalAxiosRequestConfig & { _outboundStart?: number })
._outboundStart;
const durationMs = start ? Date.now() - start : undefined;
const method = (response.config.method ?? 'GET').toUpperCase();
const url = resolveRequestUrl(response.config);
outboundHttpLogger.log(
`${response.status} ${method} ${url}${durationMs !== undefined ? ` (${durationMs}ms)` : ''}`,
);
outboundHttpLogger.log(` response=${sanitizeForLog(response.data)}`);
}
function logOutboundError(error: AxiosError): void {
const config = error.config;
if (!config) {
outboundHttpLogger.error(`← FAILED | ${error.message}`);
return;
}
const start = (config as InternalAxiosRequestConfig & { _outboundStart?: number })._outboundStart;
const durationMs = start ? Date.now() - start : undefined;
const method = (config.method ?? 'GET').toUpperCase();
const url = resolveRequestUrl(config);
outboundHttpLogger.error(
`← FAILED ${method} ${url}${durationMs !== undefined ? ` (${durationMs}ms)` : ''} | status=${error.response?.status ?? 'N/A'} | ${error.message}`,
);
if (error.response?.data !== undefined) {
outboundHttpLogger.error(` response=${sanitizeForLog(error.response.data)}`);
}
}
export function installOutboundHttpDebugInterceptors(): void {
if (!isOutboundHttpDebugEnabled() || outboundHttpDebugInstalled) {
return;
}
outboundHttpDebugInstalled = true;
outboundHttpLogger.log('Outbound HTTP debug logging enabled (OUTBOUND_HTTP_DEBUG=true)');
axios.interceptors.request.use((config) => {
(config as InternalAxiosRequestConfig & { _outboundStart?: number })._outboundStart =
Date.now();
logOutboundRequest(config);
return config;
});
axios.interceptors.response.use(
(response) => {
logOutboundResponse(response);
return response;
},
(error: AxiosError) => {
logOutboundError(error);
return Promise.reject(error);
},
);
}
installOutboundHttpDebugInterceptors();
/**
* Axios defaults for outbound provider calls.
* Set OUTBOUND_PROXY to route traffic through an SSH tunnel (e.g. Termius SOCKS on 6565).
*/
export function getOutboundAxiosDefaults(): AxiosRequestConfig {
const proxyUrl = getOutboundProxyUrl();
if (!proxyUrl) {
return {};
}
if (isSocksProxy(proxyUrl)) {
const agents = getSocksAgents(proxyUrl);
return {
httpAgent: agents.httpAgent,
httpsAgent: agents.httpsAgent,
proxy: false,
};
}
const parsed = new URL(proxyUrl);
const defaultPort = parsed.protocol === 'https:' ? 443 : 80;
return {
proxy: {
protocol: parsed.protocol.replace(':', ''),
host: parsed.hostname,
port: Number(parsed.port || defaultPort),
},
};
}
export function mergeOutboundAxiosConfig(config: AxiosRequestConfig = {}): AxiosRequestConfig {
const outbound = getOutboundAxiosDefaults();
return {
...outbound,
...config,
httpAgent: config.httpAgent ?? outbound.httpAgent,
httpsAgent: config.httpsAgent ?? outbound.httpsAgent,
};
}
export function createOutboundAxiosInstance(config: CreateAxiosDefaults = {}): AxiosInstance {
const outbound = getOutboundAxiosDefaults();
return axios.create({
...config,
httpAgent: config.httpAgent ?? outbound.httpAgent,
httpsAgent: config.httpsAgent ?? outbound.httpsAgent,
proxy: config.proxy ?? outbound.proxy,
});
}

View File

@@ -1,4 +1,5 @@
import { Logger } from '@nestjs/common';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
export interface RequestLogContext {
requestId: string;
@@ -29,10 +30,12 @@ export class RequestLogger {
}
logFailure(ctx: RequestLogContext, message: string, error?: unknown): void {
this.logger.error(
this.format({ ...ctx, success: false }, message),
error instanceof Error ? error.stack : undefined,
);
const errorDetail = formatErrorDetail(error);
const line = errorDetail
? `${this.format({ ...ctx, success: false }, message)} | error=${errorDetail}`
: this.format({ ...ctx, success: false }, message);
this.logger.error(line, error instanceof Error ? error.stack : undefined);
}
private format(ctx: RequestLogContext, message: string): string {
@@ -49,3 +52,21 @@ export class RequestLogger {
return parts.join(' | ');
}
}
function formatErrorDetail(error: unknown): string | undefined {
if (!(error instanceof Error)) {
return error !== undefined ? String(error) : undefined;
}
const normalized = (error as Error & { normalizedError?: NormalizedErrorDto }).normalizedError;
if (normalized?.providerMessage) {
const code = normalized.providerCode ? ` (${normalized.providerCode})` : '';
return `${normalized.providerMessage}${code}`;
}
if (normalized?.message && normalized.message !== error.message) {
return normalized.message;
}
return error.message;
}

View File

@@ -0,0 +1,70 @@
export interface SayahApiResponse {
ReturnValue?: boolean;
HasError?: boolean;
IsSucceed?: boolean;
isSucceed?: boolean;
Errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null;
errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null;
Result?: {
ErrorMessage?: string | null;
[key: string]: unknown;
};
}
export function formatSayahErrors(
errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null,
): string {
if (!errors) {
return 'Request failed';
}
if (Array.isArray(errors)) {
if (errors.length === 0) {
return 'Request failed';
}
return errors.map((error) => `${error.Code}: ${error.Message}`).join(', ');
}
const entries = Object.entries(errors);
if (entries.length === 0) {
return 'Request failed';
}
return entries.map(([code, field]) => `${field} (code: ${code})`).join(', ');
}
export function getSayahProviderError(
body: SayahApiResponse,
): { message: string; code: string } | null {
if ('ReturnValue' in body || 'HasError' in body) {
if (body.HasError) {
return {
message: formatSayahErrors(body.Errors),
code: 'SAYAH_ERROR',
};
}
if (body.ReturnValue === false) {
return {
message: formatSayahErrors(body.Errors),
code: 'SHEBA_MISMATCH',
};
}
return null;
}
const isSucceed = body.IsSucceed ?? body.isSucceed;
if (isSucceed === false) {
const errors = body.Errors ?? body.errors;
return {
message:
(Array.isArray(errors) || (errors && Object.keys(errors).length > 0)
? formatSayahErrors(errors)
: body.Result?.ErrorMessage) ?? 'Request failed',
code: 'API_ERROR',
};
}
return null;
}

View File

@@ -0,0 +1,92 @@
function decodeXml(value: string): string {
return value
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
}
function normalizeSoapTextValue(value: string): string {
const nestedStrings = [
...value.matchAll(/<(?:[\w]+:)?string[^>]*>([\s\S]*?)<\/(?:[\w]+:)?string>/gi),
]
.map((match) => decodeXml(match[1].trim()))
.filter(Boolean);
if (nestedStrings.length > 0) {
return nestedStrings.join(', ');
}
return decodeXml(value.trim());
}
export function parseCiiEstelamResult(soapXml: string): Record<string, string> {
const civilBlockMatch =
soapXml.match(
/<(?:[\w]+:)?CiiEstelamResult[^>]*>([\s\S]*?)<\/(?:[\w]+:)?CiiEstelamResult>/i,
) ??
soapXml.match(
/<(?:[\w]+:)?SubmitInqDteStsWithPstCodResult[^>]*>([\s\S]*?)<\/(?:[\w]+:)?SubmitInqDteStsWithPstCodResult>/i,
);
if (!civilBlockMatch) {
return {};
}
const fields: Record<string, string> = {};
const tagRegex = /<(?:[\w]+:)?(\w+)(?:[^>]*)>([\s\S]*?)<\/(?:[\w]+:)?\1>/gi;
let match: RegExpExecArray | null;
while ((match = tagRegex.exec(civilBlockMatch[1])) !== null) {
fields[match[1]] = normalizeSoapTextValue(match[2]);
}
const errorNamsMatch = soapXml.match(
/<(?:[\w]+:)?ErrorNams(?![^>]*i:nil="true")[^>]*>([\s\S]*?)<\/(?:[\w]+:)?ErrorNams>/i,
);
if (errorNamsMatch) {
const errorNams = normalizeSoapTextValue(errorNamsMatch[1]);
if (errorNams) {
fields.ErrorNams = errorNams;
}
}
return fields;
}
export function getCivilRegistrationProviderError(
fields: Record<string, string>,
): { message: string; code: string } | null {
const message = fields.Message?.trim();
const exceptionMessage = fields.ExceptionMessage?.trim();
const errorNams = fields.ErrorNams?.trim();
const nin = fields.Nin?.trim();
const hasIdentity = Boolean(fields.Name?.trim() || fields.Family?.trim());
if (exceptionMessage) {
return { message: exceptionMessage, code: 'PROVIDER_ERROR' };
}
if (errorNams) {
return { message: errorNams, code: 'PROVIDER_ERROR' };
}
if (message && message.includes('err.')) {
return { message, code: 'RECORD_NOT_FOUND' };
}
if (!nin || nin === '0' || !hasIdentity) {
return {
message: message || 'Record not found',
code: 'RECORD_NOT_FOUND',
};
}
return null;
}
export function buildCivilRegistrationFullName(fields: Record<string, string>): string | undefined {
const fullName = [fields.Name, fields.Family].filter(Boolean).join(' ').trim();
return fullName || undefined;
}

View File

@@ -7,6 +7,60 @@ export class PersonInquiryDataDto {
@ApiProperty({ example: '1370-05-15' })
birthDate!: string;
@ApiPropertyOptional({ example: 'علی محمدی' })
@ApiPropertyOptional({ example: 'سهیل حاجی زاده' })
fullName?: string;
@ApiPropertyOptional({ example: '4311402422' })
Nin?: string;
@ApiPropertyOptional({ example: 'سهيل' })
Name?: string;
@ApiPropertyOptional({ example: 'حاجي زاده' })
Family?: string;
@ApiPropertyOptional({ example: 'كوروش' })
FatherName?: string;
@ApiPropertyOptional({ example: 'د41' })
Shenasnameseri?: string;
@ApiPropertyOptional({ example: '709633' })
Shenasnameserial?: string;
@ApiPropertyOptional({ example: '0' })
ShenasnameNo?: string;
@ApiPropertyOptional({ example: '13781124' })
BirthDate?: string;
@ApiPropertyOptional({ example: '1' })
Gender?: string;
@ApiPropertyOptional({ example: '0' })
OfficeCode?: string;
@ApiPropertyOptional({ example: '0' })
BookNo?: string;
@ApiPropertyOptional({ example: '0' })
BookRow?: string;
@ApiPropertyOptional({ example: '0' })
DeathStatus?: string;
@ApiPropertyOptional()
Message?: string;
@ApiPropertyOptional()
DeathDate?: string;
@ApiPropertyOptional()
ExceptionMessage?: string;
@ApiPropertyOptional({ example: '1349689554' })
Zipcode?: string;
@ApiPropertyOptional()
ZipcodeDesc?: string;
}

View File

@@ -23,76 +23,19 @@ export class InquiryService {
private readonly inquiryLogService: InquiryLogService,
) {}
/**
* Person inquiry — unified business flow with provider fallback and audit logging.
*/
async inquirePerson(
dto: PersonInquiryRequestDto,
requestId: string,
): Promise<BaseInquiryResponseDto<PersonInquiryDataDto>> {
const trackingCode = generateTrackingCode();
const payload: PersonInquiryPayload = {
return this.inquire(
InquiryType.PERSON,
{
nationalCode: dto.nationalCode,
birthDate: dto.birthDate,
dateHasPostfix: (dto as PersonInquiryRequestDto & { dateHasPostfix?: number }).dateHasPostfix,
};
const start = Date.now();
try {
const result = await this.executeInquiry<PersonInquiryPayload, PersonInquiryResult>(
InquiryType.PERSON,
payload,
dateHasPostfix: dto.dateHasPostfix,
},
requestId,
trackingCode,
);
const response = this.buildSuccessResponse(
result.data,
result.provider,
trackingCode,
result.duration,
'Person inquiry completed successfully',
);
await this.persistLog({
inquiryType: InquiryType.PERSON,
provider: result.provider,
requestPayload: payload as unknown as Record<string, unknown>,
responsePayload: response.data as unknown as Record<string, unknown>,
status: InquiryStatus.SUCCESS,
duration: result.duration,
requestId,
trackingCode,
});
return response;
} catch (error) {
const duration = Date.now() - start;
const normalized = this.extractError(error);
const response: BaseInquiryResponseDto<PersonInquiryDataDto> = {
success: false,
provider: 'GATEWAY',
trackingCode,
message: normalized.message,
error: normalized,
duration,
};
await this.persistLog({
inquiryType: InquiryType.PERSON,
provider: ProviderName.HAMTA,
requestPayload: payload as unknown as Record<string, unknown>,
status: InquiryStatus.FAILURE,
duration,
requestId,
trackingCode,
error: normalized as unknown as Record<string, unknown>,
}).catch(() => undefined);
return response;
}
) as unknown as Promise<BaseInquiryResponseDto<PersonInquiryDataDto>>;
}
async inquire(
@@ -111,7 +54,7 @@ export class InquiryService {
trackingCode,
);
const response = this.buildGenericSuccessResponse(
const response = this.buildSuccessResponse(
result.data,
result.provider,
trackingCode,
@@ -172,27 +115,6 @@ export class InquiryService {
}
private buildSuccessResponse(
result: PersonInquiryResult,
provider: string,
trackingCode: string,
duration: number,
message: string,
): BaseInquiryResponseDto<PersonInquiryDataDto> {
return {
success: true,
provider,
trackingCode,
message,
duration,
data: {
nationalCode: result.nationalCode,
birthDate: result.birthDate,
fullName: result.fullName,
},
};
}
private buildGenericSuccessResponse(
result: unknown,
provider: string,
trackingCode: string,
@@ -210,6 +132,20 @@ export class InquiryService {
}
private toResponseData(result: unknown): Record<string, unknown> {
if (this.isPersonInquiryResult(result)) {
const raw =
result.raw && typeof result.raw === 'object' && !Array.isArray(result.raw)
? (result.raw as Record<string, unknown>)
: {};
return {
nationalCode: result.nationalCode,
birthDate: result.birthDate,
...(result.fullName ? { fullName: result.fullName } : {}),
...raw,
};
}
if (result && typeof result === 'object' && 'raw' in result) {
const raw = (result as { raw: unknown }).raw;
return raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : { raw };
@@ -220,6 +156,15 @@ export class InquiryService {
: { raw: result };
}
private isPersonInquiryResult(result: unknown): result is PersonInquiryResult {
return (
typeof result === 'object' &&
result !== null &&
'nationalCode' in result &&
'birthDate' in result
);
}
private getInquiryLabel(inquiryType: InquiryType): string {
return inquiryType.replace(/_INQUIRY$/, '').toLowerCase().replace(/_/g, ' ');
}

View File

@@ -1,4 +1,5 @@
import { Logger } from '@nestjs/common';
import { AxiosError } from 'axios';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
@@ -131,19 +132,41 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
protected formatProviderError(
providerMessage?: string,
providerCode?: string,
fallbackMessage = 'Provider returned an error',
fallbackMessage = 'Provider request failed',
): Error {
const message = providerMessage?.trim() || fallbackMessage;
const code = providerCode?.trim() || 'PROVIDER_ERROR';
const normalized = this.normalizeError({
code: 'PROVIDER_ERROR',
message: fallbackMessage,
providerMessage,
providerCode,
code,
message,
providerMessage: message,
providerCode: code,
});
const err = new Error(normalized.message);
const err = new Error(message);
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
return err;
}
protected formatAxiosError(error: AxiosError): Error {
const data = error.response?.data;
if (typeof data === 'string') {
const faultMatch = data.match(
/<(?:\w+:)?faultstring[^>]*>([\s\S]*?)<\/(?:\w+:)?faultstring>/i,
);
if (faultMatch?.[1]) {
return this.formatProviderError(
faultMatch[1].trim().replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>'),
String(error.response?.status ?? 'NETWORK_ERROR'),
);
}
}
return this.formatProviderError(
error.message,
String(error.response?.status ?? 'NETWORK_ERROR'),
);
}
protected abstract callProvider(
inquiryType: InquiryType,
payload: TRequest,

View File

@@ -1,6 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError, AxiosInstance } from 'axios';
import { AxiosError, AxiosInstance } from 'axios';
import {
createOutboundAxiosInstance,
isOutboundHttpDebugEnabled,
} from '../../common/helpers/http-client.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { AmitisAuthServiceConfig } from '../../config/configuration';
@@ -22,6 +26,8 @@ interface CentInsurTokenResponse {
ExpiresIn?: number | string;
expiresIn?: number | string;
expires_in?: number | string;
IsSucceed?: boolean;
LoginStatus?: number;
data?: CentInsurTokenResponse;
}
@@ -49,7 +55,7 @@ export class AmitisProvider {
private readonly generalTokenService: GeneralTokenService,
) {
this.config = configService.get<AmitisAuthServiceConfig>('amitis')!;
this.httpClient = axios.create({
this.httpClient = createOutboundAxiosInstance({
baseURL: this.config.baseUrl,
timeout: this.config.timeout,
});
@@ -102,25 +108,36 @@ export class AmitisProvider {
username: string,
password: string,
): Promise<string> {
// Use FormData for multipart/form-data format
const FormData = require('form-data');
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
const loginBody = new URLSearchParams({
username: username.toLowerCase(),
password,
});
try {
const loginUrl = `${this.config.baseUrl}${this.config.loginPath}`;
this.logger.log(
`AMITIS login → POST ${loginUrl} | provider=${providerName} | inquiry=${inquiryType} | username=${username.toLowerCase()}`,
);
const response = await this.httpClient.post<CentInsurTokenResponse>(
this.config.loginPath,
formData,
loginBody.toString(),
{
headers: {
...formData.getHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
if (isOutboundHttpDebugEnabled()) {
this.logger.log(
`AMITIS login ← ${response.status} | provider=${providerName} | inquiry=${inquiryType} | bodyKeys=${Object.keys(response.data ?? {}).join(',') || 'none'}`,
);
}
const token = this.extractToken(response.data);
await this.saveToken(token, providerName, inquiryType, username, 'login');
await this.saveToken(token, providerName, inquiryType, username, password, 'login');
return token.accessToken;
} catch (error) {
throw this.toAuthError(`AMITIS login failed for ${providerName}/${inquiryType}`, error);
@@ -144,6 +161,7 @@ export class AmitisProvider {
providerName,
inquiryType,
latestToken.username,
latestToken.clientSecret,
'refresh',
);
return token.accessToken;
@@ -160,15 +178,16 @@ export class AmitisProvider {
providerName: ProviderName,
inquiryType: InquiryType,
username: string,
password: string,
scope: string,
): Promise<void> {
await this.generalTokenService.create({
serviceProvider: this.getTokenKey(providerName, inquiryType),
tokenType: token.tokenType,
url: this.config.baseUrl,
clientId: '',
clientSecret: '',
username,
clientId: username.toLowerCase(),
clientSecret: password,
username: username.toLowerCase(),
scope,
accessToken: token.accessToken,
refreshToken: token.refreshToken,
@@ -182,11 +201,21 @@ export class AmitisProvider {
fallbackRefreshToken?: string,
): AmitisAuthToken {
const body = responseBody.data ?? responseBody;
if (body.IsSucceed === false) {
throw new Error(
`AMITIS login rejected (LoginStatus=${body.LoginStatus ?? 'unknown'})`,
);
}
const accessToken =
body.Token ?? body.token ?? body.AccessToken ?? body.accessToken ?? body.access_token;
if (!accessToken) {
throw new Error('AMITIS auth response did not include an access token');
if (!accessToken || !String(accessToken).trim()) {
const bodyPreview = JSON.stringify(responseBody).slice(0, 500);
throw new Error(
`AMITIS auth response did not include an access token | body=${bodyPreview}`,
);
}
const expiresIn = Number(body.ExpiresIn ?? body.expiresIn ?? body.expires_in ?? 20 * 60);
@@ -229,7 +258,13 @@ export class AmitisProvider {
private toAuthError(message: string, error: unknown): Error {
if (error instanceof AxiosError) {
const status = error.response?.status ?? 'NETWORK_ERROR';
return new Error(`${message}: ${status} ${error.message}`);
const body =
error.response?.data !== undefined
? JSON.stringify(error.response.data).slice(0, 500)
: undefined;
return new Error(
body ? `${message}: ${status} ${error.message} | body=${body}` : `${message}: ${status} ${error.message}`,
);
}
if (error instanceof Error) {

View File

@@ -1,6 +1,11 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError } from 'axios';
import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper';
import {
getSayahProviderError,
SayahApiResponse,
} from '../../common/helpers/sayah-response.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { ProviderEnvConfig } from '../../config/configuration';
@@ -107,11 +112,11 @@ export class HamtaProvider extends LegacyApiProvider {
try {
const response = await axios.get<Record<string, unknown>>(
`${inquiryConfig.url}/AddressByPostcode`,
{
mergeOutboundAxiosConfig({
params: { PostalCode: postalCode },
headers: { Authorization: `Bearer ${token}` },
timeout: this.hamtaConfig.timeout,
},
}),
);
return { raw: response.data };
@@ -146,14 +151,14 @@ export class HamtaProvider extends LegacyApiProvider {
inquiryConfig.username,
inquiryConfig.password,
),
{
mergeOutboundAxiosConfig({
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '"http://tempuri.org/ISabtInq/SubmitInqDteStsWithPstCod"',
},
timeout: this.hamtaConfig.timeout,
responseType: 'text',
},
}),
);
const result = this.extractSoapValue(response.data, 'SubmitInqDteStsWithPstCodResult');
@@ -195,14 +200,14 @@ export class HamtaProvider extends LegacyApiProvider {
inquiryConfig.username,
inquiryConfig.password,
),
{
mergeOutboundAxiosConfig({
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '"http://tempuri.org/IShahkarInq/ShahkarInquery"',
},
timeout: this.hamtaConfig.timeout,
responseType: 'text',
},
}),
);
const result = this.extractSoapValue(response.data, 'ShahkarInqueryResult');
@@ -241,11 +246,7 @@ export class HamtaProvider extends LegacyApiProvider {
);
try {
const response = await axios.post<{
IsSucceed?: boolean;
Errors?: Array<{ Code?: string; Message?: string }>;
Result?: unknown;
}>(
const response = await axios.post<SayahApiResponse>(
inquiryConfig.url,
{
AccountOwnerType: accountOwnerType,
@@ -253,20 +254,18 @@ export class HamtaProvider extends LegacyApiProvider {
LegalId: legalId,
ShebaId: shebaId,
},
{
mergeOutboundAxiosConfig({
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
timeout: this.hamtaConfig.timeout,
},
}),
);
if (!response.data.IsSucceed && response.data.Errors) {
throw this.formatProviderError(
this.formatErrors(response.data.Errors),
'SAYAH_HAS_ERROR',
);
const providerError = getSayahProviderError(response.data);
if (providerError) {
throw this.formatProviderError(providerError.message, providerError.code);
}
return { raw: response.data };

View File

@@ -1,6 +1,16 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError } from 'axios';
import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper';
import {
buildCivilRegistrationFullName,
getCivilRegistrationProviderError,
parseCiiEstelamResult,
} from '../../common/helpers/soap-civil-registration.helper';
import {
getSayahProviderError,
SayahApiResponse,
} from '../../common/helpers/sayah-response.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { ProviderEnvConfig } from '../../config/configuration';
@@ -10,6 +20,7 @@ import {
LegacyApiProvider,
LegacyInquiryPayload,
LegacyInquiryResult,
PersonInquiryResult,
} from '../shared/legacy-api.provider.abstract';
interface CivilRegistrationPayload extends LegacyInquiryPayload {
@@ -107,11 +118,11 @@ export class MoallemProvider extends LegacyApiProvider {
try {
const response = await axios.get<Record<string, unknown>>(
`${inquiryConfig.url}/AddressByPostcode`,
{
mergeOutboundAxiosConfig({
params: { PostalCode: postalCode },
headers: { Authorization: `Bearer ${token}` },
timeout: this.moallemConfig.timeout,
},
}),
);
return { raw: response.data };
@@ -128,7 +139,7 @@ export class MoallemProvider extends LegacyApiProvider {
private async inquireCivilRegistration(
payload: CivilRegistrationPayload,
): Promise<{ raw: unknown }> {
): Promise<PersonInquiryResult> {
const nationalCode = this.getRequiredString(
payload.nationalCode ?? payload.NIN,
'nationalCode',
@@ -141,37 +152,39 @@ export class MoallemProvider extends LegacyApiProvider {
const response = await axios.post<string>(
inquiryConfig.url,
this.buildCivilRegistrationEnvelope(
payload,
nationalCode,
birthDate,
inquiryConfig.username,
inquiryConfig.password,
),
{
mergeOutboundAxiosConfig({
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '"http://tempuri.org/ISabtV3/SabtInquery"',
SOAPAction: '"http://tempuri.org/ISabtInq/SubmitInqDteStsWithPstCod"',
},
timeout: this.moallemConfig.timeout,
responseType: 'text',
},
}),
);
const result = this.extractSoapValue(response.data, 'SabtInqueryResult');
const civilRegistration = parseCiiEstelamResult(response.data);
const providerError = getCivilRegistrationProviderError(civilRegistration);
if (providerError) {
throw this.formatProviderError(providerError.message, providerError.code);
}
const fullName = buildCivilRegistrationFullName(civilRegistration);
return {
raw: {
nationalCode,
birthDate,
result,
soap: response.data,
},
fullName: fullName || undefined,
raw: civilRegistration,
};
} catch (error) {
if (error instanceof AxiosError) {
throw this.formatProviderError(
error.message,
String(error.response?.status ?? 'NETWORK_ERROR'),
);
throw this.formatAxiosError(error);
}
throw error;
}
@@ -195,14 +208,14 @@ export class MoallemProvider extends LegacyApiProvider {
inquiryConfig.username,
inquiryConfig.password,
),
{
mergeOutboundAxiosConfig({
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '"http://tempuri.org/IShahkarInq/ShahkarInquery"',
},
timeout: this.moallemConfig.timeout,
responseType: 'text',
},
}),
);
const result = this.extractSoapValue(response.data, 'ShahkarInqueryResult');
@@ -241,11 +254,7 @@ export class MoallemProvider extends LegacyApiProvider {
);
try {
const response = await axios.post<{
IsSucceed?: boolean;
Errors?: Array<{ Code?: string; Message?: string }>;
Result?: unknown;
}>(
const response = await axios.post<SayahApiResponse>(
inquiryConfig.url,
{
AccountOwnerType: accountOwnerType,
@@ -253,20 +262,18 @@ export class MoallemProvider extends LegacyApiProvider {
LegalId: legalId,
ShebaId: shebaId,
},
{
mergeOutboundAxiosConfig({
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
timeout: this.moallemConfig.timeout,
},
}),
);
if (!response.data.IsSucceed && response.data.Errors) {
throw this.formatProviderError(
this.formatErrors(response.data.Errors),
'SAYAH_HAS_ERROR',
);
const providerError = getSayahProviderError(response.data);
if (providerError) {
throw this.formatProviderError(providerError.message, providerError.code);
}
return { raw: response.data };
@@ -282,22 +289,38 @@ export class MoallemProvider extends LegacyApiProvider {
}
private buildCivilRegistrationEnvelope(
payload: CivilRegistrationPayload,
nationalCode: string,
birthDate: string,
username: string,
password: string,
): string {
const birthDateCompact = birthDate.replace(/-/g, '');
const dateHasPostfix = payload.dateHasPostfix ?? 0;
return `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<SabtInquery xmlns="http://tempuri.org/">
<NIN>${this.escapeXml(nationalCode)}</NIN>
<BirthDate>${this.escapeXml(birthDate)}</BirthDate>
<SubmitInqDteStsWithPstCod xmlns="http://tempuri.org/">
<req>
<Nin>${this.escapeXml(nationalCode)}</Nin>
<Shenasnameserial>0</Shenasnameserial>
<ShenasnameNo>0</ShenasnameNo>
<BirthDate>${this.escapeXml(birthDateCompact)}</BirthDate>
<DateHasPostfix>${dateHasPostfix}</DateHasPostfix>
<Gender>0</Gender>
<OfficeCode>0</OfficeCode>
<BookNo>0</BookNo>
<NameHasPrefix>0</NameHasPrefix>
<NameHasPostFix>0</NameHasPostFix>
<FamilyHasPrefix>0</FamilyHasPrefix>
<FamilyHasPostFix>0</FamilyHasPostFix>
</req>
<Username>${this.escapeXml(username)}</Username>
<Password>${this.escapeXml(password)}</Password>
</SabtInquery>
</SubmitInqDteStsWithPstCod>
</soap:Body>
</soap:Envelope>`;
}
@@ -341,10 +364,10 @@ export class MoallemProvider extends LegacyApiProvider {
private escapeXml(value: string): string {
return value
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

View File

@@ -1,6 +1,11 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError } from 'axios';
import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper';
import {
getSayahProviderError,
SayahApiResponse,
} from '../../common/helpers/sayah-response.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
@@ -172,7 +177,9 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
}
try {
const response = await axios.get<ParsianShahkarResponse>(inquiryConfig.url, {
const response = await axios.get<ParsianShahkarResponse>(
inquiryConfig.url,
mergeOutboundAxiosConfig({
params: {
nationalCode,
mobileNumber,
@@ -181,7 +188,8 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
'X-PACKAGE-API-KEY': inquiryConfig.apiKey,
},
timeout: this.config.timeout,
});
}),
);
const body = response.data;
return {
@@ -230,14 +238,14 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
inquiryConfig.username,
inquiryConfig.password,
),
{
mergeOutboundAxiosConfig({
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '"http://tempuri.org/ISabtInq/SubmitInqDteStsWithPstCod"',
},
timeout: this.config.timeout,
responseType: 'text',
},
}),
);
return {
@@ -348,14 +356,14 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
inquiryConfig.password,
passwordFieldName,
),
{
mergeOutboundAxiosConfig({
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: `"http://tempuri.org/ICarAllPlcysV4/${methodName}"`,
},
timeout: this.config.timeout,
responseType: 'text',
},
}),
);
return {
@@ -396,7 +404,7 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
);
try {
const response = await axios.post<ParsianSayahResponse>(
const response = await axios.post<SayahApiResponse>(
inquiryConfig.url,
{
AccountOwnerType: accountOwnerType,
@@ -404,23 +412,18 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
LegalId: legalId,
ShebaId: shebaId,
},
{
mergeOutboundAxiosConfig({
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
timeout: this.config.timeout,
},
}),
);
if (
(response.data.IsSucceed === false || response.data.isSucceed === false) &&
this.hasErrors(response.data.Errors ?? response.data.errors)
) {
throw this.formatProviderError(
this.formatErrors(response.data.Errors ?? response.data.errors),
'SAYAH_HAS_ERROR',
);
const providerError = getSayahProviderError(response.data);
if (providerError) {
throw this.formatProviderError(providerError.message, providerError.code);
}
return { raw: response.data };

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError, AxiosInstance } from 'axios';
import { createOutboundAxiosInstance, mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { jalaliDateToGregorianDate } from '../../common/helpers/jalali-date.helper';
@@ -51,7 +52,7 @@ export class TejaratNouProvider implements InquiryProvider<PersonInquiryPayload,
private readonly cachedInquiryResultService: CachedInquiryResultService,
) {
this.config = this.configService.get<TejaratNouConfig>('tejaratnou')!;
this.httpClient = axios.create({
this.httpClient = createOutboundAxiosInstance({
baseURL: this.config.inquiryBaseUrl,
timeout: this.config.timeout,
headers: {
@@ -227,13 +228,13 @@ export class TejaratNouProvider implements InquiryProvider<PersonInquiryPayload,
password: this.config.password,
scope: 'api-gateway access-management',
}),
{
mergeOutboundAxiosConfig({
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Cookie: 'cookiesession1=678A8C5D2222753B93723D81AA53FF8C',
},
timeout: this.config.timeout,
},
}),
);
const jsonData = response.data;

View File

@@ -1,4 +1,6 @@
import axios, { AxiosError, AxiosInstance } from 'axios';
import { AxiosError, AxiosInstance } from 'axios';
import { createOutboundAxiosInstance } from '../../common/helpers/http-client.helper';
import { getSayahProviderError } from '../../common/helpers/sayah-response.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
@@ -51,7 +53,7 @@ export abstract class LegacyApiProvider extends BaseProvider<
) {
super(config);
this.providerConfig = config;
this.httpClient = axios.create({
this.httpClient = createOutboundAxiosInstance({
timeout: config.timeout,
headers: {
'Content-Type': 'application/json',
@@ -131,15 +133,9 @@ export abstract class LegacyApiProvider extends BaseProvider<
// Handle Sayah/Sheba API format (ReturnValue/HasError)
if ('ReturnValue' in body || 'HasError' in body) {
if (body.HasError) {
// Format error message from Errors object
const errorMessages = body.Errors
? Object.entries(body.Errors).map(([code, field]) => `${field} (code: ${code})`).join(', ')
: 'Request failed';
throw this.formatProviderError(
errorMessages,
'SAYAH_ERROR',
);
const providerError = getSayahProviderError(body);
if (providerError) {
throw this.formatProviderError(providerError.message, providerError.code);
}
return { raw: body };
}
@@ -168,14 +164,11 @@ export abstract class LegacyApiProvider extends BaseProvider<
const data = error.response?.data as LegacyProviderApiResponse | undefined;
// Check for Sayah/Sheba format error
if (data && ('ReturnValue' in data || 'HasError' in data) && data.HasError) {
const errorMessages = data.Errors
? Object.entries(data.Errors).map(([code, field]) => `${field} (code: ${code})`).join(', ')
: error.message;
throw this.formatProviderError(
errorMessages,
String(error.response?.status ?? 'SAYAH_ERROR'),
);
if (data && ('ReturnValue' in data || 'HasError' in data)) {
const providerError = getSayahProviderError(data);
if (providerError) {
throw this.formatProviderError(providerError.message, providerError.code);
}
}
// Check for CentInsur format error

View File

@@ -55,27 +55,34 @@ export class ProviderOrchestratorService {
} catch (error) {
const normalized = this.extractError(error);
errors.push(normalized);
const errorSummary = normalized.providerMessage ?? normalized.message;
const hasNextProvider = providers.indexOf(provider) < providers.length - 1;
this.logger.warn(
hasNextProvider
? `Provider ${provider.name} failed for ${inquiryType}, trying next fallback`
: `Provider ${provider.name} failed for ${inquiryType}, no fallback available`,
? `Provider ${provider.name} failed for ${inquiryType}, trying next fallback | ${errorSummary}`
: `Provider ${provider.name} failed for ${inquiryType}, no fallback available | ${errorSummary}`,
);
this.logger.debug(
`Attempt duration: ${Date.now() - attemptStart}ms | error: ${normalized.message}`,
this.logger.warn(
`Attempt duration: ${Date.now() - attemptStart}ms | providerCode=${normalized.providerCode ?? normalized.code} | providerMessage=${normalized.providerMessage ?? normalized.message}`,
);
}
}
throw new InquiryException('All providers failed', {
code: providers.length === 1 ? 'PROVIDER_SERVICE_NOT_AVAILABLE' : 'ALL_PROVIDERS_FAILED',
message:
providers.length === 1
? `${providers[0].name} service is not available`
: errors.map((e) => e.message).join('; '),
providerMessage: errors[0]?.providerMessage,
providerCode: errors[0]?.providerCode,
});
const lastError = errors[errors.length - 1]!;
if (providers.length === 1) {
throw new InquiryException(lastError.message, lastError);
}
throw new InquiryException(
errors.map((error) => error.message).join('; '),
{
code: 'ALL_PROVIDERS_FAILED',
message: errors.map((error) => error.message).join('; '),
providerMessage: lastError.providerMessage ?? lastError.message,
providerCode: lastError.providerCode ?? lastError.code,
},
);
}
private extractError(error: unknown): NormalizedErrorDto {

View File

@@ -106,8 +106,15 @@ console.debug = (...args: unknown[]) => {
originalConsole.debug(...args);
};
export function shutdownTelemetry(): Promise<void> {
return sdk.shutdown();
export async function shutdownTelemetry(): Promise<void> {
try {
await sdk.shutdown();
} catch (error) {
originalConsole.warn(
'OpenTelemetry shutdown failed:',
error instanceof Error ? error.message : String(error),
);
}
}
export function recordHttpRequestStart(attributes: Record<string, string>): void {