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,132 @@
const JALALI_BREAKS = [
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097,
2192, 2262, 2324, 2394, 2456, 3178,
];
export function jalaliDateToGregorianDate(jalaliDate: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(jalaliDate);
if (!match) {
throw new Error('birthDate must be a Jalali date in YYYY-MM-DD format');
}
const jy = Number(match[1]);
const jm = Number(match[2]);
const jd = Number(match[3]);
if (!isValidJalaliDate(jy, jm, jd)) {
throw new Error('birthDate is not a valid Jalali date');
}
const { gy, gm, gd } = jalaliToGregorian(jy, jm, jd);
return `${gy}-${pad2(gm)}-${pad2(gd)}`;
}
function isValidJalaliDate(jy: number, jm: number, jd: number): boolean {
if (jm < 1 || jm > 12 || jd < 1) return false;
if (jm <= 6) return jd <= 31;
if (jm <= 11) return jd <= 30;
return jd <= (isJalaliLeapYear(jy) ? 30 : 29);
}
function isJalaliLeapYear(jy: number): boolean {
return jalCal(jy).leap === 0;
}
function jalaliToGregorian(
jy: number,
jm: number,
jd: number,
): { gy: number; gm: number; gd: number } {
const gy = jalCal(jy).gy;
const march = jalCal(jy).march;
const daysInGregorianMonth = [
31,
isGregorianLeapYear(gy) ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let dayOfYear = jd - 1;
if (jm <= 6) {
dayOfYear += (jm - 1) * 31;
} else {
dayOfYear += 186 + (jm - 7) * 30;
}
let gd = march + dayOfYear;
let gm = 3;
let targetGy = gy;
while (gd > daysInGregorianMonth[gm - 1]) {
gd -= daysInGregorianMonth[gm - 1];
gm += 1;
if (gm > 12) {
gm = 1;
targetGy += 1;
daysInGregorianMonth[1] = isGregorianLeapYear(targetGy) ? 29 : 28;
}
}
return { gy: targetGy, gm, gd };
}
function jalCal(jy: number): { leap: number; gy: number; march: number } {
const breaksLength = JALALI_BREAKS.length;
const gy = jy + 621;
let leapJ = -14;
let jp = JALALI_BREAKS[0];
let jump = 0;
if (jy < jp || jy >= JALALI_BREAKS[breaksLength - 1]) {
throw new Error('Jalali year is out of supported range');
}
for (let i = 1; i < breaksLength; i += 1) {
const jm = JALALI_BREAKS[i];
jump = jm - jp;
if (jy < jm) break;
leapJ += div(jump, 33) * 8 + div(mod(jump, 33), 4);
jp = jm;
}
let n = jy - jp;
leapJ += div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;
const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
const march = 20 + leapJ - leapG;
if (jump - n < 6) {
n = n - jump + div(jump + 4, 33) * 33;
}
let leap = mod(mod(n + 1, 33) - 1, 4);
if (leap === -1) leap = 4;
return { leap, gy, march };
}
function isGregorianLeapYear(year: number): boolean {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}
function div(a: number, b: number): number {
return Math.floor(a / b);
}
function mod(a: number, b: number): number {
return a - Math.floor(a / b) * b;
}
function pad2(value: number): string {
return String(value).padStart(2, '0');
}

View File

@@ -0,0 +1,33 @@
const SENSITIVE_KEYS = [
'password',
'secret',
'secretKey',
'token',
'authorization',
'nationalCode',
];
/**
* Masks sensitive fields before persisting inquiry logs.
*/
export function maskPayload<T extends Record<string, unknown>>(
payload: T,
): Record<string, unknown> {
return maskObject({ ...payload }) as Record<string, unknown>;
}
function maskObject(obj: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (SENSITIVE_KEYS.some((k) => key.toLowerCase().includes(k.toLowerCase()))) {
result[key] = '***MASKED***';
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
result[key] = maskObject(value as Record<string, unknown>);
} else {
result[key] = value;
}
}
return result;
}

View File

@@ -0,0 +1,51 @@
import { Logger } from '@nestjs/common';
export interface RequestLogContext {
requestId: string;
trackingCode?: string;
provider?: string;
inquiryType?: string;
durationMs?: number;
success?: boolean;
}
/**
* Structured console logging for inquiry lifecycle events.
* Complements MongoDB persistence in LoggingModule.
*/
export class RequestLogger {
private readonly logger: Logger;
constructor(context: string) {
this.logger = new Logger(context);
}
logStart(ctx: RequestLogContext, message: string): void {
this.logger.log(this.format(ctx, message));
}
logSuccess(ctx: RequestLogContext, message: string): void {
this.logger.log(this.format({ ...ctx, success: true }, message));
}
logFailure(ctx: RequestLogContext, message: string, error?: unknown): void {
this.logger.error(
this.format({ ...ctx, success: false }, message),
error instanceof Error ? error.stack : undefined,
);
}
private format(ctx: RequestLogContext, message: string): string {
const parts = [
`requestId=${ctx.requestId}`,
ctx.trackingCode ? `trackingCode=${ctx.trackingCode}` : null,
ctx.provider ? `provider=${ctx.provider}` : null,
ctx.inquiryType ? `inquiryType=${ctx.inquiryType}` : null,
ctx.durationMs !== undefined ? `durationMs=${ctx.durationMs}` : null,
ctx.success !== undefined ? `success=${ctx.success}` : null,
`msg=${message}`,
].filter(Boolean);
return parts.join(' | ');
}
}

View File

@@ -0,0 +1,66 @@
export interface RetryOptions {
maxAttempts: number;
delayMs: number;
backoffMultiplier?: number;
shouldRetry?: (error: unknown) => boolean;
}
const DEFAULT_SHOULD_RETRY = (error: unknown): boolean => {
if (error && typeof error === 'object') {
// Check for network error codes
if ('code' in error) {
const code = (error as { code?: string }).code;
if (code === 'ECONNABORTED' || code === 'ETIMEDOUT' || code === 'ECONNRESET') {
return true;
}
}
// Check for HTTP 500 errors (server errors that might be transient)
if ('response' in error) {
const response = (error as { response?: { status?: number } }).response;
if (response?.status && response.status >= 500 && response.status < 600) {
return true;
}
}
}
return false;
};
/**
* Generic async retry with exponential backoff.
* Used by BaseProvider for transient network failures.
*/
export async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions,
): Promise<T> {
const {
maxAttempts,
delayMs,
backoffMultiplier = 2,
shouldRetry = DEFAULT_SHOULD_RETRY,
} = options;
let lastError: unknown;
let currentDelay = delayMs;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
const isLastAttempt = attempt === maxAttempts;
if (isLastAttempt || !shouldRetry(error)) {
throw error;
}
await sleep(currentDelay);
currentDelay *= backoffMultiplier;
}
}
throw lastError;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,25 @@
/**
* Races an async operation against a timeout.
* Throws a distinguishable error for BaseProvider normalization.
*/
export async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label = 'Operation',
): Promise<T> {
let timeoutHandle: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
(error as NodeJS.ErrnoException).code = 'ETIMEDOUT';
reject(error);
}, timeoutMs);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
clearTimeout(timeoutHandle!);
}
}

View File

@@ -0,0 +1,11 @@
import { randomBytes } from 'crypto';
import { TRACKING_CODE_PREFIX } from '../constants/app.constants';
/**
* Generates a unique, human-readable tracking code per inquiry.
*/
export function generateTrackingCode(): string {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const suffix = randomBytes(4).toString('hex').toUpperCase();
return `${TRACKING_CODE_PREFIX}-${date}-${suffix}`;
}