forked from Shared/esg
initial commit
This commit is contained in:
152
src/providers/base/base-provider.abstract.ts
Normal file
152
src/providers/base/base-provider.abstract.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
|
||||
import {
|
||||
InquiryProvider,
|
||||
ProviderExecutionContext,
|
||||
} from '../../common/interfaces/inquiry-provider.interface';
|
||||
import { withRetry } from '../../common/helpers/retry.helper';
|
||||
import { withTimeout } from '../../common/helpers/timeout.helper';
|
||||
import { RequestLogger } from '../../common/helpers/request-logger.helper';
|
||||
import { ProviderConfigSlice } from '../interfaces/provider-config.interface';
|
||||
|
||||
/**
|
||||
* Abstract base for all provider adapters.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Retry + timeout orchestration
|
||||
* - Standardized error normalization
|
||||
* - Response formatting hooks
|
||||
* - Structured logging
|
||||
*
|
||||
* Subclasses implement provider-specific HTTP/business logic only.
|
||||
*/
|
||||
export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
|
||||
implements InquiryProvider<TRequest, TResponse>
|
||||
{
|
||||
abstract readonly name: ProviderName;
|
||||
abstract readonly supportedInquiryTypes: InquiryType[];
|
||||
|
||||
protected readonly logger: RequestLogger;
|
||||
protected readonly nestLogger: Logger;
|
||||
|
||||
constructor(protected readonly config: ProviderConfigSlice) {
|
||||
this.logger = new RequestLogger(this.constructor.name);
|
||||
this.nestLogger = new Logger(this.constructor.name);
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled;
|
||||
}
|
||||
|
||||
async execute(
|
||||
inquiryType: InquiryType,
|
||||
payload: TRequest,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<TResponse> {
|
||||
if (!this.supportedInquiryTypes.includes(inquiryType)) {
|
||||
throw this.normalizeError({
|
||||
code: 'UNSUPPORTED_INQUIRY',
|
||||
message: `Provider ${this.name} does not support ${inquiryType}`,
|
||||
});
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
this.logger.logStart(
|
||||
{
|
||||
requestId: context.requestId,
|
||||
trackingCode: context.trackingCode,
|
||||
provider: this.name,
|
||||
inquiryType,
|
||||
},
|
||||
'Provider execution started',
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await this.executeWithResilience(
|
||||
() => this.callProvider(inquiryType, payload, context),
|
||||
context,
|
||||
);
|
||||
|
||||
this.logger.logSuccess(
|
||||
{
|
||||
requestId: context.requestId,
|
||||
trackingCode: context.trackingCode,
|
||||
provider: this.name,
|
||||
inquiryType,
|
||||
durationMs: Date.now() - start,
|
||||
},
|
||||
'Provider execution succeeded',
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.logFailure(
|
||||
{
|
||||
requestId: context.requestId,
|
||||
trackingCode: context.trackingCode,
|
||||
provider: this.name,
|
||||
inquiryType,
|
||||
durationMs: Date.now() - start,
|
||||
},
|
||||
'Provider execution failed',
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
protected async executeWithResilience<T>(
|
||||
fn: () => Promise<T>,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<T> {
|
||||
const operation = () =>
|
||||
withTimeout(fn(), this.config.timeout, `${this.name} request`);
|
||||
|
||||
return withRetry(operation, {
|
||||
maxAttempts: this.config.maxRetries,
|
||||
delayMs: 300,
|
||||
shouldRetry: (error) => this.isRetryable(error),
|
||||
});
|
||||
}
|
||||
|
||||
protected isRetryable(error: unknown): boolean {
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
const code = (error as { code?: string }).code;
|
||||
return code === 'ETIMEDOUT' || code === 'ECONNABORTED' || code === 'ECONNRESET';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected normalizeError(partial: Partial<NormalizedErrorDto>): NormalizedErrorDto {
|
||||
return {
|
||||
code: partial.code ?? 'PROVIDER_ERROR',
|
||||
message: partial.message ?? 'Provider request failed',
|
||||
providerMessage: partial.providerMessage,
|
||||
providerCode: partial.providerCode,
|
||||
};
|
||||
}
|
||||
|
||||
protected formatProviderError(
|
||||
providerMessage?: string,
|
||||
providerCode?: string,
|
||||
fallbackMessage = 'Provider returned an error',
|
||||
): Error {
|
||||
const normalized = this.normalizeError({
|
||||
code: 'PROVIDER_ERROR',
|
||||
message: fallbackMessage,
|
||||
providerMessage,
|
||||
providerCode,
|
||||
});
|
||||
const err = new Error(normalized.message);
|
||||
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
|
||||
return err;
|
||||
}
|
||||
|
||||
protected abstract callProvider(
|
||||
inquiryType: InquiryType,
|
||||
payload: TRequest,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<TResponse>;
|
||||
}
|
||||
6
src/providers/dto/auth-token.dto.ts
Normal file
6
src/providers/dto/auth-token.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class AuthTokenDto {
|
||||
accessToken!: string;
|
||||
refreshToken?: string;
|
||||
tokenType!: string;
|
||||
expiresIn!: number;
|
||||
}
|
||||
76
src/providers/factory/provider.factory.ts
Normal file
76
src/providers/factory/provider.factory.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { InquiryProvider } from '../../common/interfaces/inquiry-provider.interface';
|
||||
import { InquiryRoutingConfig } from '../../config/configuration';
|
||||
import { HamtaProvider } from '../implementations/hamta.provider';
|
||||
import { MoallemProvider } from '../implementations/moallem.provider';
|
||||
import { TejaratNouProvider } from '../implementations/tejaratnou.provider';
|
||||
|
||||
/**
|
||||
* Factory + registry for provider instances.
|
||||
* Resolves providers by name and builds ordered execution chains (default + fallbacks).
|
||||
* Note: AMITIS is not a provider, it's an authentication service used by other providers.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProviderFactory {
|
||||
private readonly logger = new Logger(ProviderFactory.name);
|
||||
private readonly registry: Map<ProviderName, InquiryProvider>;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
hamta: HamtaProvider,
|
||||
moallem: MoallemProvider,
|
||||
tejaratnou: TejaratNouProvider,
|
||||
) {
|
||||
this.registry = new Map<ProviderName, InquiryProvider>([
|
||||
[ProviderName.HAMTA, hamta],
|
||||
[ProviderName.MOALLEM, moallem],
|
||||
[ProviderName.TEJARATNOU, tejaratnou],
|
||||
]);
|
||||
}
|
||||
|
||||
getProvider(name: ProviderName): InquiryProvider | undefined {
|
||||
const provider = this.registry.get(name);
|
||||
if (!provider?.isEnabled()) {
|
||||
this.logger.warn(`Provider ${name} is disabled or not configured`);
|
||||
return undefined;
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns providers in execution order: default first, then fallbacks.
|
||||
*/
|
||||
getProvidersForInquiry(inquiryType: InquiryType): InquiryProvider[] {
|
||||
const routing = this.configService.get<InquiryRoutingConfig>(
|
||||
`inquiryRouting.${inquiryType}`,
|
||||
);
|
||||
|
||||
if (!routing) {
|
||||
this.logger.error(`No routing config for inquiry type ${inquiryType}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const orderedNames = [
|
||||
routing.defaultProvider,
|
||||
...(routing.fallbackEnabled ? routing.fallbackProviders : []),
|
||||
];
|
||||
|
||||
const seen = new Set<ProviderName>();
|
||||
const providers: InquiryProvider[] = [];
|
||||
|
||||
for (const name of orderedNames) {
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
|
||||
const provider = this.getProvider(name);
|
||||
if (provider?.supportedInquiryTypes.includes(inquiryType)) {
|
||||
providers.push(provider);
|
||||
}
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
}
|
||||
240
src/providers/implementations/amitis.provider.ts
Normal file
240
src/providers/implementations/amitis.provider.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosError, AxiosInstance } from 'axios';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { AmitisAuthServiceConfig } from '../../config/configuration';
|
||||
import { GeneralTokenDocument } from '../schemas/general-token.schema';
|
||||
import { GeneralTokenService } from '../services/general-token.service';
|
||||
|
||||
interface CentInsurTokenResponse {
|
||||
Token?: string;
|
||||
token?: string;
|
||||
AccessToken?: string;
|
||||
accessToken?: string;
|
||||
access_token?: string;
|
||||
RefreshToken?: string;
|
||||
refreshToken?: string;
|
||||
refresh_token?: string;
|
||||
TokenType?: string;
|
||||
tokenType?: string;
|
||||
token_type?: string;
|
||||
ExpiresIn?: number | string;
|
||||
expiresIn?: number | string;
|
||||
expires_in?: number | string;
|
||||
data?: CentInsurTokenResponse;
|
||||
}
|
||||
|
||||
interface AmitisAuthToken {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
tokenType: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AMITIS Authentication Service
|
||||
* Provides token-based authentication for Hamta and Moallem providers
|
||||
* This is NOT a provider itself, but an authentication helper service
|
||||
*/
|
||||
@Injectable()
|
||||
export class AmitisProvider {
|
||||
readonly name = ProviderName.AMITIS;
|
||||
private readonly logger = new Logger(AmitisProvider.name);
|
||||
private readonly httpClient: AxiosInstance;
|
||||
private readonly config: AmitisAuthServiceConfig;
|
||||
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private readonly generalTokenService: GeneralTokenService,
|
||||
) {
|
||||
this.config = configService.get<AmitisAuthServiceConfig>('amitis')!;
|
||||
this.httpClient = axios.create({
|
||||
baseURL: this.config.baseUrl,
|
||||
timeout: this.config.timeout,
|
||||
});
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled && Boolean(this.config.baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token for a specific provider and inquiry type
|
||||
* Handles token caching, refresh, and daily expiration
|
||||
*/
|
||||
async getAccessToken(
|
||||
providerName: ProviderName,
|
||||
inquiryType: InquiryType,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
const tokenKey = this.getTokenKey(providerName, inquiryType);
|
||||
const latestToken = await this.generalTokenService.getLatestToken(tokenKey);
|
||||
|
||||
// Use cached token if valid and from same day
|
||||
if (latestToken && !this.isExpired(latestToken) && this.isSameTokenDay(latestToken)) {
|
||||
return latestToken.accessToken;
|
||||
}
|
||||
|
||||
// Try to refresh if we have a refresh token and it's from the same day
|
||||
if (latestToken?.refreshToken && this.isSameTokenDay(latestToken)) {
|
||||
try {
|
||||
return await this.refreshAccessToken(latestToken, providerName, inquiryType);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`AMITIS token refresh failed for ${providerName}/${inquiryType}, requesting a new token: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Login to get new token
|
||||
return this.login(providerName, inquiryType, username, password);
|
||||
}
|
||||
|
||||
private async login(
|
||||
providerName: ProviderName,
|
||||
inquiryType: InquiryType,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
// Use URLSearchParams for application/x-www-form-urlencoded format
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
|
||||
try {
|
||||
const response = await this.httpClient.post<CentInsurTokenResponse>(
|
||||
this.config.loginPath,
|
||||
formData.toString(),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
},
|
||||
);
|
||||
const token = this.extractToken(response.data);
|
||||
|
||||
await this.saveToken(token, providerName, inquiryType, username, 'login');
|
||||
return token.accessToken;
|
||||
} catch (error) {
|
||||
throw this.toAuthError(`AMITIS login failed for ${providerName}/${inquiryType}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshAccessToken(
|
||||
latestToken: GeneralTokenDocument,
|
||||
providerName: ProviderName,
|
||||
inquiryType: InquiryType,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await this.httpClient.post<CentInsurTokenResponse>(this.config.refreshPath, {
|
||||
Token: latestToken.accessToken,
|
||||
RefreshToken: latestToken.refreshToken,
|
||||
});
|
||||
const token = this.extractToken(response.data, latestToken.refreshToken);
|
||||
|
||||
await this.saveToken(
|
||||
token,
|
||||
providerName,
|
||||
inquiryType,
|
||||
latestToken.username,
|
||||
'refresh',
|
||||
);
|
||||
return token.accessToken;
|
||||
} catch (error) {
|
||||
throw this.toAuthError(
|
||||
`AMITIS refresh failed for ${providerName}/${inquiryType}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveToken(
|
||||
token: AmitisAuthToken,
|
||||
providerName: ProviderName,
|
||||
inquiryType: InquiryType,
|
||||
username: string,
|
||||
scope: string,
|
||||
): Promise<void> {
|
||||
await this.generalTokenService.create({
|
||||
serviceProvider: this.getTokenKey(providerName, inquiryType),
|
||||
tokenType: token.tokenType,
|
||||
url: this.config.baseUrl,
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
username,
|
||||
scope,
|
||||
accessToken: token.accessToken,
|
||||
refreshToken: token.refreshToken,
|
||||
expiresIn: token.expiresIn,
|
||||
expiresAt: new Date(Date.now() + token.expiresIn * 1000),
|
||||
});
|
||||
}
|
||||
|
||||
private extractToken(
|
||||
responseBody: CentInsurTokenResponse,
|
||||
fallbackRefreshToken?: string,
|
||||
): AmitisAuthToken {
|
||||
const body = responseBody.data ?? responseBody;
|
||||
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');
|
||||
}
|
||||
|
||||
const expiresIn = Number(body.ExpiresIn ?? body.expiresIn ?? body.expires_in ?? 20 * 60);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken:
|
||||
body.RefreshToken ?? body.refreshToken ?? body.refresh_token ?? fallbackRefreshToken,
|
||||
tokenType: body.TokenType ?? body.tokenType ?? body.token_type ?? 'Bearer',
|
||||
expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 20 * 60,
|
||||
};
|
||||
}
|
||||
|
||||
private isExpired(token: GeneralTokenDocument): boolean {
|
||||
const refreshBufferMs = 60 * 1000;
|
||||
return !token.expiresAt || Date.now() + refreshBufferMs >= token.expiresAt.getTime();
|
||||
}
|
||||
|
||||
private isSameTokenDay(token: GeneralTokenDocument): boolean {
|
||||
if (!token.createdAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.getDateKey(token.createdAt) === this.getDateKey(new Date());
|
||||
}
|
||||
|
||||
private getDateKey(date: Date): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: this.config.tokenTimeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
private getTokenKey(providerName: ProviderName, inquiryType: InquiryType): string {
|
||||
return `${ProviderName.AMITIS}:${providerName}:${inquiryType}`;
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new Error(`${message}: ${error.message}`);
|
||||
}
|
||||
|
||||
return new Error(`${message}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Made with Bob
|
||||
165
src/providers/implementations/hamta.provider.ts
Normal file
165
src/providers/implementations/hamta.provider.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { ProviderEnvConfig } from '../../config/configuration';
|
||||
import { AmitisProvider } from './amitis.provider';
|
||||
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
||||
import {
|
||||
LegacyInquiryPayload,
|
||||
LegacyInquiryResult,
|
||||
LegacyApiProvider,
|
||||
} from '../shared/legacy-api.provider.abstract';
|
||||
|
||||
interface ShahkarInquiryPayload extends LegacyInquiryPayload {
|
||||
nationalCode?: string;
|
||||
nationalCod?: string;
|
||||
NationalCod?: string;
|
||||
mobileNo?: string;
|
||||
MobileNo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hamta provider — uses shared LegacyApiProvider base.
|
||||
* Supports multiple inquiry types with different authentication methods.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HamtaProvider extends LegacyApiProvider {
|
||||
readonly name = ProviderName.HAMTA;
|
||||
readonly supportedInquiryTypes = [
|
||||
InquiryType.PERSON,
|
||||
InquiryType.REAL_ESTATE,
|
||||
InquiryType.SHEBA,
|
||||
InquiryType.SHAHKAR,
|
||||
InquiryType.POSTAL_CODE,
|
||||
InquiryType.LEGAL_PERSON,
|
||||
];
|
||||
|
||||
private readonly hamtaConfig: ProviderEnvConfig;
|
||||
|
||||
constructor(configService: ConfigService, private readonly amitisProvider: AmitisProvider) {
|
||||
const config = configService.get<ProviderEnvConfig>('hamta')!;
|
||||
super(
|
||||
config,
|
||||
(providerName, inquiryType, username, password) =>
|
||||
amitisProvider.getAccessToken(providerName, inquiryType, username, password),
|
||||
);
|
||||
this.hamtaConfig = config;
|
||||
}
|
||||
|
||||
protected async callProvider(
|
||||
inquiryType: InquiryType,
|
||||
payload: LegacyInquiryPayload,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<LegacyInquiryResult> {
|
||||
// Shahkar uses SOAP authentication, handled separately
|
||||
if (inquiryType === InquiryType.SHAHKAR) {
|
||||
return this.inquireShahkar(payload as ShahkarInquiryPayload);
|
||||
}
|
||||
|
||||
return super.callProvider(inquiryType, payload, context);
|
||||
}
|
||||
|
||||
private async inquireShahkar(payload: ShahkarInquiryPayload): Promise<{ raw: unknown }> {
|
||||
const nationalCode = this.getRequiredString(
|
||||
payload.nationalCode ?? payload.nationalCod ?? payload.NationalCod,
|
||||
'nationalCode',
|
||||
);
|
||||
const mobileNo = this.getRequiredString(payload.mobileNo ?? payload.MobileNo, 'mobileNo');
|
||||
|
||||
const inquiryConfig = this.getInquiryConfig(InquiryType.SHAHKAR);
|
||||
|
||||
try {
|
||||
const response = await axios.post<string>(
|
||||
inquiryConfig.url,
|
||||
this.buildShahkarEnvelope(
|
||||
nationalCode,
|
||||
mobileNo,
|
||||
inquiryConfig.username,
|
||||
inquiryConfig.password,
|
||||
),
|
||||
{
|
||||
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');
|
||||
|
||||
return {
|
||||
raw: {
|
||||
nationalCode,
|
||||
mobileNo,
|
||||
result,
|
||||
soap: response.data,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw this.formatProviderError(
|
||||
error.message,
|
||||
String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private buildShahkarEnvelope(
|
||||
nationalCode: string,
|
||||
mobileNo: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): string {
|
||||
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>
|
||||
<ShahkarInquery xmlns="http://tempuri.org/">
|
||||
<NationalCod>${this.escapeXml(nationalCode)}</NationalCod>
|
||||
<MobileNo>${this.escapeXml(mobileNo)}</MobileNo>
|
||||
<Username>${this.escapeXml(username)}</Username>
|
||||
<Password>${this.escapeXml(password)}</Password>
|
||||
</ShahkarInquery>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`;
|
||||
}
|
||||
|
||||
private extractSoapValue(xml: string, tagName: string): string | null {
|
||||
const match = xml.match(new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)</(?:\\w+:)?${tagName}>`));
|
||||
return match ? this.decodeXml(match[1].trim()) : null;
|
||||
}
|
||||
|
||||
private getRequiredString(value: unknown, fieldName: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw this.formatProviderError(undefined, undefined, `${fieldName} is required`);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
private decodeXml(value: string): string {
|
||||
return value
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
}
|
||||
360
src/providers/implementations/moallem.provider.ts
Normal file
360
src/providers/implementations/moallem.provider.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { ProviderEnvConfig } from '../../config/configuration';
|
||||
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
||||
import { AmitisProvider } from './amitis.provider';
|
||||
import {
|
||||
LegacyApiProvider,
|
||||
LegacyInquiryPayload,
|
||||
LegacyInquiryResult,
|
||||
} from '../shared/legacy-api.provider.abstract';
|
||||
|
||||
interface CivilRegistrationPayload extends LegacyInquiryPayload {
|
||||
nationalCode?: string;
|
||||
NIN?: string;
|
||||
birthDate?: string;
|
||||
BirthDate?: string;
|
||||
dateHasPostfix?: number;
|
||||
}
|
||||
|
||||
interface PostalCodePayload extends LegacyInquiryPayload {
|
||||
postalCode?: string;
|
||||
PostalCode?: string;
|
||||
}
|
||||
|
||||
interface ShahkarPayload extends LegacyInquiryPayload {
|
||||
nationalCode?: string;
|
||||
nationalCod?: string;
|
||||
NationalCod?: string;
|
||||
mobileNo?: string;
|
||||
MobileNo?: string;
|
||||
}
|
||||
|
||||
interface SayahPayload extends LegacyInquiryPayload {
|
||||
accountOwnerType?: string;
|
||||
nationalId?: string;
|
||||
legalId?: string | null;
|
||||
shebaId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moallem provider — implements Moallem/CentInsur REST and SOAP inquiry services.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MoallemProvider extends LegacyApiProvider {
|
||||
readonly name = ProviderName.MOALLEM;
|
||||
readonly supportedInquiryTypes = [
|
||||
InquiryType.PERSON,
|
||||
InquiryType.SHEBA,
|
||||
InquiryType.SHAHKAR,
|
||||
InquiryType.POSTAL_CODE,
|
||||
];
|
||||
|
||||
private readonly moallemConfig: ProviderEnvConfig;
|
||||
|
||||
constructor(configService: ConfigService, private readonly amitisProvider: AmitisProvider) {
|
||||
const config = configService.get<ProviderEnvConfig>('moallem')!;
|
||||
super(
|
||||
config,
|
||||
(providerName, inquiryType, username, password) =>
|
||||
amitisProvider.getAccessToken(providerName, inquiryType, username, password),
|
||||
);
|
||||
this.moallemConfig = config;
|
||||
}
|
||||
|
||||
protected async callProvider(
|
||||
inquiryType: InquiryType,
|
||||
payload: LegacyInquiryPayload,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<LegacyInquiryResult> {
|
||||
if (inquiryType === InquiryType.PERSON) {
|
||||
return this.inquireCivilRegistration(payload as CivilRegistrationPayload);
|
||||
}
|
||||
|
||||
if (inquiryType === InquiryType.POSTAL_CODE) {
|
||||
return this.inquirePostalCode(payload as PostalCodePayload);
|
||||
}
|
||||
|
||||
if (inquiryType === InquiryType.SHAHKAR) {
|
||||
return this.inquireShahkar(payload as ShahkarPayload);
|
||||
}
|
||||
|
||||
if (inquiryType === InquiryType.SHEBA) {
|
||||
return this.inquireSayah(payload as SayahPayload);
|
||||
}
|
||||
|
||||
return super.callProvider(inquiryType, payload, context);
|
||||
}
|
||||
|
||||
private async inquirePostalCode(payload: PostalCodePayload): Promise<{ raw: unknown }> {
|
||||
const postalCode = this.getRequiredString(
|
||||
payload.postalCode ?? payload.PostalCode,
|
||||
'postalCode',
|
||||
);
|
||||
|
||||
const inquiryConfig = this.getInquiryConfig(InquiryType.POSTAL_CODE);
|
||||
const token = await this.amitisProvider.getAccessToken(
|
||||
ProviderName.MOALLEM,
|
||||
InquiryType.POSTAL_CODE,
|
||||
inquiryConfig.username,
|
||||
inquiryConfig.password,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await axios.get<Record<string, unknown>>(
|
||||
`${inquiryConfig.url}/AddressByPostcode`,
|
||||
{
|
||||
params: { PostalCode: postalCode },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
timeout: this.moallemConfig.timeout,
|
||||
},
|
||||
);
|
||||
|
||||
return { raw: response.data };
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw this.formatProviderError(
|
||||
error.message,
|
||||
String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async inquireCivilRegistration(
|
||||
payload: CivilRegistrationPayload,
|
||||
): Promise<{ raw: unknown }> {
|
||||
const nationalCode = this.getRequiredString(
|
||||
payload.nationalCode ?? payload.NIN,
|
||||
'nationalCode',
|
||||
);
|
||||
const birthDate = this.getRequiredString(payload.birthDate ?? payload.BirthDate, 'birthDate');
|
||||
|
||||
const inquiryConfig = this.getInquiryConfig(InquiryType.PERSON);
|
||||
|
||||
try {
|
||||
const response = await axios.post<string>(
|
||||
inquiryConfig.url,
|
||||
this.buildCivilRegistrationEnvelope(
|
||||
nationalCode,
|
||||
birthDate,
|
||||
inquiryConfig.username,
|
||||
inquiryConfig.password,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/xml; charset=utf-8',
|
||||
SOAPAction: '"http://tempuri.org/ISabtV3/SabtInquery"',
|
||||
},
|
||||
timeout: this.moallemConfig.timeout,
|
||||
responseType: 'text',
|
||||
},
|
||||
);
|
||||
|
||||
const result = this.extractSoapValue(response.data, 'SabtInqueryResult');
|
||||
|
||||
return {
|
||||
raw: {
|
||||
nationalCode,
|
||||
birthDate,
|
||||
result,
|
||||
soap: response.data,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw this.formatProviderError(
|
||||
error.message,
|
||||
String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async inquireShahkar(payload: ShahkarPayload): Promise<{ raw: unknown }> {
|
||||
const nationalCode = this.getRequiredString(
|
||||
payload.nationalCode ?? payload.nationalCod ?? payload.NationalCod,
|
||||
'nationalCode',
|
||||
);
|
||||
const mobileNo = this.getRequiredString(payload.mobileNo ?? payload.MobileNo, 'mobileNo');
|
||||
|
||||
const inquiryConfig = this.getInquiryConfig(InquiryType.SHAHKAR);
|
||||
|
||||
try {
|
||||
const response = await axios.post<string>(
|
||||
inquiryConfig.url,
|
||||
this.buildShahkarEnvelope(
|
||||
nationalCode,
|
||||
mobileNo,
|
||||
inquiryConfig.username,
|
||||
inquiryConfig.password,
|
||||
),
|
||||
{
|
||||
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');
|
||||
|
||||
return {
|
||||
raw: {
|
||||
nationalCode,
|
||||
mobileNo,
|
||||
result,
|
||||
soap: response.data,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw this.formatProviderError(
|
||||
error.message,
|
||||
String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async inquireSayah(payload: SayahPayload): Promise<{ raw: unknown }> {
|
||||
const accountOwnerType = this.getRequiredString(payload.accountOwnerType, 'accountOwnerType');
|
||||
const nationalId = payload.nationalId ?? '';
|
||||
const legalId = payload.legalId ?? null;
|
||||
const shebaId = this.getRequiredString(payload.shebaId, 'shebaId');
|
||||
|
||||
const inquiryConfig = this.getInquiryConfig(InquiryType.SHEBA);
|
||||
const token = await this.amitisProvider.getAccessToken(
|
||||
ProviderName.MOALLEM,
|
||||
InquiryType.SHEBA,
|
||||
inquiryConfig.username,
|
||||
inquiryConfig.password,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await axios.post<{
|
||||
IsSucceed?: boolean;
|
||||
Errors?: Array<{ Code?: string; Message?: string }>;
|
||||
Result?: unknown;
|
||||
}>(
|
||||
inquiryConfig.url,
|
||||
{
|
||||
AccountOwnerType: accountOwnerType,
|
||||
NationalId: nationalId,
|
||||
LegalId: legalId,
|
||||
ShebaId: shebaId,
|
||||
},
|
||||
{
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
||||
return { raw: response.data };
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw this.formatProviderError(
|
||||
error.message,
|
||||
String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private buildCivilRegistrationEnvelope(
|
||||
nationalCode: string,
|
||||
birthDate: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): string {
|
||||
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>
|
||||
<Username>${this.escapeXml(username)}</Username>
|
||||
<Password>${this.escapeXml(password)}</Password>
|
||||
</SabtInquery>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`;
|
||||
}
|
||||
|
||||
private buildShahkarEnvelope(
|
||||
nationalCode: string,
|
||||
mobileNo: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): string {
|
||||
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>
|
||||
<ShahkarInquery xmlns="http://tempuri.org/">
|
||||
<NationalCod>${this.escapeXml(nationalCode)}</NationalCod>
|
||||
<MobileNo>${this.escapeXml(mobileNo)}</MobileNo>
|
||||
<Username>${this.escapeXml(username)}</Username>
|
||||
<Password>${this.escapeXml(password)}</Password>
|
||||
</ShahkarInquery>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`;
|
||||
}
|
||||
|
||||
private extractSoapValue(xml: string, tagName: string): string | null {
|
||||
const match = xml.match(new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)</(?:\\w+:)?${tagName}>`));
|
||||
return match ? this.decodeXml(match[1].trim()) : null;
|
||||
}
|
||||
|
||||
private getRequiredString(value: unknown, fieldName: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw this.formatProviderError(undefined, undefined, `${fieldName} is required`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private formatErrors(errors: Array<{ Code?: string; Message?: string }>): string {
|
||||
return errors.map((e) => `${e.Code}: ${e.Message}`).join(', ');
|
||||
}
|
||||
|
||||
private escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
private decodeXml(value: string): string {
|
||||
return value
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
}
|
||||
|
||||
// Made with Bob
|
||||
262
src/providers/implementations/tejaratnou.provider.ts
Normal file
262
src/providers/implementations/tejaratnou.provider.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosError, AxiosInstance } from 'axios';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { jalaliDateToGregorianDate } from '../../common/helpers/jalali-date.helper';
|
||||
import { InquiryProvider, ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
||||
import { TejaratNouConfig } from '../interfaces/tejaratnou-config.interface';
|
||||
import { GeneralTokenService } from '../services/general-token.service';
|
||||
import { CachedInquiryResultService } from '../services/cached-inquiry-result.service';
|
||||
import { PersonInquiryPayload, PersonInquiryResult } from '../shared/legacy-api.provider.abstract';
|
||||
|
||||
interface TejaratNouPersonInquiryResponse {
|
||||
data?: TejaratNouPersonInquiryData;
|
||||
isSuccess?: boolean;
|
||||
statusCode?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface TejaratNouPersonInquiryData {
|
||||
nationalCode?: number | string;
|
||||
nationalCodeString?: string;
|
||||
name?: string;
|
||||
family?: string;
|
||||
fatherName?: string;
|
||||
shenasnameSeri?: string;
|
||||
shenasnameSerial?: number | string;
|
||||
shenasnameNo?: number | string;
|
||||
birthDate?: number | string;
|
||||
birthDateGregorian?: string;
|
||||
gender?: unknown;
|
||||
deathStatus?: unknown;
|
||||
deathDate?: string;
|
||||
zipcode?: string;
|
||||
zipcodeDesc?: string;
|
||||
exceptionMessage?: string;
|
||||
message?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TejaratNouProvider implements InquiryProvider<PersonInquiryPayload, PersonInquiryResult> {
|
||||
readonly name = ProviderName.TEJARATNOU;
|
||||
readonly supportedInquiryTypes = [InquiryType.PERSON];
|
||||
private readonly logger = new Logger(TejaratNouProvider.name);
|
||||
private readonly httpClient: AxiosInstance;
|
||||
private readonly config: TejaratNouConfig;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly generalTokenService: GeneralTokenService,
|
||||
private readonly cachedInquiryResultService: CachedInquiryResultService,
|
||||
) {
|
||||
this.config = this.configService.get<TejaratNouConfig>('tejaratnou')!;
|
||||
this.httpClient = axios.create({
|
||||
baseURL: this.config.inquiryBaseUrl,
|
||||
timeout: this.config.timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled && Boolean(this.config.baseUrl) && Boolean(this.config.inquiryBaseUrl);
|
||||
}
|
||||
|
||||
async execute(
|
||||
inquiryType: InquiryType,
|
||||
payload: PersonInquiryPayload,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<PersonInquiryResult> {
|
||||
if (inquiryType !== InquiryType.PERSON) {
|
||||
throw new Error(`Unsupported inquiry type: ${inquiryType}`);
|
||||
}
|
||||
|
||||
return this.inquirePerson(payload, context);
|
||||
}
|
||||
|
||||
private async inquirePerson(
|
||||
payload: PersonInquiryPayload,
|
||||
_context: ProviderExecutionContext,
|
||||
): Promise<PersonInquiryResult> {
|
||||
const cachedResult = await this.cachedInquiryResultService.findByNationalCodeAndBirthDate(
|
||||
payload.nationalCode,
|
||||
payload.birthDate,
|
||||
);
|
||||
|
||||
if (cachedResult) {
|
||||
this.logger.log(`Cache hit for nationalCode: ${payload.nationalCode}`);
|
||||
return {
|
||||
nationalCode: cachedResult.nationalCode,
|
||||
birthDate: cachedResult.birthDate,
|
||||
fullName: `${cachedResult.name} ${cachedResult.family}`,
|
||||
raw: cachedResult as any,
|
||||
};
|
||||
}
|
||||
|
||||
const token = await this.getTniToken();
|
||||
if (!token) {
|
||||
throw new Error('Failed to retrieve TNI token');
|
||||
}
|
||||
|
||||
try {
|
||||
const providerBirthDate = jalaliDateToGregorianDate(payload.birthDate);
|
||||
const response = await this.httpClient.post<TejaratNouPersonInquiryResponse>(
|
||||
`/api/identity-inquiry/national-code/${payload.nationalCode}/birthdate/${encodeURIComponent(providerBirthDate)}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'text/plain',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.data.isSuccess || !response.data.data) {
|
||||
throw this.createProviderError(
|
||||
response.data.message ?? 'TejaratNou person inquiry failed',
|
||||
String(response.data.statusCode ?? 'PROVIDER_ERROR'),
|
||||
);
|
||||
}
|
||||
|
||||
const inquiryData = response.data.data;
|
||||
const nationalCode = this.getNationalCode(inquiryData);
|
||||
|
||||
await this.cacheInquiryResult(inquiryData, payload.birthDate);
|
||||
|
||||
return {
|
||||
nationalCode,
|
||||
birthDate: payload.birthDate,
|
||||
fullName: this.getFullName(inquiryData),
|
||||
raw: inquiryData,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`TejaratNou person inquiry failed: ${error.response?.status ?? 'NETWORK_ERROR'} ${error.message}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`TejaratNou person inquiry failed: ${error}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async cacheInquiryResult(
|
||||
inquiryData: TejaratNouPersonInquiryData,
|
||||
gatewayBirthDate: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.cachedInquiryResultService.create({
|
||||
nationalCode: this.getNationalCode(inquiryData),
|
||||
name: String(inquiryData.name ?? ''),
|
||||
family: String(inquiryData.family ?? ''),
|
||||
fatherName: String(inquiryData.fatherName ?? ''),
|
||||
shenasnameSeri: String(inquiryData.shenasnameSeri ?? ''),
|
||||
shenasnameSerial: String(inquiryData.shenasnameSerial ?? ''),
|
||||
shenasnameNo: String(inquiryData.shenasnameNo ?? ''),
|
||||
birthDate: gatewayBirthDate,
|
||||
birthDateGregorian: inquiryData.birthDateGregorian,
|
||||
gender: inquiryData.gender,
|
||||
deathStatus: inquiryData.deathStatus,
|
||||
deathDate:
|
||||
inquiryData.deathDate === undefined || inquiryData.deathDate === null
|
||||
? undefined
|
||||
: String(inquiryData.deathDate),
|
||||
zipcode: String(inquiryData.zipcode ?? ''),
|
||||
zipcodeDesc: inquiryData.zipcodeDesc,
|
||||
exceptionMessage:
|
||||
inquiryData.exceptionMessage === undefined || inquiryData.exceptionMessage === null
|
||||
? undefined
|
||||
: String(inquiryData.exceptionMessage),
|
||||
message: inquiryData.message,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`TejaratNou inquiry succeeded, but cache write failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getNationalCode(inquiryData: TejaratNouPersonInquiryData): string {
|
||||
return String(inquiryData.nationalCodeString ?? inquiryData.nationalCode ?? '');
|
||||
}
|
||||
|
||||
private getFullName(inquiryData: TejaratNouPersonInquiryData): string {
|
||||
return [inquiryData.name, inquiryData.family].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
private createProviderError(providerMessage: string, providerCode: string): Error {
|
||||
const error = new Error(providerMessage);
|
||||
(
|
||||
error as Error & {
|
||||
normalizedError: {
|
||||
code: string;
|
||||
message: string;
|
||||
providerMessage: string;
|
||||
providerCode: string;
|
||||
};
|
||||
}
|
||||
).normalizedError = {
|
||||
code: 'PROVIDER_ERROR',
|
||||
message: providerMessage,
|
||||
providerMessage,
|
||||
providerCode,
|
||||
};
|
||||
return error;
|
||||
}
|
||||
|
||||
private async getTniToken(): Promise<string | null> {
|
||||
const latestToken = await this.generalTokenService.getLatestToken();
|
||||
if (latestToken && !(await this.generalTokenService.isTokenExpired(latestToken))) {
|
||||
this.logger.log(`Using existing token: ${latestToken._id}`);
|
||||
return latestToken.accessToken;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.config.baseUrl}/connect/token`,
|
||||
new URLSearchParams({
|
||||
grant_type: 'password',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
username: this.config.username,
|
||||
password: this.config.password,
|
||||
scope: 'api-gateway access-management',
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Cookie: 'cookiesession1=678A8C5D2222753B93723D81AA53FF8C',
|
||||
},
|
||||
timeout: this.config.timeout,
|
||||
},
|
||||
);
|
||||
|
||||
const jsonData = response.data;
|
||||
const expiresIn = jsonData.expires_in;
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
await this.generalTokenService.create({
|
||||
serviceProvider: 'TejaratNou',
|
||||
tokenType: jsonData.token_type,
|
||||
url: this.config.baseUrl,
|
||||
clientId: this.config.clientId,
|
||||
clientSecret: this.config.clientSecret,
|
||||
username: this.config.username,
|
||||
scope: jsonData.scope,
|
||||
accessToken: jsonData.access_token,
|
||||
expiresIn: expiresIn,
|
||||
expiresAt: expiresAt,
|
||||
});
|
||||
|
||||
return jsonData.access_token;
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to get TNI token: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/providers/interfaces/provider-config.interface.ts
Normal file
19
src/providers/interfaces/provider-config.interface.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ProviderEnvConfig } from '../../config/configuration';
|
||||
|
||||
export interface LegacyProviderApiResponse {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
code?: string;
|
||||
data?: Record<string, unknown>;
|
||||
// CentInsur API format
|
||||
IsSucceed?: boolean;
|
||||
Result?: {
|
||||
Result?: boolean;
|
||||
ErrorMessage?: string | null;
|
||||
ExternalServiceResponseDuration?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
TrackingCode?: string;
|
||||
}
|
||||
|
||||
export type ProviderConfigSlice = ProviderEnvConfig;
|
||||
10
src/providers/interfaces/tejaratnou-config.interface.ts
Normal file
10
src/providers/interfaces/tejaratnou-config.interface.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface TejaratNouConfig {
|
||||
baseUrl: string;
|
||||
inquiryBaseUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
username: string;
|
||||
password: string;
|
||||
timeout: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
36
src/providers/providers.module.ts
Normal file
36
src/providers/providers.module.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { ProviderFactory } from './factory/provider.factory';
|
||||
import { HamtaProvider } from './implementations/hamta.provider';
|
||||
import { MoallemProvider } from './implementations/moallem.provider';
|
||||
import { TejaratNouProvider } from './implementations/tejaratnou.provider';
|
||||
import { AmitisProvider } from './implementations/amitis.provider';
|
||||
import { ProviderOrchestratorService } from './strategy/provider-orchestrator.service';
|
||||
import { GeneralToken, GeneralTokenSchema } from './schemas/general-token.schema';
|
||||
import { CachedInquiryResult, CachedInquiryResultSchema } from './schemas/cached-inquiry-result.schema';
|
||||
import { GeneralTokenService } from './services/general-token.service';
|
||||
import { CachedInquiryResultService } from './services/cached-inquiry-result.service';
|
||||
|
||||
/**
|
||||
* Provider adapters, factory, and orchestration strategy.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: GeneralToken.name, schema: GeneralTokenSchema },
|
||||
{ name: CachedInquiryResult.name, schema: CachedInquiryResultSchema },
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
HamtaProvider,
|
||||
MoallemProvider,
|
||||
TejaratNouProvider,
|
||||
AmitisProvider,
|
||||
GeneralTokenService,
|
||||
CachedInquiryResultService,
|
||||
ProviderFactory,
|
||||
ProviderOrchestratorService,
|
||||
],
|
||||
exports: [ProviderFactory, ProviderOrchestratorService, GeneralTokenService, CachedInquiryResultService],
|
||||
})
|
||||
export class ProvidersModule {}
|
||||
59
src/providers/schemas/cached-inquiry-result.schema.ts
Normal file
59
src/providers/schemas/cached-inquiry-result.schema.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Schema as MongooseSchema } from 'mongoose';
|
||||
|
||||
export type CachedInquiryResultDocument = HydratedDocument<CachedInquiryResult>;
|
||||
|
||||
@Schema({ timestamps: { createdAt: true, updatedAt: false }, collection: 'cached_inquiry_results' })
|
||||
export class CachedInquiryResult {
|
||||
@Prop({ required: true, index: true })
|
||||
nationalCode!: string;
|
||||
|
||||
@Prop({ required: true, index: true })
|
||||
birthDate!: string;
|
||||
|
||||
@Prop()
|
||||
birthDateGregorian?: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
name!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
family!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
fatherName!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
shenasnameSeri!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
shenasnameSerial!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
shenasnameNo!: string;
|
||||
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
gender?: unknown;
|
||||
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
deathStatus?: unknown;
|
||||
|
||||
@Prop()
|
||||
deathDate?: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
zipcode!: string;
|
||||
|
||||
@Prop()
|
||||
zipcodeDesc?: string;
|
||||
|
||||
@Prop()
|
||||
exceptionMessage?: string;
|
||||
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
message?: unknown;
|
||||
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
export const CachedInquiryResultSchema = SchemaFactory.createForClass(CachedInquiryResult);
|
||||
44
src/providers/schemas/general-token.schema.ts
Normal file
44
src/providers/schemas/general-token.schema.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument } from 'mongoose';
|
||||
|
||||
export type GeneralTokenDocument = HydratedDocument<GeneralToken>;
|
||||
|
||||
@Schema({ timestamps: { createdAt: true, updatedAt: false }, collection: 'general_tokens' })
|
||||
export class GeneralToken {
|
||||
@Prop({ required: true })
|
||||
serviceProvider!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
tokenType!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
url!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
clientId!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
clientSecret!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
username!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
scope!: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
accessToken!: string;
|
||||
|
||||
@Prop()
|
||||
refreshToken?: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
expiresIn!: number;
|
||||
|
||||
@Prop({ required: true })
|
||||
expiresAt!: Date;
|
||||
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
export const GeneralTokenSchema = SchemaFactory.createForClass(GeneralToken);
|
||||
27
src/providers/services/cached-inquiry-result.service.ts
Normal file
27
src/providers/services/cached-inquiry-result.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
import { CachedInquiryResult, CachedInquiryResultDocument } from '../schemas/cached-inquiry-result.schema';
|
||||
|
||||
@Injectable()
|
||||
export class CachedInquiryResultService {
|
||||
constructor(
|
||||
@InjectModel(CachedInquiryResult.name)
|
||||
private readonly cachedInquiryResultModel: Model<CachedInquiryResultDocument>,
|
||||
) {}
|
||||
|
||||
async findByNationalCodeAndBirthDate(
|
||||
nationalCode: string,
|
||||
birthDate: string,
|
||||
): Promise<CachedInquiryResultDocument | null> {
|
||||
return this.cachedInquiryResultModel
|
||||
.findOne({ nationalCode, birthDate })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async create(
|
||||
data: Omit<CachedInquiryResult, 'createdAt'>,
|
||||
): Promise<CachedInquiryResultDocument> {
|
||||
return this.cachedInquiryResultModel.create(data);
|
||||
}
|
||||
}
|
||||
33
src/providers/services/general-token.service.ts
Normal file
33
src/providers/services/general-token.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
import { GeneralToken, GeneralTokenDocument } from '../schemas/general-token.schema';
|
||||
|
||||
@Injectable()
|
||||
export class GeneralTokenService {
|
||||
constructor(
|
||||
@InjectModel(GeneralToken.name)
|
||||
private readonly generalTokenModel: Model<GeneralTokenDocument>,
|
||||
) {}
|
||||
|
||||
async getLatestToken(serviceProvider = 'TejaratNou'): Promise<GeneralTokenDocument | null> {
|
||||
return this.generalTokenModel
|
||||
.findOne({ serviceProvider })
|
||||
.sort({ createdAt: -1 })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async create(
|
||||
data: Omit<GeneralToken, 'createdAt'>,
|
||||
): Promise<GeneralTokenDocument> {
|
||||
return this.generalTokenModel.create(data);
|
||||
}
|
||||
|
||||
async isTokenExpired(token: GeneralTokenDocument): Promise<boolean> {
|
||||
if (!token.expiresAt) {
|
||||
return true;
|
||||
}
|
||||
const now = new Date();
|
||||
return now >= token.expiresAt;
|
||||
}
|
||||
}
|
||||
239
src/providers/shared/legacy-api.provider.abstract.ts
Normal file
239
src/providers/shared/legacy-api.provider.abstract.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import axios, { AxiosError, AxiosInstance } from 'axios';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
||||
import { BaseProvider } from '../base/base-provider.abstract';
|
||||
import {
|
||||
LegacyProviderApiResponse,
|
||||
} from '../interfaces/provider-config.interface';
|
||||
import { ProviderEnvConfig, InquiryConfig } from '../../config/configuration';
|
||||
|
||||
export interface PersonInquiryPayload {
|
||||
nationalCode: string;
|
||||
birthDate: string;
|
||||
dateHasPostfix?: number;
|
||||
}
|
||||
|
||||
export interface PersonInquiryResult {
|
||||
fullName?: string;
|
||||
nationalCode: string;
|
||||
birthDate: string;
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
export type LegacyInquiryPayload = Record<string, unknown>;
|
||||
export type LegacyInquiryResult = PersonInquiryResult | { raw: unknown };
|
||||
export type LegacyAuthTokenResolver = (
|
||||
providerName: ProviderName,
|
||||
inquiryType: InquiryType,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<string>;
|
||||
|
||||
/**
|
||||
* Shared implementation for providers with REST API contracts (Hamta, Moallem).
|
||||
*
|
||||
* Supports multiple authentication methods:
|
||||
* - AMITIS: Token-based authentication via AMITIS service
|
||||
* - SOAP: Direct SOAP authentication (handled by subclasses)
|
||||
* - NONE: No authentication required
|
||||
*/
|
||||
export abstract class LegacyApiProvider extends BaseProvider<
|
||||
LegacyInquiryPayload,
|
||||
LegacyInquiryResult
|
||||
> {
|
||||
protected readonly httpClient: AxiosInstance;
|
||||
protected readonly providerConfig: ProviderEnvConfig;
|
||||
|
||||
constructor(
|
||||
config: ProviderEnvConfig,
|
||||
private readonly authTokenResolver?: LegacyAuthTokenResolver,
|
||||
) {
|
||||
super(config);
|
||||
this.providerConfig = config;
|
||||
this.httpClient = axios.create({
|
||||
timeout: config.timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected async callProvider(
|
||||
inquiryType: InquiryType,
|
||||
payload: LegacyInquiryPayload,
|
||||
_context: ProviderExecutionContext,
|
||||
): Promise<LegacyInquiryResult> {
|
||||
if (inquiryType === InquiryType.PERSON) {
|
||||
return this.inquirePerson(payload as unknown as PersonInquiryPayload, inquiryType);
|
||||
}
|
||||
|
||||
return this.inquireGeneric(inquiryType, payload);
|
||||
}
|
||||
|
||||
protected async inquirePerson(
|
||||
payload: PersonInquiryPayload,
|
||||
inquiryType: InquiryType,
|
||||
): Promise<PersonInquiryResult> {
|
||||
const inquiryConfig = this.getInquiryConfig(inquiryType);
|
||||
|
||||
try {
|
||||
const response = await this.httpClient.post<LegacyProviderApiResponse>(
|
||||
inquiryConfig.url,
|
||||
{
|
||||
nationalCode: payload.nationalCode,
|
||||
birthDate: payload.birthDate,
|
||||
},
|
||||
await this.buildRequestConfig(inquiryType, inquiryConfig),
|
||||
);
|
||||
|
||||
const body = response.data;
|
||||
|
||||
if (!body.success) {
|
||||
throw this.formatProviderError(body.message, body.code);
|
||||
}
|
||||
|
||||
return {
|
||||
nationalCode: payload.nationalCode,
|
||||
birthDate: payload.birthDate,
|
||||
fullName: body.data?.fullName as string | undefined,
|
||||
raw: body,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
const data = error.response?.data as LegacyProviderApiResponse | undefined;
|
||||
throw this.formatProviderError(
|
||||
data?.message ?? error.message,
|
||||
data?.code ?? String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
protected async inquireGeneric(
|
||||
inquiryType: InquiryType,
|
||||
payload: LegacyInquiryPayload,
|
||||
): Promise<{ raw: unknown }> {
|
||||
const inquiryConfig = this.getInquiryConfig(inquiryType);
|
||||
|
||||
try {
|
||||
const response = await this.httpClient.post<LegacyProviderApiResponse>(
|
||||
inquiryConfig.url,
|
||||
payload,
|
||||
await this.buildRequestConfig(inquiryType, inquiryConfig),
|
||||
);
|
||||
|
||||
const body = response.data;
|
||||
|
||||
// Handle CentInsur API format (IsSucceed/Result)
|
||||
if ('IsSucceed' in body) {
|
||||
if (!body.IsSucceed) {
|
||||
throw this.formatProviderError(
|
||||
body.Result?.ErrorMessage ?? 'Request failed',
|
||||
'API_ERROR',
|
||||
);
|
||||
}
|
||||
return { raw: body };
|
||||
}
|
||||
|
||||
// Handle legacy format (success)
|
||||
if (!body.success) {
|
||||
throw this.formatProviderError(body.message, body.code);
|
||||
}
|
||||
|
||||
return {
|
||||
raw: body,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
const data = error.response?.data as LegacyProviderApiResponse | undefined;
|
||||
|
||||
// Check for CentInsur format error
|
||||
if (data && 'IsSucceed' in data && !data.IsSucceed) {
|
||||
throw this.formatProviderError(
|
||||
data.Result?.ErrorMessage ?? error.message,
|
||||
String(error.response?.status ?? 'API_ERROR'),
|
||||
);
|
||||
}
|
||||
|
||||
throw this.formatProviderError(
|
||||
data?.message ?? error.message,
|
||||
data?.code ?? String(error.response?.status ?? 'NETWORK_ERROR'),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
protected getInquiryConfig(inquiryType: InquiryType): InquiryConfig {
|
||||
const inquiryConfig = this.providerConfig.inquiries[inquiryType];
|
||||
|
||||
if (!inquiryConfig || !inquiryConfig.url) {
|
||||
throw this.formatProviderError(
|
||||
undefined,
|
||||
undefined,
|
||||
`Inquiry type ${inquiryType} is not configured for ${this.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
return inquiryConfig;
|
||||
}
|
||||
|
||||
private async buildRequestConfig(
|
||||
inquiryType: InquiryType,
|
||||
inquiryConfig: InquiryConfig,
|
||||
): Promise<{
|
||||
headers?: Record<string, string>;
|
||||
}> {
|
||||
// No authentication required
|
||||
if (inquiryConfig.authMethod === 'NONE') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// SOAP authentication is handled by subclasses (e.g., Shahkar)
|
||||
if (inquiryConfig.authMethod === 'SOAP') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// AMITIS token-based authentication
|
||||
if (inquiryConfig.authMethod === 'AMITIS') {
|
||||
if (!this.authTokenResolver) {
|
||||
throw this.formatProviderError(
|
||||
undefined,
|
||||
undefined,
|
||||
`No auth token resolver configured for ${this.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!inquiryConfig.username || !inquiryConfig.password) {
|
||||
throw this.formatProviderError(
|
||||
undefined,
|
||||
undefined,
|
||||
`Credentials not configured for ${this.name}/${inquiryType}`,
|
||||
);
|
||||
}
|
||||
|
||||
const token = await this.authTokenResolver(
|
||||
this.name,
|
||||
inquiryType,
|
||||
inquiryConfig.username,
|
||||
inquiryConfig.password,
|
||||
);
|
||||
|
||||
return {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw this.formatProviderError(
|
||||
undefined,
|
||||
undefined,
|
||||
`Unsupported auth method ${inquiryConfig.authMethod} for ${this.name}/${inquiryType}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Made with Bob
|
||||
93
src/providers/strategy/provider-orchestrator.service.ts
Normal file
93
src/providers/strategy/provider-orchestrator.service.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../../common/enums/provider-name.enum';
|
||||
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
|
||||
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
|
||||
import { InquiryException } from '../../common/exceptions/inquiry.exception';
|
||||
import { ProviderFactory } from '../factory/provider.factory';
|
||||
|
||||
export interface OrchestrationResult<T> {
|
||||
data: T;
|
||||
provider: ProviderName;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strategy orchestrator — tries default provider, then fallbacks.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProviderOrchestratorService {
|
||||
private readonly logger = new Logger(ProviderOrchestratorService.name);
|
||||
|
||||
constructor(private readonly providerFactory: ProviderFactory) {}
|
||||
|
||||
async executeWithFallback<TRequest, TResponse>(
|
||||
inquiryType: InquiryType,
|
||||
payload: TRequest,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<OrchestrationResult<TResponse>> {
|
||||
const providers = this.providerFactory.getProvidersForInquiry(inquiryType);
|
||||
|
||||
if (providers.length === 0) {
|
||||
throw new InquiryException('No providers available for inquiry', {
|
||||
code: 'NO_PROVIDERS',
|
||||
message: `No enabled providers configured for ${inquiryType}`,
|
||||
});
|
||||
}
|
||||
|
||||
const errors: NormalizedErrorDto[] = [];
|
||||
const start = Date.now();
|
||||
|
||||
for (const provider of providers) {
|
||||
const attemptStart = Date.now();
|
||||
try {
|
||||
const data = (await provider.execute(
|
||||
inquiryType,
|
||||
payload,
|
||||
context,
|
||||
)) as TResponse;
|
||||
|
||||
return {
|
||||
data,
|
||||
provider: provider.name,
|
||||
duration: Date.now() - start,
|
||||
};
|
||||
} catch (error) {
|
||||
const normalized = this.extractError(error);
|
||||
errors.push(normalized);
|
||||
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`,
|
||||
);
|
||||
this.logger.debug(
|
||||
`Attempt duration: ${Date.now() - attemptStart}ms | error: ${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,
|
||||
});
|
||||
}
|
||||
|
||||
private extractError(error: unknown): NormalizedErrorDto {
|
||||
if (error && typeof error === 'object' && 'normalizedError' in error) {
|
||||
return (error as { normalizedError: NormalizedErrorDto }).normalizedError;
|
||||
}
|
||||
if (error instanceof InquiryException) {
|
||||
return error.normalizedError;
|
||||
}
|
||||
return {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: error instanceof Error ? error.message : 'Unknown provider error',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user