forked from Shared/esg
initial commit
This commit is contained in:
20
src/common/common.module.ts
Normal file
20
src/common/common.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
|
||||
import { LoggingInterceptor } from './interceptors/logging.interceptor';
|
||||
import { RequestIdInterceptor } from './interceptors/request-id.interceptor';
|
||||
|
||||
/**
|
||||
* Shared cross-cutting concerns: filters, interceptors, helpers.
|
||||
* Marked @Global so feature modules need not re-import utilities.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
||||
{ provide: APP_INTERCEPTOR, useClass: RequestIdInterceptor },
|
||||
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
|
||||
],
|
||||
exports: [],
|
||||
})
|
||||
export class CommonModule {}
|
||||
3
src/common/constants/app.constants.ts
Normal file
3
src/common/constants/app.constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const REQUEST_ID_HEADER = 'x-request-id';
|
||||
export const API_KEY_HEADER = 'x-api-key';
|
||||
export const TRACKING_CODE_PREFIX = 'INQ';
|
||||
8
src/common/decorators/request-id.decorator.ts
Normal file
8
src/common/decorators/request-id.decorator.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const RequestId = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): string => {
|
||||
const request = ctx.switchToHttp().getRequest<{ requestId?: string }>();
|
||||
return request.requestId ?? 'unknown';
|
||||
},
|
||||
);
|
||||
29
src/common/dto/base-inquiry-response.dto.ts
Normal file
29
src/common/dto/base-inquiry-response.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { NormalizedErrorDto } from './normalized-error.dto';
|
||||
|
||||
/**
|
||||
* Unified inquiry response envelope.
|
||||
* Every inquiry endpoint MUST return this structure.
|
||||
*/
|
||||
export class BaseInquiryResponseDto<T = Record<string, unknown>> {
|
||||
@ApiProperty({ example: true })
|
||||
success!: boolean;
|
||||
|
||||
@ApiProperty({ example: 'HAMTA' })
|
||||
provider!: string;
|
||||
|
||||
@ApiProperty({ example: 'INQ-20250525-ABC123' })
|
||||
trackingCode!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Inquiry completed successfully' })
|
||||
message?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
data?: T;
|
||||
|
||||
@ApiPropertyOptional({ type: NormalizedErrorDto })
|
||||
error?: NormalizedErrorDto;
|
||||
|
||||
@ApiProperty({ example: 342, description: 'Duration in milliseconds' })
|
||||
duration!: number;
|
||||
}
|
||||
19
src/common/dto/normalized-error.dto.ts
Normal file
19
src/common/dto/normalized-error.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* Standardized error shape returned to API consumers
|
||||
* regardless of upstream provider error formats.
|
||||
*/
|
||||
export class NormalizedErrorDto {
|
||||
@ApiProperty({ example: 'PROVIDER_TIMEOUT' })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ example: 'Provider request timed out' })
|
||||
message!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'سرویس در دسترس نیست' })
|
||||
providerMessage?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '503' })
|
||||
providerCode?: string;
|
||||
}
|
||||
10
src/common/enums/audit-event.enum.ts
Normal file
10
src/common/enums/audit-event.enum.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Security-relevant events written to the audit log collection. */
|
||||
export enum AuditEvent {
|
||||
LOGIN = 'LOGIN',
|
||||
LOGIN_FAILED = 'LOGIN_FAILED',
|
||||
LOGOUT = 'LOGOUT',
|
||||
USER_CREATED = 'USER_CREATED',
|
||||
PASSWORD_RESET = 'PASSWORD_RESET',
|
||||
USER_BLOCKED = 'USER_BLOCKED',
|
||||
USER_UNBLOCKED = 'USER_UNBLOCKED',
|
||||
}
|
||||
8
src/common/enums/client-type.enum.ts
Normal file
8
src/common/enums/client-type.enum.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Distinguishes internal operators from external API consumers.
|
||||
* Used in JWT payload and for future multi-tenant / provider routing rules.
|
||||
*/
|
||||
export enum ClientType {
|
||||
INTERNAL = 'INTERNAL',
|
||||
EXTERNAL = 'EXTERNAL',
|
||||
}
|
||||
5
src/common/enums/inquiry-status.enum.ts
Normal file
5
src/common/enums/inquiry-status.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum InquiryStatus {
|
||||
SUCCESS = 'SUCCESS',
|
||||
FAILURE = 'FAILURE',
|
||||
PARTIAL = 'PARTIAL',
|
||||
}
|
||||
13
src/common/enums/inquiry-type.enum.ts
Normal file
13
src/common/enums/inquiry-type.enum.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Canonical inquiry types exposed via unified gateway endpoints.
|
||||
* Add new types here as new POST /inquiries/* routes are introduced.
|
||||
*/
|
||||
export enum InquiryType {
|
||||
PERSON = 'PERSON_INQUIRY',
|
||||
REAL_ESTATE = 'REAL_ESTATE_INQUIRY',
|
||||
SHEBA = 'SHEBA_INQUIRY',
|
||||
SHAHKAR = 'SHAHKAR_INQUIRY',
|
||||
POSTAL_CODE = 'POSTAL_CODE_INQUIRY',
|
||||
LEGAL_PERSON = 'LEGAL_PERSON_INQUIRY',
|
||||
CAR_PLATE = 'CAR_PLATE_INQUIRY',
|
||||
}
|
||||
10
src/common/enums/provider-name.enum.ts
Normal file
10
src/common/enums/provider-name.enum.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Registered provider identifiers.
|
||||
* Used by factory, configuration, and response normalization.
|
||||
*/
|
||||
export enum ProviderName {
|
||||
HAMTA = 'HAMTA',
|
||||
MOALLEM = 'MOALLEM',
|
||||
TEJARATNOU = 'TEJARATNOU',
|
||||
AMITIS = 'AMITIS',
|
||||
}
|
||||
12
src/common/enums/role.enum.ts
Normal file
12
src/common/enums/role.enum.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Role-based access control (RBAC) roles for the unified users system.
|
||||
* SUPER_ADMIN > ADMIN > USER in privilege hierarchy.
|
||||
*/
|
||||
export enum Role {
|
||||
SUPER_ADMIN = 'SUPER_ADMIN',
|
||||
ADMIN = 'ADMIN',
|
||||
USER = 'USER',
|
||||
}
|
||||
|
||||
/** Roles allowed to manage other users (create, block, reset password). */
|
||||
export const ADMIN_ROLES: Role[] = [Role.SUPER_ADMIN, Role.ADMIN];
|
||||
12
src/common/exceptions/inquiry.exception.ts
Normal file
12
src/common/exceptions/inquiry.exception.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
|
||||
|
||||
export class InquiryException extends HttpException {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly normalizedError: NormalizedErrorDto,
|
||||
status: HttpStatus = HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
) {
|
||||
super({ message, error: normalizedError }, status);
|
||||
}
|
||||
}
|
||||
11
src/common/exceptions/provider.exception.ts
Normal file
11
src/common/exceptions/provider.exception.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
|
||||
|
||||
export class ProviderException extends HttpException {
|
||||
constructor(
|
||||
public readonly normalizedError: NormalizedErrorDto,
|
||||
status: HttpStatus = HttpStatus.BAD_GATEWAY,
|
||||
) {
|
||||
super(normalizedError, status);
|
||||
}
|
||||
}
|
||||
77
src/common/filters/all-exceptions.filter.ts
Normal file
77
src/common/filters/all-exceptions.filter.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
|
||||
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
|
||||
import { ProviderException } from '../exceptions/provider.exception';
|
||||
|
||||
/**
|
||||
* Global exception filter — normalizes all errors into BaseInquiryResponseDto
|
||||
* when request context includes tracking metadata.
|
||||
*/
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request & { trackingCode?: string }>();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const normalizedError = this.extractNormalizedError(exception);
|
||||
const trackingCode = request.trackingCode ?? 'UNKNOWN';
|
||||
|
||||
this.logger.error(
|
||||
`requestId=${request.headers['x-request-id']} | trackingCode=${trackingCode} | ${normalizedError.message}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
|
||||
const body: BaseInquiryResponseDto = {
|
||||
success: false,
|
||||
provider: 'GATEWAY',
|
||||
trackingCode,
|
||||
message: normalizedError.message,
|
||||
error: normalizedError,
|
||||
duration: 0,
|
||||
};
|
||||
|
||||
response.status(status).json(body);
|
||||
}
|
||||
|
||||
private extractNormalizedError(exception: unknown): NormalizedErrorDto {
|
||||
if (exception instanceof ProviderException) {
|
||||
return exception.normalizedError;
|
||||
}
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const res = exception.getResponse();
|
||||
if (typeof res === 'object' && res !== null && 'error' in res) {
|
||||
return (res as { error: NormalizedErrorDto }).error;
|
||||
}
|
||||
const message =
|
||||
typeof res === 'string'
|
||||
? res
|
||||
: (res as { message?: string | string[] }).message;
|
||||
return {
|
||||
code: 'HTTP_ERROR',
|
||||
message: Array.isArray(message) ? message.join(', ') : String(message ?? exception.message),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: exception instanceof Error ? exception.message : 'Unexpected error',
|
||||
};
|
||||
}
|
||||
}
|
||||
132
src/common/helpers/jalali-date.helper.ts
Normal file
132
src/common/helpers/jalali-date.helper.ts
Normal 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');
|
||||
}
|
||||
33
src/common/helpers/mask-payload.helper.ts
Normal file
33
src/common/helpers/mask-payload.helper.ts
Normal 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;
|
||||
}
|
||||
51
src/common/helpers/request-logger.helper.ts
Normal file
51
src/common/helpers/request-logger.helper.ts
Normal 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(' | ');
|
||||
}
|
||||
}
|
||||
66
src/common/helpers/retry.helper.ts
Normal file
66
src/common/helpers/retry.helper.ts
Normal 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));
|
||||
}
|
||||
25
src/common/helpers/timeout.helper.ts
Normal file
25
src/common/helpers/timeout.helper.ts
Normal 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!);
|
||||
}
|
||||
}
|
||||
11
src/common/helpers/tracking-code.helper.ts
Normal file
11
src/common/helpers/tracking-code.helper.ts
Normal 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}`;
|
||||
}
|
||||
43
src/common/interceptors/logging.interceptor.ts
Normal file
43
src/common/interceptors/logging.interceptor.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { RequestLogger } from '../helpers/request-logger.helper';
|
||||
|
||||
/**
|
||||
* Logs HTTP request duration at the controller boundary.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
private readonly requestLogger = new RequestLogger(LoggingInterceptor.name);
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const req = context.switchToHttp().getRequest<{
|
||||
method: string;
|
||||
url: string;
|
||||
requestId?: string;
|
||||
}>();
|
||||
const start = Date.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: () => {
|
||||
this.requestLogger.logSuccess(
|
||||
{ requestId: req.requestId ?? 'unknown' },
|
||||
`${req.method} ${req.url} completed in ${Date.now() - start}ms`,
|
||||
);
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.requestLogger.logFailure(
|
||||
{ requestId: req.requestId ?? 'unknown', durationMs: Date.now() - start },
|
||||
`${req.method} ${req.url} failed`,
|
||||
err,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/common/interceptors/request-id.interceptor.ts
Normal file
31
src/common/interceptors/request-id.interceptor.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { REQUEST_ID_HEADER } from '../constants/app.constants';
|
||||
|
||||
/**
|
||||
* Ensures every request has a unique request ID for tracing.
|
||||
* Propagates ID via response header and request object.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RequestIdInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const http = context.switchToHttp();
|
||||
const request = http.getRequest<Record<string, unknown>>();
|
||||
const response = http.getResponse<{ setHeader: (k: string, v: string) => void }>();
|
||||
|
||||
const incoming =
|
||||
(request.headers as Record<string, string | undefined>)?.[REQUEST_ID_HEADER];
|
||||
const requestId = incoming ?? uuidv4();
|
||||
|
||||
request.requestId = requestId;
|
||||
response.setHeader(REQUEST_ID_HEADER, requestId);
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
21
src/common/interceptors/response-transform.interceptor.ts
Normal file
21
src/common/interceptors/response-transform.interceptor.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
|
||||
|
||||
/**
|
||||
* Ensures inquiry endpoints always emit BaseInquiryResponseDto shape.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ResponseTransformInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
return next.handle().pipe(
|
||||
map((data: BaseInquiryResponseDto) => data),
|
||||
);
|
||||
}
|
||||
}
|
||||
22
src/common/interfaces/inquiry-provider.interface.ts
Normal file
22
src/common/interfaces/inquiry-provider.interface.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { InquiryType } from '../enums/inquiry-type.enum';
|
||||
import { ProviderName } from '../enums/provider-name.enum';
|
||||
|
||||
/**
|
||||
* Contract every external provider adapter must fulfill.
|
||||
* Keeps orchestration logic decoupled from provider implementations (Strategy pattern).
|
||||
*/
|
||||
export interface InquiryProvider<TRequest = unknown, TResponse = unknown> {
|
||||
readonly name: ProviderName;
|
||||
readonly supportedInquiryTypes: InquiryType[];
|
||||
isEnabled(): boolean;
|
||||
execute(
|
||||
inquiryType: InquiryType,
|
||||
payload: TRequest,
|
||||
context: ProviderExecutionContext,
|
||||
): Promise<TResponse>;
|
||||
}
|
||||
|
||||
export interface ProviderExecutionContext {
|
||||
requestId: string;
|
||||
trackingCode: string;
|
||||
}
|
||||
19
src/common/middleware/request-id.middleware.ts
Normal file
19
src/common/middleware/request-id.middleware.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { REQUEST_ID_HEADER } from '../constants/app.constants';
|
||||
|
||||
/**
|
||||
* Early middleware assignment of request ID (runs before guards).
|
||||
*/
|
||||
@Injectable()
|
||||
export class RequestIdMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: NextFunction): void {
|
||||
const incoming = req.headers[REQUEST_ID_HEADER] as string | undefined;
|
||||
const requestId = incoming ?? uuidv4();
|
||||
|
||||
(req as Request & { requestId: string }).requestId = requestId;
|
||||
res.setHeader(REQUEST_ID_HEADER, requestId);
|
||||
next();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user