forked from Shared/esg
Merge pull request 'moallem all endpoints working' (#4) from s.hajizadeh/esg:main into main
Reviewed-on: Shared/esg#4
This commit is contained in:
55
.local.env
Normal file
55
.local.env
Normal 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
48
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
222
src/common/helpers/http-client.helper.ts
Normal file
222
src/common/helpers/http-client.helper.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
70
src/common/helpers/sayah-response.helper.ts
Normal file
70
src/common/helpers/sayah-response.helper.ts
Normal 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;
|
||||
}
|
||||
92
src/common/helpers/soap-civil-registration.helper.ts
Normal file
92
src/common/helpers/soap-civil-registration.helper.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
function decodeXml(value: string): string {
|
||||
return value
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, ' ');
|
||||
}
|
||||
|
||||
@@ -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(/&/g, '&').replace(/</g, '<').replace(/>/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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user