diff --git a/.env.example b/.env.example index 0458666..c2b2f0d 100644 --- a/.env.example +++ b/.env.example @@ -19,9 +19,9 @@ THROTTLE_TTL=60 THROTTLE_LIMIT=100 # Provider routing (per inquiry type) -PERSON_DEFAULT_PROVIDER=HAMTA +PERSON_DEFAULT_PROVIDER=PARSIAN PERSON_FALLBACK_ENABLED=true -PERSON_FALLBACK_PROVIDERS=MOALLEM,TEJARATNOU +PERSON_FALLBACK_PROVIDERS=HAMTA,TEJARATNOU SHAHKAR_DEFAULT_PROVIDER=PARSIAN SHAHKAR_FALLBACK_ENABLED=false SHAHKAR_FALLBACK_PROVIDERS= @@ -49,6 +49,10 @@ HAMTA_SECRET_KEY= HAMTA_TIMEOUT=10000 HAMTA_ENABLED=true HAMTA_MAX_RETRIES=2 +HAMTA_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out +HAMTA_PERSON_USERNAME= +HAMTA_PERSON_PASSWORD= +HAMTA_PERSON_AUTH_METHOD=SOAP # Moallem provider (identical API structure to Hamta) MOALLEM_BASE_URL=https://api.moallem.example.com @@ -63,6 +67,10 @@ MOALLEM_MAX_RETRIES=2 PARSIAN_ENABLED=true PARSIAN_TIMEOUT=10000 PARSIAN_MAX_RETRIES=2 +PARSIAN_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out +PARSIAN_PERSON_USERNAME= +PARSIAN_PERSON_PASSWORD= +PARSIAN_PERSON_AUTH_METHOD=SOAP PARSIAN_SHAHKAR_URL=https://apigateway.parsianinsurance.com/shahkarinqOut PARSIAN_SHAHKAR_API_KEY= PARSIAN_SHAHKAR_AUTH_METHOD=NONE diff --git a/README.md b/README.md index 1ce6f04..421e275 100644 --- a/README.md +++ b/README.md @@ -111,14 +111,14 @@ Per-inquiry configuration via environment variables: ```env # Default provider and fallback chain -PERSON_DEFAULT_PROVIDER=HAMTA -PERSON_FALLBACK_PROVIDERS=MOALLEM +PERSON_DEFAULT_PROVIDER=PARSIAN +PERSON_FALLBACK_PROVIDERS=HAMTA,TEJARATNOU # Per-inquiry credentials and authentication -HAMTA_PERSON_URL=https://api.example.com/person -HAMTA_PERSON_USERNAME=user123 -HAMTA_PERSON_PASSWORD=pass123 -HAMTA_PERSON_AUTH_METHOD=AMITIS +PARSIAN_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out +PARSIAN_PERSON_USERNAME=user123 +PARSIAN_PERSON_PASSWORD=pass123 +PARSIAN_PERSON_AUTH_METHOD=SOAP # AMITIS authentication service AMITIS_LOGIN_URL=https://auth.services.centinsur.ir/api/security/login @@ -131,16 +131,16 @@ See [Environment Configuration Guide](docs/ENVIRONMENT_CONFIGURATION.md) for com | Inquiry Type | Providers | Description | |--------------|-----------|-------------| -| `PERSON_INQUIRY` | HAMTA, MOALLEM | Person information by national code and birth date | +| `PERSON_INQUIRY` | PARSIAN, HAMTA, TEJARATNOU, MOALLEM | Person information by national code and birth date | | `REAL_ESTATE_INQUIRY` | HAMTA | Real estate ownership information | | `POSTAL_CODE_INQUIRY` | HAMTA, MOALLEM | Address validation by postal code | -| `SHAHKAR_INQUIRY` | PARSIAN, HAMTA, MOALLEM | Mobile number verification | +| `SHAHKAR_INQUIRY` | PARSIAN, HAMTA, MOALLEM | Mobile number verification | | `CIVIL_REGISTRATION_INQUIRY` | MOALLEM | Civil registration data | -| `SHEBA_INQUIRY` | PARSIAN, HAMTA, MOALLEM | Bank account (SHEBA) validation | -| `SAYAH_INQUIRY` | TEJARATNOU | Sayah system inquiry | -| `POLICY_BY_CHASSIS_INQUIRY` | PARSIAN | Car policy history by chassis number | -| `POLICY_BY_PLATE_INQUIRY` | PARSIAN | Car policy history by national plate | -| `POLICY_BY_NATIONAL_CODE_INQUIRY` | PARSIAN | Car policy history by national code | +| `SHEBA_INQUIRY` | PARSIAN, HAMTA, MOALLEM | Bank account (SHEBA) validation | +| `SAYAH_INQUIRY` | TEJARATNOU | Sayah system inquiry | +| `POLICY_BY_CHASSIS_INQUIRY` | PARSIAN | Car policy history by chassis number | +| `POLICY_BY_PLATE_INQUIRY` | PARSIAN | Car policy history by national plate | +| `POLICY_BY_NATIONAL_CODE_INQUIRY` | PARSIAN | Car policy history by national code | See [API Documentation](docs/API_DOCUMENTATION.md) for detailed request/response formats. diff --git a/docs/ENVIRONMENT_CONFIGURATION.md b/docs/ENVIRONMENT_CONFIGURATION.md index 7cc2527..763b68a 100644 --- a/docs/ENVIRONMENT_CONFIGURATION.md +++ b/docs/ENVIRONMENT_CONFIGURATION.md @@ -112,11 +112,11 @@ HAMTA_ENABLED=true HAMTA_TIMEOUT=10000 HAMTA_MAX_RETRIES=3 -# Person Inquiry (AMITIS auth) -HAMTA_PERSON_URL=https://api.hamta.example.com/api/inquiry/person -HAMTA_PERSON_USERNAME=pa6476 -HAMTA_PERSON_PASSWORD=ciiws@sabt92 -HAMTA_PERSON_AUTH_METHOD=AMITIS +# Person Inquiry (SOAP auth) +HAMTA_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out +HAMTA_PERSON_USERNAME=hamta.sabtahval +HAMTA_PERSON_PASSWORD=your-password +HAMTA_PERSON_AUTH_METHOD=SOAP # Real Estate Inquiry (AMITIS auth) HAMTA_REAL_ESTATE_URL=https://apigw.services.centinsur.ir/amlakeskanservice/amlakeskan/inquiry @@ -203,6 +203,12 @@ PARSIAN_ENABLED=true PARSIAN_TIMEOUT=10000 PARSIAN_MAX_RETRIES=2 +# Person Inquiry (SOAP auth) +PARSIAN_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out +PARSIAN_PERSON_USERNAME=pa6476 +PARSIAN_PERSON_PASSWORD=your-password +PARSIAN_PERSON_AUTH_METHOD=SOAP + # Shahkar Inquiry (X-PACKAGE-API-KEY header) PARSIAN_SHAHKAR_URL=https://apigateway.parsianinsurance.com/shahkarinqOut PARSIAN_SHAHKAR_API_KEY=your-package-api-key @@ -277,22 +283,22 @@ Routing determines which provider handles each inquiry type and whether to use f #### Person Inquiry (Single Provider) ```env -PERSON_DEFAULT_PROVIDER=TEJARATNOU +PERSON_DEFAULT_PROVIDER=PARSIAN PERSON_FALLBACK_ENABLED=false PERSON_FALLBACK_PROVIDERS= ``` -**Behavior**: Only TEJARATNOU is used. If it fails, request fails. +**Behavior**: Only PARSIAN is used. If it fails, request fails. #### Person Inquiry (With Fallback) ```env -PERSON_DEFAULT_PROVIDER=TEJARATNOU +PERSON_DEFAULT_PROVIDER=PARSIAN PERSON_FALLBACK_ENABLED=true -PERSON_FALLBACK_PROVIDERS=HAMTA,MOALLEM +PERSON_FALLBACK_PROVIDERS=HAMTA,TEJARATNOU ``` **Behavior**: -1. Try TEJARATNOU first +1. Try PARSIAN first 2. If fails, try HAMTA -3. If fails, try MOALLEM +3. If fails, try TEJARATNOU 4. If all fail, return error #### Real Estate Inquiry @@ -497,9 +503,9 @@ AMITIS_HAMTA_PERSON_PASSWORD=ciiws@sabt92 ### New Format (Current) ```env -HAMTA_PERSON_USERNAME=pa6476 -HAMTA_PERSON_PASSWORD=ciiws@sabt92 -HAMTA_PERSON_AUTH_METHOD=AMITIS +PARSIAN_PERSON_USERNAME=pa6476 +PARSIAN_PERSON_PASSWORD=your-password +PARSIAN_PERSON_AUTH_METHOD=SOAP ``` **Key Changes:** diff --git a/src/common/dto/normalized-error.dto.ts b/src/common/dto/normalized-error.dto.ts index f08a48a..3e46f48 100644 --- a/src/common/dto/normalized-error.dto.ts +++ b/src/common/dto/normalized-error.dto.ts @@ -16,4 +16,10 @@ export class NormalizedErrorDto { @ApiPropertyOptional({ example: '503' }) providerCode?: string; + + @ApiPropertyOptional({ + example: [{ field: 'sheba', constraints: ['sheba must start with IR and contain 24 digits'] }], + description: 'Field-level validation errors when code is VALIDATION_ERROR', + }) + details?: Array<{ field: string; constraints: string[] }>; } diff --git a/src/common/filters/all-exceptions.filter.ts b/src/common/filters/all-exceptions.filter.ts index 5d4e9b3..a427f52 100644 --- a/src/common/filters/all-exceptions.filter.ts +++ b/src/common/filters/all-exceptions.filter.ts @@ -10,6 +10,7 @@ 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'; +import { isNormalizedError } from '../helpers/validation-error.helper'; /** * Global exception filter — normalizes all errors into BaseInquiryResponseDto @@ -22,7 +23,7 @@ export class AllExceptionsFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost): void { const ctx = host.switchToHttp(); const response = ctx.getResponse(); - const request = ctx.getRequest(); + const request = ctx.getRequest(); const status = exception instanceof HttpException @@ -31,9 +32,10 @@ export class AllExceptionsFilter implements ExceptionFilter { const normalizedError = this.extractNormalizedError(exception); const trackingCode = request.trackingCode ?? 'UNKNOWN'; + const requestId = request.requestId ?? (request.headers['x-request-id'] as string | undefined) ?? 'unknown'; this.logger.error( - `requestId=${request.headers['x-request-id']} | trackingCode=${trackingCode} | ${normalizedError.message}`, + `requestId=${requestId} | trackingCode=${trackingCode} | code=${normalizedError.code} | ${normalizedError.message}`, exception instanceof Error ? exception.stack : undefined, ); @@ -56,13 +58,22 @@ export class AllExceptionsFilter implements ExceptionFilter { if (exception instanceof HttpException) { const res = exception.getResponse(); - if (typeof res === 'object' && res !== null && 'error' in res) { - return (res as { error: NormalizedErrorDto }).error; + + if (typeof res === 'object' && res !== null) { + if ('error' in res && isNormalizedError((res as { error: unknown }).error)) { + return (res as { error: NormalizedErrorDto }).error; + } + + if (isNormalizedError(res)) { + return res; + } } + 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), diff --git a/src/common/helpers/validation-error.helper.ts b/src/common/helpers/validation-error.helper.ts new file mode 100644 index 0000000..08d802f --- /dev/null +++ b/src/common/helpers/validation-error.helper.ts @@ -0,0 +1,97 @@ +import { ValidationError } from 'class-validator'; +import { NormalizedErrorDto } from '../dto/normalized-error.dto'; + +export interface ValidationFieldError { + field: string; + constraints: string[]; +} + +export function flattenValidationErrors( + errors: ValidationError[], + parentPath = '', +): ValidationFieldError[] { + const result: ValidationFieldError[] = []; + + for (const error of errors) { + const field = parentPath ? `${parentPath}.${error.property}` : error.property; + + if (error.constraints) { + result.push({ + field, + constraints: Object.values(error.constraints), + }); + } + + if (error.children?.length) { + result.push(...flattenValidationErrors(error.children, field)); + } + } + + return result; +} + +export function buildValidationErrorResponse(errors: ValidationError[]): { + normalizedError: NormalizedErrorDto; + details: ValidationFieldError[]; +} { + const details = flattenValidationErrors(errors); + const summary = + details.length > 0 + ? details.map((d) => `${d.field}: ${d.constraints.join(', ')}`).join('; ') + : 'Request validation failed'; + + return { + normalizedError: { + code: 'VALIDATION_ERROR', + message: summary, + details, + }, + details, + }; +} + +export function formatHttpExceptionMessage(exception: unknown): string { + if (!(exception && typeof exception === 'object' && 'getResponse' in exception)) { + return exception instanceof Error ? exception.message : String(exception); + } + + const response = (exception as { getResponse: () => unknown }).getResponse(); + + if (typeof response === 'string') { + return response; + } + + if (typeof response !== 'object' || response === null) { + return exception instanceof Error ? exception.message : 'Request failed'; + } + + const body = response as { + message?: string | string[]; + error?: unknown; + }; + + if (isNormalizedError(body.error)) { + return body.error.message; + } + + if (Array.isArray(body.message)) { + return body.message.join('; '); + } + + if (typeof body.message === 'string' && body.message.length > 0) { + return body.message; + } + + return exception instanceof Error ? exception.message : 'Request failed'; +} + +export function isNormalizedError(value: unknown): value is NormalizedErrorDto { + return ( + typeof value === 'object' && + value !== null && + 'code' in value && + 'message' in value && + typeof (value as NormalizedErrorDto).code === 'string' && + typeof (value as NormalizedErrorDto).message === 'string' + ); +} diff --git a/src/common/interceptors/logging.interceptor.ts b/src/common/interceptors/logging.interceptor.ts index eb00cad..3bc38e8 100644 --- a/src/common/interceptors/logging.interceptor.ts +++ b/src/common/interceptors/logging.interceptor.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { Observable, tap } from 'rxjs'; import { RequestLogger } from '../helpers/request-logger.helper'; +import { formatHttpExceptionMessage } from '../helpers/validation-error.helper'; /** * Logs HTTP request duration at the controller boundary. @@ -31,9 +32,10 @@ export class LoggingInterceptor implements NestInterceptor { ); }, error: (err: unknown) => { + const detail = formatHttpExceptionMessage(err); this.requestLogger.logFailure( { requestId: req.requestId ?? 'unknown', durationMs: Date.now() - start }, - `${req.method} ${req.url} failed`, + `${req.method} ${req.url} failed: ${detail}`, err, ); }, diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 9d4b9c4..78d38ca 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -95,7 +95,10 @@ export default () => ({ }, } satisfies TejaratNouConfig, inquiryRouting: { - [InquiryType.PERSON]: buildInquiryRoutingConfig('PERSON', ProviderName.TEJARATNOU, []), + [InquiryType.PERSON]: buildInquiryRoutingConfig('PERSON', ProviderName.PARSIAN, [ + ProviderName.HAMTA, + ProviderName.TEJARATNOU, + ]), [InquiryType.REAL_ESTATE]: buildInquiryRoutingConfig('REAL_ESTATE', ProviderName.HAMTA, [ ProviderName.MOALLEM, ]), diff --git a/src/inquiry/dto/real-estate-request.dto.ts b/src/inquiry/dto/real-estate-request.dto.ts new file mode 100644 index 0000000..c48358e --- /dev/null +++ b/src/inquiry/dto/real-estate-request.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, Length, Matches } from 'class-validator'; + +export class RealEstateRequestDto { + @ApiProperty({ example: '5098961130', description: 'Iranian national code (10 digits)' }) + @IsString() + @IsNotEmpty() + @Length(10, 10) + @Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' }) + nationalCode!: string; + + @ApiProperty({ example: '1349689554', description: 'Postal code (10 digits)' }) + @IsString() + @IsNotEmpty() + @Length(10, 10) + @Matches(/^\d{10}$/, { message: 'postalCode must be exactly 10 digits' }) + postalCode!: string; +} diff --git a/src/inquiry/inquiry.controller.ts b/src/inquiry/inquiry.controller.ts index d572b6b..fed20a6 100644 --- a/src/inquiry/inquiry.controller.ts +++ b/src/inquiry/inquiry.controller.ts @@ -28,6 +28,7 @@ import { GenericInquiryResponseDto } from './dto/generic-inquiry-response.dto'; import { PostalCodeRequestDto } from './dto/postal-code-request.dto'; import { ShahkarRequestDto } from './dto/shahkar-request.dto'; import { SayahRequestDto } from './dto/sayah-request.dto'; +import { RealEstateRequestDto } from './dto/real-estate-request.dto'; import { PolicyByChassisRequestDto } from './dto/policy-by-chassis-request.dto'; import { PolicyByNationalCodeRequestDto } from './dto/policy-by-national-code-request.dto'; import { PolicyByPlateRequestDto } from './dto/policy-by-plate-request.dto'; @@ -68,10 +69,14 @@ export class InquiryController { @ApiOperation({ summary: 'Real estate inquiry' }) @ApiResponse({ status: 200, type: GenericInquiryResponseDto }) async inquireRealEstate( - @Body() payload: Record, + @Body() payload: RealEstateRequestDto, @Req() req: Request & { requestId?: string; trackingCode?: string }, ): Promise>> { - return this.inquireGeneric(InquiryType.REAL_ESTATE, payload, req); + return this.inquireGeneric( + InquiryType.REAL_ESTATE, + payload as unknown as Record, + req, + ); } @Post('sheba') diff --git a/src/main.ts b/src/main.ts index 457856b..0198586 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import './telemetry'; -import { Logger, ValidationPipe } from '@nestjs/common'; +import { BadRequestException, Logger, ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; @@ -7,6 +7,7 @@ import { NextFunction, Request, Response } from 'express'; import { AppModule } from './app.module'; import { API_KEY_HEADER } from './common/constants/app.constants'; import { OpenTelemetryNestLogger } from './otel-nest-logger'; +import { buildValidationErrorResponse } from './common/helpers/validation-error.helper'; import { recordHttpRequestEnd, recordHttpRequestStart, @@ -46,6 +47,13 @@ async function bootstrap(): Promise { forbidNonWhitelisted: true, transform: true, transformOptions: { enableImplicitConversion: true }, + exceptionFactory: (errors) => { + const { normalizedError } = buildValidationErrorResponse(errors); + return new BadRequestException({ + message: normalizedError.message, + error: normalizedError, + }); + }, }), ); diff --git a/src/providers/implementations/hamta.provider.ts b/src/providers/implementations/hamta.provider.ts index 906b51a..9a46058 100644 --- a/src/providers/implementations/hamta.provider.ts +++ b/src/providers/implementations/hamta.provider.ts @@ -51,6 +51,7 @@ export class HamtaProvider extends LegacyApiProvider { InquiryType.SHEBA, InquiryType.SHAHKAR, InquiryType.POSTAL_CODE, + InquiryType.REAL_ESTATE, ]; private readonly hamtaConfig: ProviderEnvConfig; diff --git a/src/providers/implementations/moallem.provider.ts b/src/providers/implementations/moallem.provider.ts index cd40c5a..e7004ac 100644 --- a/src/providers/implementations/moallem.provider.ts +++ b/src/providers/implementations/moallem.provider.ts @@ -51,6 +51,7 @@ export class MoallemProvider extends LegacyApiProvider { InquiryType.SHEBA, InquiryType.SHAHKAR, InquiryType.POSTAL_CODE, + InquiryType.REAL_ESTATE, ]; private readonly moallemConfig: ProviderEnvConfig; diff --git a/src/providers/implementations/parsian.provider.ts b/src/providers/implementations/parsian.provider.ts index 1cdac98..0dfad25 100644 --- a/src/providers/implementations/parsian.provider.ts +++ b/src/providers/implementations/parsian.provider.ts @@ -10,8 +10,16 @@ import { AmitisProvider } from './amitis.provider'; import { LegacyInquiryPayload, LegacyInquiryResult, + PersonInquiryResult, } from '../shared/legacy-api.provider.abstract'; +interface ParsianCivilRegistrationPayload extends LegacyInquiryPayload { + nationalCode?: string; + birthDate?: string; + NIN?: string; + BirthDate?: string; +} + interface ParsianShahkarPayload extends LegacyInquiryPayload { nationalCode?: string; nationalCod?: string; @@ -71,6 +79,7 @@ interface ParsianPolicyByPlatePayload extends LegacyInquiryPayload { export class ParsianProvider extends BaseProvider { readonly name = ProviderName.PARSIAN; readonly supportedInquiryTypes = [ + InquiryType.PERSON, InquiryType.SHAHKAR, InquiryType.SHEBA, InquiryType.POLICY_BY_CHASSIS, @@ -86,6 +95,7 @@ export class ParsianProvider extends BaseProvider { + if (inquiryType === InquiryType.PERSON) { + return this.inquireCivilRegistration(payload as ParsianCivilRegistrationPayload); + } + if (inquiryType === InquiryType.SHAHKAR) { return this.inquireShahkar(payload as ParsianShahkarPayload); } @@ -188,6 +203,64 @@ export class ParsianProvider extends BaseProvider { + const nationalCode = this.getRequiredString( + payload.nationalCode ?? payload.NIN, + 'nationalCode', + ); + const birthDate = this.getRequiredString(payload.birthDate ?? payload.BirthDate, 'birthDate'); + const inquiryConfig = this.config.inquiries[InquiryType.PERSON]; + + if (!this.hasSoapCredentials(inquiryConfig)) { + throw this.formatProviderError( + undefined, + undefined, + 'Parsian person inquiry is not configured', + ); + } + + try { + const response = await axios.post( + inquiryConfig.url, + this.buildCivilRegistrationEnvelope( + nationalCode, + birthDate, + inquiryConfig.username, + inquiryConfig.password, + ), + { + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + SOAPAction: '"http://tempuri.org/ISabtInq/SubmitInqDteStsWithPstCod"', + }, + timeout: this.config.timeout, + responseType: 'text', + }, + ); + + return { + nationalCode, + birthDate, + raw: { + nationalCode, + birthDate, + result: this.extractSoapValue(response.data, 'SubmitInqDteStsWithPstCodResult'), + soap: response.data, + }, + }; + } catch (error) { + if (error instanceof AxiosError) { + throw this.formatProviderError( + error.message, + String(error.response?.status ?? 'NETWORK_ERROR'), + ); + } + throw error; + } + } + private async inquirePolicyByChassis( payload: ParsianPolicyByChassisPayload, ): Promise<{ raw: unknown }> { @@ -401,6 +474,27 @@ ${fieldXml} `; } + private buildCivilRegistrationEnvelope( + nationalCode: string, + birthDate: string, + username: string, + password: string, + ): string { + return ` + + + + ${this.escapeXml(nationalCode)} + ${this.escapeXml(birthDate)} + ${this.escapeXml(username)} + ${this.escapeXml(password)} + + +`; + } + private extractSoapValue(xml: string, tagName: string): string | null { const match = xml.match( new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)`),