moallem all endpoints working

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

View File

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

View File

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

View File

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

View File

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