initial commit

This commit is contained in:
2026-06-09 14:07:37 +03:30
parent 30ac533800
commit 996a4fcda7
121 changed files with 20557 additions and 3 deletions

View 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>;
}

View File

@@ -0,0 +1,6 @@
export class AuthTokenDto {
accessToken!: string;
refreshToken?: string;
tokenType!: string;
expiresIn!: number;
}

View 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;
}
}

View 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

View 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, '&apos;');
}
private decodeXml(value: string): string {
return value
.replace(/'/g, "'")
.replace(/"/g, '"')
.replace(/>/g, '>')
.replace(/</g, '<')
.replace(/&/g, '&');
}
}

View 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, '&apos;');
}
private decodeXml(value: string): string {
return value
.replace(/'/g, "'")
.replace(/"/g, '"')
.replace(/>/g, '>')
.replace(/</g, '<')
.replace(/&/g, '&');
}
}
// Made with Bob

View 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;
}
}
}

View 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;

View File

@@ -0,0 +1,10 @@
export interface TejaratNouConfig {
baseUrl: string;
inquiryBaseUrl: string;
clientId: string;
clientSecret: string;
username: string;
password: string;
timeout: number;
enabled: boolean;
}

View 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 {}

View 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);

View 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);

View 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);
}
}

View 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;
}
}

View 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

View 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',
};
}
}