From 8f66502c496eaa3bcdbe07f56ee8183e7dd70154 Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Sun, 2 Aug 2026 11:33:24 +0330 Subject: [PATCH 1/2] fanavaran rate bugs fixed + sms of claimId and claimNo added --- .../claim-request-management.module.ts | 2 + .../claim-request-management.service.ts | 907 +++++++++++------- .../entites/schema/claim-cases.schema.ts | 30 + src/fanavaran/fanavaran-auth.service.spec.ts | 104 ++ src/fanavaran/fanavaran-auth.service.ts | 433 +++++++++ src/fanavaran/fanavaran-lookup.module.ts | 7 +- src/fanavaran/fanavaran-lookup.service.ts | 104 +- src/fanavaran/fanavaran.controller.ts | 20 +- .../provider/parsian-template-messages.ts | 2 + .../sms-orchestration.service.ts | 34 + 10 files changed, 1205 insertions(+), 438 deletions(-) create mode 100644 src/fanavaran/fanavaran-auth.service.spec.ts create mode 100644 src/fanavaran/fanavaran-auth.service.ts diff --git a/src/claim-request-management/claim-request-management.module.ts b/src/claim-request-management/claim-request-management.module.ts index 552c58f..57a9f6f 100644 --- a/src/claim-request-management/claim-request-management.module.ts +++ b/src/claim-request-management/claim-request-management.module.ts @@ -58,6 +58,7 @@ import { MediaPolicyModule } from "src/media-policy/media-policy.module"; import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module"; import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module"; import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module"; +import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module"; @Module({ imports: [ @@ -76,6 +77,7 @@ import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normaliz SandHubModule, ClientModule, MediaPolicyModule, + SmsOrchestrationModule, JwtModule.register({}), MongooseModule.forFeature([ { name: ClaimCase.name, schema: ClaimCaseSchema }, diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 0f3bb27..2ed849d 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -177,6 +177,7 @@ import { resolveFanavaranClientKey, } from "src/core/config/fanavaran-client.config"; import { FanavaranAuditService } from "src/fanavaran/fanavaran-audit.service"; +import { FanavaranAuthService } from "src/fanavaran/fanavaran-auth.service"; import { FanavaranLookupService } from "src/fanavaran/fanavaran-lookup.service"; import { FANAVARAN_REMOTE_LOOKUPS } from "src/fanavaran/fanavaran-lookup.config"; import type { FanavaranAuditSession } from "src/fanavaran/fanavaran-audit.types"; @@ -185,6 +186,7 @@ import { FanavaranAuditStatus, FanavaranAuditStep, } from "src/fanavaran/schema/fanavaran-audit-log.schema"; +import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { selectLatestActiveFanavaranPolicy } from "./fanavaran-policy-selection"; export interface FanavaranAutoSubmitResult { @@ -287,11 +289,7 @@ const FANAVARAN_PLATE_LETTER_CODE: Record = { export class ClaimRequestManagementService { private readonly logger = new Logger(ClaimRequestManagementService.name); - // Authentication URLs and credentials (same as lookups service) - private readonly GET_APP_TOKEN_URL = - "https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken"; - private readonly LOGIN_URL = - "https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login"; + // Business submit URL (auth lives in FanavaranAuthService) private readonly FANAVARAN_SUBMIT_URL = "https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/third-party-car-financial-claims"; @@ -327,6 +325,11 @@ export class ClaimRequestManagementService { CulpritTypeId: 337, } as const; + /** In-process dedupe for scheduleFanavaranRetry timers. */ + private readonly fanavaranRetryTimers = new Map(); + /** Prevent concurrent auto/manual runs of the same claim stage. */ + private readonly fanavaranInFlightStages = new Set(); + private readonly MAP_IR_API_KEY = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2In0.eyJhdWQiOiIyMTcxOCIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2IiwiaWF0IjoxNjgwNjA4NTkxLCJuYmYiOjE2ODA2MDg1OTEsImV4cCI6MTY4MzIwMDU5MSwic3ViIjoiIiwic2NvcGVzIjpbImJhc2ljIl19.rTviLd8b5yTHUDa3ODZyva593eMnL0d3XPg3sKkZxMOf_jNIH6lFQyIfbId-wsd1EAdsOdsL3CME_Y8t332PWJbxMNgnEq4Rf2IkClkvkSx6Sb5_4bmlhBM75zw2SmccvgbFUn4xkTOw0FT4vABC2Y3-MKctjMpmO8QOrVULSKt4psrmQhr7hBu7YRDnAAEc6muZ1VpRvdB1kqNKddoSIrfDaq6aDRJ-BNbGRAaFFvP_kH4cgSCKV4dU0TknL3mRKUiVy6_TDkjtzAN8fE2wsdvNo2pGTJPzKFsR2ipgGNTvB__g3bOnVpKsgFXPBH0e_Qa7ff1tZ3VGWy3jRNh9Lg"; @@ -351,7 +354,9 @@ export class ClaimRequestManagementService { private readonly sandHubService: SandHubService, private readonly httpService: HttpService, private readonly fanavaranAuditService: FanavaranAuditService, + private readonly fanavaranAuthService: FanavaranAuthService, private readonly fanavaranLookupService: FanavaranLookupService, + private readonly smsOrchestrationService: SmsOrchestrationService, private readonly fileMakerDbService: FileMakerDbService, private readonly fieldExpertDbService: FieldExpertDbService, private readonly plateNormalizer: PlateNormalizerService, @@ -3534,6 +3539,8 @@ export class ClaimRequestManagementService { damageParts?: any[]; resolvePolicyId: () => Promise; logPrefix: string; + /** When false, missing PolicyId is left null (preview-only). Default true. */ + requirePolicyId?: boolean; defaults?: { AccidentCityId: number; AccidentReportTypeId: number; @@ -3572,12 +3579,15 @@ export class ClaimRequestManagementService { this.applyFanavaranDefaultFields(result); const policyId = await input.resolvePolicyId(); - if (policyId === undefined || policyId === null) { + if ( + (policyId === undefined || policyId === null) && + input.requirePolicyId !== false + ) { throw new BadRequestException( `${input.logPrefix} PolicyId is required for Fanavaran submit. Policy inquiry returned no valid policy; contact the administrator.`, ); } - result.PolicyId = policyId; + result.PolicyId = policyId ?? null; return result; } @@ -3926,17 +3936,25 @@ export class ClaimRequestManagementService { const builtYear = builtYearRaw ? Number(builtYearRaw) || null : null; const carType = input.claimCase?.vehicle?.carType as string | undefined; - const vehicleKindId = await this.resolveVehicleKindId( - input.clientKey, - carType, - ); + const cachedDamage = (input.claimCase as any)?.fanavaranSync?.damageCase ?? {}; + let vehicleKindId = + cachedDamage.vehicleKindId != null + ? Number(cachedDamage.vehicleKindId) + : null; + if (vehicleKindId == null) { + vehicleKindId = await this.resolveVehicleKindId( + input.clientKey, + carType, + ); + } - // Check cache first — person.fanavaranDriverId may already be persisted from a prior lookup - let driverFanavaranId = person.fanavaranDriverId ?? null; + // Prefer claim-level cache, then party.person.fanavaranDriverId, then live inquiry + let driverFanavaranId = + cachedDamage.driverId ?? person.fanavaranDriverId ?? null; if (driverFanavaranId) { this.logger.log( - `[buildFanavaranDamageCasePayload] Using CACHED DriverId=${driverFanavaranId} from person.fanavaranDriverId`, + `[buildFanavaranDamageCasePayload] Using CACHED DriverId=${driverFanavaranId}`, ); } else { driverFanavaranId = await this.resolveDriverFanavaranId( @@ -3965,12 +3983,22 @@ export class ClaimRequestManagementService { } } + let insuranceCorpId = + cachedDamage.insuranceCorpId != null + ? Number(cachedDamage.insuranceCorpId) + : null; + if (insuranceCorpId == null) { + insuranceCorpId = await this.fanavaranLookupService.resolveInsuranceCorpId( + input.clientKey, + ); + } + this.logger.log( `[buildFanavaranDamageCasePayload] Final DriverId=${driverFanavaranId ?? "NULL"} for nationalCodeOfDriver=${person.nationalCodeOfDriver ?? "MISSING"}, ` + `PolicyNo=${policyNo ?? "NULL"}, PolicyCINumber=${policyCINumber ?? "NULL"}`, ); - return { + const payload = { BeginDate: beginDate, BuiltYear: builtYear, ChassisNo: this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [ @@ -3985,7 +4013,7 @@ export class ClaimRequestManagementService { EndDate: endDate, EstimateAmount: FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT, FaultPercent: input.defaults.FaultPercent, - InsuranceCorpId: await this.fanavaranLookupService.resolveInsuranceCorpId(input.clientKey), + InsuranceCorpId: insuranceCorpId, DriverIsOwner: person.driverIsInsurer ? 1 : input.defaults.DriverIsOwner, LicenceCityId: null, LicenceCountryId: null, @@ -4022,6 +4050,27 @@ export class ClaimRequestManagementService { AccidentVehicleUsedId: input.defaults.AccidentVehicleUsedId, PolicyCINumber: policyCINumber, }; + + // Persist Fanavaran-sourced IDs + last payload for reuse on later preview/submit + if (input.claimCase?._id) { + await this.claimCaseDbService.findByIdAndUpdate(String(input.claimCase._id), { + $set: { + "fanavaranSync.damageCase.lastPayload": payload, + "fanavaranSync.damageCase.lastPayloadBuiltAt": new Date(), + ...(driverFanavaranId != null + ? { "fanavaranSync.damageCase.driverId": driverFanavaranId } + : {}), + ...(vehicleKindId != null + ? { "fanavaranSync.damageCase.vehicleKindId": vehicleKindId } + : {}), + ...(insuranceCorpId != null + ? { "fanavaranSync.damageCase.insuranceCorpId": insuranceCorpId } + : {}), + }, + }); + } + + return payload; } /** @@ -4162,7 +4211,8 @@ export class ClaimRequestManagementService { } /** - * Step 1: Get appToken from GetAppToken API + * Resolve Fanavaran authenticationToken via shared tenant cache (TTL). + * Prefer clientKey overload — config-based overload keeps legacy Tejaratno callers working. */ private async getAppToken( config: { @@ -4171,216 +4221,53 @@ export class ClaimRequestManagementService { } = this.getTejaratnoFanavaranConfig(), auditSession?: FanavaranAuditSession, ): Promise { - const startedAt = Date.now(); - if (auditSession) { - await this.fanavaranAuditService.recordStep({ - session: auditSession, - step: FanavaranAuditStep.GET_APP_TOKEN, - status: FanavaranAuditStatus.STARTED, - requestUrl: this.GET_APP_TOKEN_URL, - requestMeta: { appName: config.appName }, - }); - } - - try { - const requestHeaders: any = { - appname: config.appName, - secret: config.secret, - "Content-Length": "0", - }; - delete requestHeaders["Content-Type"]; - - const response = await firstValueFrom( - this.httpService.post(this.GET_APP_TOKEN_URL, "", { - headers: requestHeaders, - transformRequest: [ - (data, headers) => { - if (headers) { - delete headers["Content-Type"]; - delete headers["content-type"]; - } - return data; - }, - ], - }), - ); - - const appToken = - response.headers.apptoken || - response.headers.appToken || - response.headers["apptoken"] || - response.headers["appToken"]; - if (!appToken) { - this.logger.error("Failed to get appToken from response headers"); - throw new BadGatewayException( - "Failed to get appToken from response headers", - ); - } - - if (auditSession) { - await this.fanavaranAuditService.recordStep({ - session: auditSession, - step: FanavaranAuditStep.GET_APP_TOKEN, - status: FanavaranAuditStatus.SUCCESS, - requestUrl: this.GET_APP_TOKEN_URL, - httpStatus: response.status, - responseMeta: { hasAppToken: true }, - durationMs: Date.now() - startedAt, - }); - } - - this.logger.log(`Successfully obtained appToken`); - return appToken; - } catch (error) { - if (auditSession) { - await this.fanavaranAuditService.recordStep({ - session: auditSession, - step: FanavaranAuditStep.GET_APP_TOKEN, - status: FanavaranAuditStatus.FAILURE, - requestUrl: this.GET_APP_TOKEN_URL, - httpStatus: isAxiosError(error) ? error.response?.status : undefined, - errorMessage: this.fanavaranAuditService.extractErrorMessage(error), - errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), - durationMs: Date.now() - startedAt, - }); - } - - this.logger.error("Failed to get appToken", error); - if (isAxiosError(error)) { - const errorMessage = - error.response?.data?.Message || - error.response?.data?.message || - error.response?.data || - error.message || - "Failed to get appToken from external API"; - throw new BadGatewayException( - this.fanavaranAuditService.formatErrorWithTrackingCode( - String(errorMessage), - auditSession?.trackingCode, - ), - ); - } - throw new BadGatewayException( - this.fanavaranAuditService.formatErrorWithTrackingCode( - "Failed to get appToken from external API", - auditSession?.trackingCode, - ), - ); - } + const clientKey = this.resolveClientKeyFromAuthConfig(config); + // App token is internal to FanavaranAuthService; return auth token for legacy callers + // that only needed a successful login chain. Prefer getFanavaranAuthHeaders. + return this.fanavaranAuthService.getAuthenticationToken(clientKey, { + auditSession, + }); } - /** - * Step 2: Login to get authenticationToken - */ private async login( - appToken: string, + _appToken: string, config: { username: string; password: string; } = this.getTejaratnoFanavaranConfig(), auditSession?: FanavaranAuditSession, ): Promise { - const startedAt = Date.now(); - if (auditSession) { - await this.fanavaranAuditService.recordStep({ - session: auditSession, - step: FanavaranAuditStep.LOGIN, - status: FanavaranAuditStatus.STARTED, - requestUrl: this.LOGIN_URL, - requestMeta: { userName: config.username }, - }); + const clientKey = this.resolveClientKeyFromLoginConfig(config); + return this.fanavaranAuthService.getAuthenticationToken(clientKey, { + auditSession, + }); + } + + private resolveClientKeyFromAuthConfig(config: { + appName: string; + }): FanavaranClientKey { + if (config.appName === this.PARSIAN_FANAVARAN_CONFIG.appName) { + return "parsian"; } + return resolveFanavaranClientKey(); + } - try { - const requestHeaders: any = { - appToken: appToken, - userName: config.username, - password: config.password, - "Content-Length": "0", - }; - delete requestHeaders["Content-Type"]; - - const response = await firstValueFrom( - this.httpService.post(this.LOGIN_URL, "", { - headers: requestHeaders, - transformRequest: [ - (data, headers) => { - if (headers) { - delete headers["Content-Type"]; - delete headers["content-type"]; - } - return data; - }, - ], - }), - ); - - const authenticationToken = - response.headers.authenticationtoken || - response.headers.authenticationToken || - response.headers["authenticationtoken"] || - response.headers["authenticationToken"] || - response.data?.authenticationtoken || - response.data?.authenticationToken || - response.data?.authentication_token; - - if (!authenticationToken) { - this.logger.error("Failed to get authenticationToken from response"); - throw new BadGatewayException( - "Failed to get authenticationToken from response", - ); - } - - if (auditSession) { - await this.fanavaranAuditService.recordStep({ - session: auditSession, - step: FanavaranAuditStep.LOGIN, - status: FanavaranAuditStatus.SUCCESS, - requestUrl: this.LOGIN_URL, - httpStatus: response.status, - responseMeta: { hasAuthenticationToken: true }, - durationMs: Date.now() - startedAt, - }); - } - - this.logger.log(`Successfully obtained authenticationToken`); - return authenticationToken; - } catch (error) { - if (auditSession) { - await this.fanavaranAuditService.recordStep({ - session: auditSession, - step: FanavaranAuditStep.LOGIN, - status: FanavaranAuditStatus.FAILURE, - requestUrl: this.LOGIN_URL, - httpStatus: isAxiosError(error) ? error.response?.status : undefined, - errorMessage: this.fanavaranAuditService.extractErrorMessage(error), - errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), - durationMs: Date.now() - startedAt, - }); - } - - this.logger.error("Failed to login", error); - if (isAxiosError(error)) { - const errorMessage = - error.response?.data?.Message || - error.response?.data?.message || - error.response?.data || - error.message || - "Failed to login to external API"; - throw new BadGatewayException( - this.fanavaranAuditService.formatErrorWithTrackingCode( - String(errorMessage), - auditSession?.trackingCode, - ), - ); - } - throw new BadGatewayException( - this.fanavaranAuditService.formatErrorWithTrackingCode( - "Failed to login to external API", - auditSession?.trackingCode, - ), - ); + private resolveClientKeyFromLoginConfig(config: { + username: string; + }): FanavaranClientKey { + if (config.username === this.PARSIAN_FANAVARAN_CONFIG.username) { + return "parsian"; } + return resolveFanavaranClientKey(); + } + + private async getFanavaranAuthHeaders( + clientKey: FanavaranClientKey, + auditSession?: FanavaranAuditSession, + ) { + return this.fanavaranAuthService.getRequestHeaders(clientKey, { + auditSession, + }); } private async getPolicyIdFromNationalCode( @@ -4396,7 +4283,10 @@ export class ClaimRequestManagementService { }, logPrefix: string, auditSession?: FanavaranAuditSession, + options?: { clientKey?: FanavaranClientKey; claimCaseId?: string }, ): Promise { + const clientKey = + options?.clientKey ?? this.resolveClientKeyFromAuthConfig(config); const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`; const startedAt = Date.now(); @@ -4418,10 +4308,9 @@ export class ClaimRequestManagementService { } try { - const appToken = await this.getAppToken(config, auditSession); - const authenticationToken = await this.login( - appToken, - config, + this.fanavaranAuthService.assertNotInBackoff(clientKey); + const headers = await this.getFanavaranAuthHeaders( + clientKey, auditSession, ); @@ -4432,10 +4321,7 @@ export class ClaimRequestManagementService { const response = await firstValueFrom( this.httpService.get(policyInquiryUrl, { headers: { - authenticationToken: authenticationToken, - CorpId: config.corpId, - ContractId: config.contractId, - Location: config.location, + ...headers, "Content-Type": "application/json", }, timeout: 15000, @@ -4449,18 +4335,21 @@ export class ClaimRequestManagementService { this.logger.log( `${logPrefix} Policy inquiry response status=${response.status} count=${policyCount}`, ); - this.logger.log( - `${logPrefix} Policy inquiry raw response: ${JSON.stringify( - response.data, - null, - 2, - )}`, - ); const selectedPolicy = selectLatestActiveFanavaranPolicy(response.data); this.logger.log( `${logPrefix} Selected latest active policy PolicyId=${selectedPolicy.policyId} EndDate=${selectedPolicy.endDate}`, ); + if (options?.claimCaseId && selectedPolicy.policyId != null) { + await this.claimCaseDbService.findByIdAndUpdate(options.claimCaseId, { + $set: { + "fanavaranSync.baseClaim.policyId": selectedPolicy.policyId, + }, + }); + } + + this.fanavaranAuthService.clearBackoff(clientKey); + if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, @@ -4479,6 +4368,7 @@ export class ClaimRequestManagementService { } return selectedPolicy.policyId; } catch (error) { + this.fanavaranAuthService.registerFailure(clientKey, error); const errorMessage = error instanceof Error ? error.message : "unknown policy inquiry error"; if (auditSession) { @@ -4612,18 +4502,36 @@ export class ClaimRequestManagementService { } /** - * Preview Fanavaran submit body (V2 claimCases + blameCases) for a specific client. + * Build Fanavaran GEN.03 payload from local claim/blame data. + * + * Fanavaran-sourced fields (PolicyId) use resolve-once caching: + * - If `fanavaranSync.baseClaim.policyId` exists → reuse (no policy HTTP). + * - Else → live inquiry-my-policies once, then persist policyId + lastPayload. + * + * Auth still uses the shared midnight-TTL token cache (not a new login per preview). + * Pass `forceRefreshPolicy: true` to bust the PolicyId cache. */ async previewFanavaranSubmitV2( claimCaseId: string, clientKey: FanavaranClientKey, options?: { debug?: boolean; + /** Bust cached PolicyId and inquire again. */ + forceRefreshPolicy?: boolean; + /** @deprecated Use forceRefreshPolicy. When true with no cache, inquired; kept for submit. */ + resolvePolicy?: boolean; + /** When true (submit), missing PolicyId throws. Preview keeps payload even if inquiry fails. */ + requirePolicyId?: boolean; auditSession?: FanavaranAuditSession; + /** Persist built payload onto fanavaranSync.baseClaim.lastPayload (default true). */ + persistPayload?: boolean; }, ): Promise { const profile = getFanavaranClientProfile(clientKey); const logPrefix = `[Fanavaran ${clientKey} V2] claimCaseId=${claimCaseId}`; + const forceRefreshPolicy = options?.forceRefreshPolicy === true; + const requirePolicyId = options?.requirePolicyId === true; + const persistPayload = options?.persistPayload !== false; const auditSession = options?.auditSession ?? ({ @@ -4632,18 +4540,12 @@ export class ClaimRequestManagementService { source: FanavaranAuditSource.PREVIEW, claimCaseId, } satisfies FanavaranAuditSession); - const buildStartedAt = Date.now(); - - await this.fanavaranAuditService.recordStep({ - session: auditSession, - step: FanavaranAuditStep.BUILD_PAYLOAD, - status: FanavaranAuditStatus.STARTED, - }); try { const debug = { clientKey, claimCaseId, + forceRefreshPolicy, steps: { claimCaseFound: false, blameRequestLinked: false, @@ -4655,6 +4557,7 @@ export class ClaimRequestManagementService { damageReplyFound: false, guiltyPartyIdFound: false, nationalCodeOfInsurerFound: false, + policyIdFromCache: false, policyInquiryAttempted: false, policyInquirySucceeded: false, accidentReasonFallbackFromSnapshot: false, @@ -4668,6 +4571,8 @@ export class ClaimRequestManagementService { estimateAmount: null as number | null, }, failureReason: null as string | null, + note: + "PolicyId is resolve-once: first preview/submit may call Fanavaran policy inquiry; later calls reuse fanavaranSync.baseClaim.policyId. Auth token is shared until Tehran midnight.", }; const claimCase = await this.claimCaseDbService.findById(claimCaseId); @@ -4718,6 +4623,9 @@ export class ClaimRequestManagementService { : 0; } + const cachedPolicyId = + (claimCase as any)?.fanavaranSync?.baseClaim?.policyId ?? null; + const payload = await this.buildFanavaranSubmitPayload({ accidentReason: selectedAccidentReason, createdAt: (blameCase as { createdAt?: Date }).createdAt, @@ -4725,6 +4633,7 @@ export class ClaimRequestManagementService { damageParts, defaults: profile.defaults, logPrefix, + requirePolicyId, resolvePolicyId: async () => { const guiltyPartyId = this.resolveGuiltyPartyIdV2( blameCase.parties ?? [], @@ -4762,24 +4671,43 @@ export class ClaimRequestManagementService { return null; } + if (cachedPolicyId != null && !forceRefreshPolicy) { + debug.steps.policyIdFromCache = true; + debug.values.policyId = Number(cachedPolicyId); + return Number(cachedPolicyId); + } + debug.steps.policyInquiryAttempted = true; const policyId = await this.getPolicyIdFromNationalCode( nationalCodeOfInsurer, profile.auth, logPrefix, auditSession, + { clientKey, claimCaseId }, ); debug.values.policyId = policyId; debug.steps.policyInquirySucceeded = policyId !== null; if (policyId === null) { debug.failureReason = debug.failureReason ?? - "policy inquiry returned no PolicyId (timeout, network error, or empty response)"; + "policy inquiry returned no PolicyId (timeout, network error, empty response, or backoff)"; } return policyId; }, }); + if (persistPayload) { + await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { + $set: { + "fanavaranSync.baseClaim.lastPayload": payload, + "fanavaranSync.baseClaim.lastPayloadBuiltAt": new Date(), + ...(payload.PolicyId != null + ? { "fanavaranSync.baseClaim.policyId": payload.PolicyId } + : {}), + }, + }); + } + if (options?.debug) { return { clientKey, @@ -4815,7 +4743,12 @@ export class ClaimRequestManagementService { bodyOverride?: Record, ): Promise { try { - return await this.executeFanavaranV2Submit(claimCaseId, clientKey, bodyOverride); + return await this.executeFanavaranV2Submit( + claimCaseId, + clientKey, + bodyOverride, + FanavaranAuditSource.SUBMIT, + ); } catch (error) { this.logger.error( `[Fanavaran ${clientKey} V2] Error submitting to Fanavaran`, @@ -5171,6 +5104,7 @@ export class ClaimRequestManagementService { { clientKey, auditSource: FanavaranAuditSource.AUTO_SUBMIT }, ), `${logPrefix} [${i + 1}/${pending.length}] retry`, + { error, clientKey }, ); } @@ -5392,6 +5326,7 @@ export class ClaimRequestManagementService { "attachments", () => this.autoSubmitFanavaranAttachment(claimCaseId, file, options), logPrefix, + { error, clientKey }, ); return { @@ -5729,6 +5664,13 @@ export class ClaimRequestManagementService { const { payload, warnings, replyKey } = await this.buildFanavaranExpertisePayload({ claimCase, clientKey }); + await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { + $set: { + "fanavaranSync.expertise.lastPayload": payload, + "fanavaranSync.expertise.lastPayloadBuiltAt": new Date(), + }, + }); + return { clientKey, claimCaseId, @@ -5961,6 +5903,7 @@ export class ClaimRequestManagementService { "expertise", () => this.autoSubmitFanavaranExpertiseOnExpertReply(claimCaseId), logPrefix, + { error, clientKey }, ); return { @@ -6014,21 +5957,24 @@ export class ClaimRequestManagementService { payload: Record, clientKey: FanavaranClientKey, ) { - const profile = getFanavaranClientProfile(clientKey); - const appToken = await this.getAppToken(profile.auth); - const authenticationToken = await this.login(appToken, profile.auth); + this.fanavaranAuthService.assertNotInBackoff(clientKey); + const headers = await this.getFanavaranAuthHeaders(clientKey); - return await firstValueFrom( - this.httpService.post(url, payload, { - headers: { - authenticationToken, - CorpId: profile.auth.corpId, - ContractId: profile.auth.contractId, - Location: profile.auth.location, - "Content-Type": "application/json", - }, - }), - ); + try { + const response = await firstValueFrom( + this.httpService.post(url, payload, { + headers: { + ...headers, + "Content-Type": "application/json", + }, + }), + ); + this.fanavaranAuthService.clearBackoff(clientKey); + return response; + } catch (error) { + this.fanavaranAuthService.registerFailure(clientKey, error); + throw error; + } } private async postFanavaranMultipart( @@ -6037,9 +5983,8 @@ export class ClaimRequestManagementService { files: Array<{ path: string; fileName: string }>, clientKey: FanavaranClientKey, ) { - const profile = getFanavaranClientProfile(clientKey); - const appToken = await this.getAppToken(profile.auth); - const authenticationToken = await this.login(appToken, profile.auth); + this.fanavaranAuthService.assertNotInBackoff(clientKey); + const headers = await this.getFanavaranAuthHeaders(clientKey); const form = new FormData(); form.append("Param", JSON.stringify(content), { @@ -6053,10 +5998,10 @@ export class ClaimRequestManagementService { const curlParts = [ `curl -X POST '${url}'`, - `-H 'authenticationToken: ${authenticationToken}'`, - `-H 'CorpId: ${profile.auth.corpId}'`, - `-H 'ContractId: ${profile.auth.contractId}'`, - `-H 'Location: ${profile.auth.location}'`, + `-H 'authenticationToken: ${headers.authenticationToken}'`, + `-H 'CorpId: ${headers.CorpId}'`, + `-H 'ContractId: ${headers.ContractId}'`, + `-H 'Location: ${headers.Location}'`, `-F 'Param=${JSON.stringify(content)}'`, ...files.map((f) => `-F 'Param1=@${f.path};filename=${f.fileName}'`), ]; @@ -6067,25 +6012,27 @@ export class ClaimRequestManagementService { `${logPrefix} REQUEST: url=${url} | Param=${JSON.stringify(content)} | files=[${files.map((f) => f.fileName).join(", ")}] | fileCount=${files.length}`, ); - const response = await firstValueFrom( - this.httpService.post(url, form, { - headers: { - ...form.getHeaders(), - authenticationToken, - CorpId: profile.auth.corpId, - ContractId: profile.auth.contractId, - Location: profile.auth.location, - }, - maxBodyLength: Infinity, - maxContentLength: Infinity, - }), - ); + try { + const response = await firstValueFrom( + this.httpService.post(url, form, { + headers: { + ...form.getHeaders(), + ...headers, + }, + maxBodyLength: Infinity, + maxContentLength: Infinity, + }), + ); - this.logger.log( - `${logPrefix} RESPONSE: status=${response.status} | body=${JSON.stringify(response.data)}`, - ); - - return response; + this.logger.log( + `${logPrefix} RESPONSE: status=${response.status} | body=${JSON.stringify(response.data)}`, + ); + this.fanavaranAuthService.clearBackoff(clientKey); + return response; + } catch (error) { + this.fanavaranAuthService.registerFailure(clientKey, error); + throw error; + } } private async executeFanavaranDamageCaseSubmit( @@ -6292,11 +6239,9 @@ export class ClaimRequestManagementService { try { await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $set: { - "fanavaranSync.damageCase": { - status: "failed", - lastTriedAt: new Date(), - lastError: warning, - }, + "fanavaranSync.damageCase.status": "failed", + "fanavaranSync.damageCase.lastTriedAt": new Date(), + "fanavaranSync.damageCase.lastError": warning, }, $push: { history: { @@ -6318,8 +6263,13 @@ export class ClaimRequestManagementService { await this.scheduleFanavaranRetry( claimCaseId, "damageCase", - () => this.autoSubmitFanavaranDamageCaseOnOuterPartsSelected(claimCaseId, selectedParts), + () => + this.autoSubmitFanavaranDamageCaseOnOuterPartsSelected( + claimCaseId, + selectedParts, + ), logPrefix, + { error, clientKey }, ); return { @@ -6341,6 +6291,10 @@ export class ClaimRequestManagementService { const clientKey = resolveFanavaranClientKey(); const logPrefix = `[Fanavaran ${clientKey} V2 Early Auto] claimCaseId=${claimCaseId}`; + const locked = await this.withFanavaranStageLock( + claimCaseId, + "baseClaim", + async () => { try { const claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) { @@ -6367,6 +6321,8 @@ export class ClaimRequestManagementService { const fanavaranResponse = await this.executeFanavaranV2Submit( claimCaseId, clientKey, + undefined, + FanavaranAuditSource.AUTO_SUBMIT, ); await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { @@ -6397,6 +6353,11 @@ export class ClaimRequestManagementService { try { await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { + $set: { + "fanavaranSync.baseClaim.status": "failed", + "fanavaranSync.baseClaim.lastTriedAt": new Date(), + "fanavaranSync.baseClaim.lastError": warning, + }, $push: { history: { type: "FANAVARAN_EARLY_AUTO_SUBMIT_FAILED", @@ -6413,12 +6374,32 @@ export class ClaimRequestManagementService { ); } + await this.scheduleFanavaranRetry( + claimCaseId, + "baseClaim", + () => this.autoSubmitToFanavaranV2OnClaimCreated(claimCaseId), + logPrefix, + { error, clientKey }, + ); + return { attempted: true, submitted: false, warning: `${warning} Initial case was not sent to Fanavaran. Retry manually via POST ${fanavaranSubmitPath(clientKey, claimCaseId)}.`, }; } + }, + ); + + if (locked && typeof locked === "object" && "skipped" in locked && locked.skipped) { + return { + attempted: false, + submitted: false, + skipped: true, + skipReason: locked.skipReason, + }; + } + return locked as FanavaranAutoSubmitResult; } /** @@ -6548,6 +6529,7 @@ export class ClaimRequestManagementService { "baseClaim", () => this.autoSubmitToFanavaranV2OnClaimCreated(claimCaseId), logPrefix, + { error, clientKey }, ); return { @@ -6592,23 +6574,58 @@ export class ClaimRequestManagementService { } private static readonly FANAVARAN_RETRY_DELAY_MS = 5 * 60 * 1000; // 5 minutes + private static readonly FANAVARAN_TRANSIENT_RETRY_DELAY_MS = 10 * 60 * 1000; // 10 minutes private static readonly FANAVARAN_MAX_RETRIES = 2; + private fanavaranRetryKey( + claimCaseId: string, + stage: "baseClaim" | "damageCase" | "attachments" | "expertise", + ): string { + return `${claimCaseId}:${stage}`; + } + + private async withFanavaranStageLock( + claimCaseId: string, + stage: string, + fn: () => Promise, + ): Promise { + const key = `${claimCaseId}:${stage}`; + if (this.fanavaranInFlightStages.has(key)) { + this.logger.warn( + `[Fanavaran] Skipping concurrent ${stage} for claimCaseId=${claimCaseId}`, + ); + return { + skipped: true, + skipReason: `Fanavaran ${stage} already in flight for this claim`, + }; + } + this.fanavaranInFlightStages.add(key); + try { + return await fn(); + } finally { + this.fanavaranInFlightStages.delete(key); + } + } + /** - * After a Fanavaran stage failure, schedule a retry if retries remain. - * Increments retryCount, sets nextRetryAt, and after the delay calls the - * provided retryFn. Returns true if a retry was scheduled. + * After a Fanavaran stage failure, schedule a single deferred retry. + * Dedupes in-process timers and Mongo `nextRetryAt` so concurrent failures + * cannot enqueue multiple setTimeouts for the same claim/stage. */ private async scheduleFanavaranRetry( claimCaseId: string, stage: "baseClaim" | "damageCase" | "attachments" | "expertise", retryFn: () => Promise, logPrefix: string, + options?: { error?: unknown; clientKey?: FanavaranClientKey }, ): Promise { + const key = this.fanavaranRetryKey(claimCaseId, stage); const claimCase = await this.claimCaseDbService.findById(claimCaseId); const syncStage = (claimCase as any)?.fanavaranSync?.[stage]; const retryCount = syncStage?.retryCount ?? 0; - const maxRetries = syncStage?.maxRetries ?? ClaimRequestManagementService.FANAVARAN_MAX_RETRIES; + const maxRetries = + syncStage?.maxRetries ?? + ClaimRequestManagementService.FANAVARAN_MAX_RETRIES; if (retryCount >= maxRetries) { this.logger.warn( @@ -6617,26 +6634,70 @@ export class ClaimRequestManagementService { return false; } + // Already scheduled in DB for the future — do not add another timer. + const existingNext = syncStage?.nextRetryAt + ? new Date(syncStage.nextRetryAt).getTime() + : 0; + if ( + syncStage?.status === "pending" && + existingNext > Date.now() + 5_000 + ) { + this.logger.log( + `${logPrefix} Retry already pending for ${stage} at ${new Date(existingNext).toISOString()}; skipping duplicate schedule.`, + ); + return false; + } + + // In-process timer already armed for this key. + if (this.fanavaranRetryTimers.has(key)) { + this.logger.log( + `${logPrefix} In-process retry timer already exists for ${stage}; skipping duplicate.`, + ); + return false; + } + + const transient = FanavaranAuthService.isTransientTryLaterError( + options?.error, + ); + const delayMs = transient + ? ClaimRequestManagementService.FANAVARAN_TRANSIENT_RETRY_DELAY_MS + : ClaimRequestManagementService.FANAVARAN_RETRY_DELAY_MS; + + if (options?.clientKey && options?.error) { + this.fanavaranAuthService.registerFailure( + options.clientKey, + options.error, + ); + } + const nextRetryCount = retryCount + 1; - const nextRetryAt = new Date(Date.now() + ClaimRequestManagementService.FANAVARAN_RETRY_DELAY_MS); + const nextRetryAt = new Date(Date.now() + delayMs); await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $set: { [`fanavaranSync.${stage}.retryCount`]: nextRetryCount, + [`fanavaranSync.${stage}.maxRetries`]: maxRetries, [`fanavaranSync.${stage}.nextRetryAt`]: nextRetryAt, [`fanavaranSync.${stage}.status`]: "pending", + [`fanavaranSync.${stage}.lastTriedAt`]: new Date(), + ...(options?.error + ? { + [`fanavaranSync.${stage}.lastError`]: + this.extractFanavaranErrorMessage(options.error), + } + : {}), }, }); this.logger.log( - `${logPrefix} Scheduling retry ${nextRetryCount}/${maxRetries} for ${stage} at ${nextRetryAt.toISOString()}`, + `${logPrefix} Scheduling retry ${nextRetryCount}/${maxRetries} for ${stage} at ${nextRetryAt.toISOString()} (delay=${delayMs}ms${transient ? ", transient backoff" : ""})`, ); - setTimeout(async () => { + const timer = setTimeout(async () => { + this.fanavaranRetryTimers.delete(key); try { const fresh = await this.claimCaseDbService.findById(claimCaseId); const freshStage = (fresh as any)?.fanavaranSync?.[stage]; - // Only retry if still in pending state (not manually resolved) if (freshStage?.status === "pending") { await retryFn(); } @@ -6646,26 +6707,143 @@ export class ClaimRequestManagementService { retryError, ); } - }, ClaimRequestManagementService.FANAVARAN_RETRY_DELAY_MS); + }, delayMs); + this.fanavaranRetryTimers.set(key, timer); return true; } + /** + * Phone for the claim owner / damaged party (same recipient as other claim SMS). + */ + private async resolveFanavaranClaimOwnerPhone( + claimCase: any, + ): Promise { + const notifyUserId = + claimCase?.damagedPartyUserId ?? claimCase?.owner?.userId; + if (!notifyUserId) return undefined; + const ownerUserId = String(notifyUserId); + + if (claimCase.blameRequestId) { + const blame = await this.blameRequestDbService.findById( + String(claimCase.blameRequestId), + ); + const ownerParty = (blame?.parties || []).find( + (p: any) => + p?.person?.userId && String(p.person.userId) === ownerUserId, + ); + const fromParty = ownerParty?.person?.phoneNumber; + if (typeof fromParty === "string" && fromParty.trim()) { + return fromParty.trim(); + } + } + + const user = await this.userDbService.findOne({ + _id: new Types.ObjectId(ownerUserId), + }); + const mobile = user?.mobile; + if (typeof mobile === "string" && mobile.trim()) return mobile.trim(); + return undefined; + } + + /** + * After Fanavaran base claim create succeeds, SMS the claim owner with + * ClaimNo + claimId. Uses SmsOrchestrationService → same provider as login + * for this deployment (`SMS` / `SMS_PROVIDER`). Never throws. + */ + private async notifyClaimOwnerFanavaranBaseClaimRegistered(input: { + claimCaseId: string; + claimId?: number | string | null; + claimNo?: number | string | null; + logPrefix: string; + }): Promise { + try { + if (input.claimId == null && input.claimNo == null) { + this.logger.warn( + `${input.logPrefix} Skip Fanavaran SMS: no claimId/ClaimNo on response`, + ); + return; + } + + const claimCase = await this.claimCaseDbService.findById( + input.claimCaseId, + ); + if (!claimCase) return; + + if ((claimCase as any)?.fanavaranSync?.baseClaim?.smsNotifiedAt) { + this.logger.log( + `${input.logPrefix} Fanavaran SMS already sent; skipping duplicate`, + ); + return; + } + + const phone = await this.resolveFanavaranClaimOwnerPhone(claimCase); + if (!phone) { + this.logger.warn( + `${input.logPrefix} Skip Fanavaran SMS: no phone for claim owner`, + ); + return; + } + + const publicId = + typeof claimCase.publicId === "string" && claimCase.publicId.trim() + ? claimCase.publicId.trim() + : String(input.claimCaseId); + + const sent = + await this.smsOrchestrationService.sendFanavaranBaseClaimRegisteredNotice( + { + receptor: phone, + publicId, + claimNo: input.claimNo, + claimId: input.claimId, + }, + ); + + if (sent) { + await this.claimCaseDbService.findByIdAndUpdate(input.claimCaseId, { + $set: { + "fanavaranSync.baseClaim.smsNotifiedAt": new Date(), + }, + }); + this.logger.log( + `${input.logPrefix} Fanavaran claim SMS sent to claim owner phone=${phone} claimNo=${input.claimNo ?? "-"} claimId=${input.claimId ?? "-"}`, + ); + } + } catch (error) { + this.logger.error( + `${input.logPrefix} Fanavaran claim SMS failed (non-fatal)`, + error, + ); + } + } + private async executeFanavaranV2Submit( claimCaseId: string, clientKey: FanavaranClientKey, bodyOverride?: Record, + auditSource: FanavaranAuditSource = FanavaranAuditSource.AUTO_SUBMIT, ): Promise { - const profile = getFanavaranClientProfile(clientKey); const logPrefix = `[Fanavaran ${clientKey} V2]`; this.logger.log( `${logPrefix} Starting submission for claimCaseId: ${claimCaseId}`, ); - const fanavaranData = bodyOverride ?? await this.previewFanavaranSubmitV2( - claimCaseId, + + const auditSession: FanavaranAuditSession = { + trackingCode: this.fanavaranAuditService.generateTrackingCode(), clientKey, - ); + source: auditSource, + claimCaseId, + }; + + const fanavaranData = + bodyOverride ?? + (await this.previewFanavaranSubmitV2(claimCaseId, clientKey, { + requirePolicyId: true, + auditSession, + persistPayload: true, + })); this.logger.log( `${logPrefix} Mapped data prepared:`, JSON.stringify(fanavaranData, null, 2), @@ -6684,53 +6862,102 @@ export class ClaimRequestManagementService { ); } - const appToken = await this.getAppToken(profile.auth); - const authenticationToken = await this.login(appToken, profile.auth); - - const response = await firstValueFrom( - this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, { - headers: { - authenticationToken: authenticationToken, - CorpId: profile.auth.corpId, - ContractId: profile.auth.contractId, - Location: profile.auth.location, - "Content-Type": "application/json", - }, - }), + this.fanavaranAuthService.assertNotInBackoff(clientKey); + const headers = await this.getFanavaranAuthHeaders( + clientKey, + auditSession, ); - this.logger.log(`${logPrefix} API Response Status: ${response.status}`); - this.logger.log( - `${logPrefix} API Response Data:`, - JSON.stringify(response.data, null, 2), - ); + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.SUBMIT_CLAIM, + status: FanavaranAuditStatus.STARTED, + requestUrl: this.FANAVARAN_SUBMIT_URL, + requestMeta: { policyId: fanavaranData?.PolicyId ?? null }, + }); + const startedAt = Date.now(); - if (response.data) { - const claimNo = response.data.ClaimNo; - const claimId = response.data.Id; - - const updateData: any = { $set: {} as Record }; - if (claimNo !== undefined) { - updateData.$set.claimNo = claimNo; - } - if (claimId !== undefined) { - updateData.$set.claimId = claimId; - } - updateData.$set["fanavaranSync.baseClaim"] = { - status: "success", - lastTriedAt: new Date(), - claimId, - claimNo, - response: response.data, - }; - - await this.claimCaseDbService.findByIdAndUpdate( - claimCaseId, - updateData, + try { + const response = await firstValueFrom( + this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, { + headers: { + ...headers, + "Content-Type": "application/json", + }, + }), ); - } - return response.data; + this.logger.log(`${logPrefix} API Response Status: ${response.status}`); + this.logger.log( + `${logPrefix} API Response Data:`, + JSON.stringify(response.data, null, 2), + ); + + this.fanavaranAuthService.clearBackoff(clientKey); + + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.SUBMIT_CLAIM, + status: FanavaranAuditStatus.SUCCESS, + requestUrl: this.FANAVARAN_SUBMIT_URL, + httpStatus: response.status, + responseMeta: { + claimId: response.data?.Id, + claimNo: response.data?.ClaimNo, + }, + durationMs: Date.now() - startedAt, + }); + + if (response.data) { + const claimNo = response.data.ClaimNo; + const claimId = response.data.Id; + + const updateData: any = { $set: {} as Record }; + if (claimNo !== undefined) { + updateData.$set.claimNo = claimNo; + } + if (claimId !== undefined) { + updateData.$set.claimId = claimId; + } + updateData.$set["fanavaranSync.baseClaim.status"] = "success"; + updateData.$set["fanavaranSync.baseClaim.lastTriedAt"] = new Date(); + updateData.$set["fanavaranSync.baseClaim.claimId"] = claimId; + updateData.$set["fanavaranSync.baseClaim.claimNo"] = claimNo; + updateData.$set["fanavaranSync.baseClaim.response"] = response.data; + if (fanavaranData?.PolicyId != null) { + updateData.$set["fanavaranSync.baseClaim.policyId"] = + fanavaranData.PolicyId; + } + + await this.claimCaseDbService.findByIdAndUpdate( + claimCaseId, + updateData, + ); + + // Best-effort: same SMS provider as login for this deployment + await this.notifyClaimOwnerFanavaranBaseClaimRegistered({ + claimCaseId, + claimId, + claimNo, + logPrefix, + }); + } + + return response.data; + } catch (error) { + this.fanavaranAuthService.registerFailure(clientKey, error); + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.SUBMIT_CLAIM, + status: FanavaranAuditStatus.FAILURE, + requestUrl: this.FANAVARAN_SUBMIT_URL, + httpStatus: isAxiosError(error) ? error.response?.status : undefined, + errorMessage: this.fanavaranAuditService.extractErrorMessage(error), + errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), + durationMs: Date.now() - startedAt, + }); + throw error; + } } /** @deprecated Use executeFanavaranV2Submit(claimCaseId, "parsian") */ diff --git a/src/claim-request-management/entites/schema/claim-cases.schema.ts b/src/claim-request-management/entites/schema/claim-cases.schema.ts index 8e1c841..b34eaae 100644 --- a/src/claim-request-management/entites/schema/claim-cases.schema.ts +++ b/src/claim-request-management/entites/schema/claim-cases.schema.ts @@ -81,6 +81,36 @@ export class FanavaranSyncStage { @Prop({ type: Number }) expertiseId?: number; + /** Cached Fanavaran PolicyId from inquiry-my-policies (guilty party). */ + @Prop({ type: Number }) + policyId?: number; + + /** Cached Fanavaran DriverId (parties inquiry-by-unique-identifier). */ + @Prop({ type: Number }) + driverId?: number; + + /** Cached Fanavaran VehicleKindId. */ + @Prop({ type: Number }) + vehicleKindId?: number; + + /** Cached Fanavaran InsuranceCorpId. */ + @Prop({ type: Number }) + insuranceCorpId?: number; + + /** + * Last successfully built payload for this stage (preview/submit). + * Lets later preview/submit reuse Fanavaran-sourced fields without re-calling. + */ + @Prop({ type: MongooseSchema.Types.Mixed }) + lastPayload?: Record; + + @Prop({ type: Date }) + lastPayloadBuiltAt?: Date; + + /** When we SMS'd the claim owner about Fanavaran claimId/ClaimNo (base claim). */ + @Prop({ type: Date }) + smsNotifiedAt?: Date; + @Prop({ type: [MongooseSchema.Types.Mixed], default: [] }) files?: unknown[]; diff --git a/src/fanavaran/fanavaran-auth.service.spec.ts b/src/fanavaran/fanavaran-auth.service.spec.ts new file mode 100644 index 0000000..3af85ea --- /dev/null +++ b/src/fanavaran/fanavaran-auth.service.spec.ts @@ -0,0 +1,104 @@ +import { FanavaranAuthService } from "./fanavaran-auth.service"; + +describe("FanavaranAuthService", () => { + it("detects Fanavaran transient try-later messages", () => { + expect( + FanavaranAuthService.isTransientTryLaterError( + "کد پیگیری خطا: 10573755\r\n1405/05/1016:56:16:276\r\n.لطفا پس از چند لحظه مجدد تلاش فرمایید.", + ), + ).toBe(true); + expect( + FanavaranAuthService.isTransientTryLaterError( + "فیلد شماره بيمه نامه ضروری میباشد", + ), + ).toBe(false); + }); + + it("caches token and single-flights concurrent logins", async () => { + const http = { + post: jest.fn(), + }; + const audit = { + recordStep: jest.fn().mockResolvedValue(undefined), + extractErrorMessage: (e: unknown) => + e instanceof Error ? e.message : String(e), + sanitizeErrorDetails: () => ({}), + formatErrorWithTrackingCode: (m: string) => m, + }; + + let loginCalls = 0; + http.post.mockImplementation((url: string) => { + if (url.includes("GetAppToken")) { + return { + toPromise: undefined, + pipe: undefined, + subscribe: undefined, + // firstValueFrom uses Observable — mock as Observable-like via rxjs + }; + } + return {}; + }); + + // Use real firstValueFrom path by mocking httpService.post to return an Observable + const { of, delay } = await import("rxjs"); + http.post.mockImplementation((url: string) => { + if (url.includes("GetAppToken")) { + return of({ + status: 200, + headers: { apptoken: "app-1" }, + data: {}, + }); + } + loginCalls += 1; + return of({ + status: 200, + headers: { authenticationtoken: "auth-1" }, + data: {}, + }).pipe(delay(20)); + }); + + const service = new FanavaranAuthService(http as any, audit as any); + + const [a, b, c] = await Promise.all([ + service.getAuthenticationToken("parsian"), + service.getAuthenticationToken("parsian"), + service.getAuthenticationToken("parsian"), + ]); + + expect(a).toBe("auth-1"); + expect(b).toBe("auth-1"); + expect(c).toBe("auth-1"); + expect(loginCalls).toBe(1); + + // Cache hit — no extra login + await service.getAuthenticationToken("parsian"); + expect(loginCalls).toBe(1); + }); + + it("enters tenant backoff on try-later errors", () => { + const service = new FanavaranAuthService({} as any, {} as any); + service.registerFailure( + "parsian", + "کد پیگیری خطا: 1\r\n.لطفا پس از چند لحظه مجدد تلاش فرمایید.", + ); + expect(service.isInBackoff("parsian")).toBe(true); + expect(() => service.assertNotInBackoff("parsian")).toThrow( + /backoff/i, + ); + }); + + it("computes next Asia/Tehran midnight expiry after now", () => { + // 2026-08-02 10:00:00 UTC ≈ 13:30 Tehran (UTC+3:30) → same calendar day midnight + const now = Date.parse("2026-08-02T10:00:00.000Z"); + const expiry = FanavaranAuthService.getNextMidnightExpiryMs(now); + expect(expiry).toBeGreaterThan(now); + // Must land within ~14h (before next Tehran midnight) + expect(expiry - now).toBeLessThanOrEqual(24 * 60 * 60 * 1000); + expect(expiry - now).toBeGreaterThan(0); + + // Just after Tehran midnight: 2026-08-01 20:30:01 UTC = 2026-08-02 00:00:01 Tehran + const justAfterMidnight = Date.parse("2026-08-01T20:30:01.000Z"); + const next = FanavaranAuthService.getNextMidnightExpiryMs(justAfterMidnight); + expect(next - justAfterMidnight).toBeGreaterThan(23 * 60 * 60 * 1000); + }); +}); diff --git a/src/fanavaran/fanavaran-auth.service.ts b/src/fanavaran/fanavaran-auth.service.ts new file mode 100644 index 0000000..0ac88ec --- /dev/null +++ b/src/fanavaran/fanavaran-auth.service.ts @@ -0,0 +1,433 @@ +import { HttpService } from "@nestjs/axios"; +import { + BadGatewayException, + HttpException, + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { isAxiosError } from "axios"; +import { firstValueFrom } from "rxjs"; +import { + getFanavaranClientProfile, + type FanavaranAuthConfig, + type FanavaranClientKey, +} from "src/core/config/fanavaran-client.config"; +import { FanavaranAuditService } from "./fanavaran-audit.service"; +import type { FanavaranAuditSession } from "./fanavaran-audit.types"; +import { + FanavaranAuditStatus, + FanavaranAuditStep, +} from "./schema/fanavaran-audit-log.schema"; + +interface CachedFanavaranAuth { + authenticationToken: string; + /** Epoch ms when the cached token should be refreshed. */ + expiresAt: number; +} + +interface TenantBackoffState { + until: number; + reason: string; +} + +/** Shared Fanavaran auth: one AppToken+Login per tenant, reused until TTL / backoff. */ +@Injectable() +export class FanavaranAuthService { + private readonly logger = new Logger(FanavaranAuthService.name); + + private readonly getAppTokenUrl = + "https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken"; + private readonly loginUrl = + "https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login"; + + /** When Fanavaran says "try again later", pause all tenant calls. */ + static readonly TRANSIENT_BACKOFF_MS = 5 * 60 * 1000; + + /** + * Fanavaran authenticationToken is valid until local midnight (Asia/Tehran). + * First call after 00:00 gets a fresh token; daytime calls reuse the cache. + */ + static readonly TOKEN_TIME_ZONE = "Asia/Tehran"; + + private readonly tokenCache = new Map(); + private readonly inflightLogin = new Map< + FanavaranClientKey, + Promise + >(); + private readonly backoffByTenant = new Map< + FanavaranClientKey, + TenantBackoffState + >(); + + constructor( + private readonly httpService: HttpService, + private readonly fanavaranAuditService: FanavaranAuditService, + ) {} + + /** True when Fanavaran asked us to wait (Persian “try again later” / tracking-code 500). */ + static isTransientTryLaterError(errorOrMessage: unknown): boolean { + const message = FanavaranAuthService.extractMessage(errorOrMessage); + if (!message) return false; + return ( + message.includes("لطفا پس از چند لحظه مجدد تلاش") || + message.includes("مجدد تلاش فرمایید") || + /try again later/i.test(message) + ); + } + + static extractMessage(errorOrMessage: unknown): string { + if (typeof errorOrMessage === "string") return errorOrMessage; + if (isAxiosError(errorOrMessage)) { + const data = errorOrMessage.response?.data as + | { Message?: string; message?: string } + | string + | undefined; + if (typeof data === "string") return data; + return ( + data?.Message || + data?.message || + errorOrMessage.message || + "" + ); + } + if (errorOrMessage instanceof Error) return errorOrMessage.message; + return errorOrMessage == null ? "" : String(errorOrMessage); + } + + /** + * Epoch ms of the next 00:00:00 in Asia/Tehran after `now`. + * If `now` is exactly midnight Tehran, returns the following midnight. + */ + static getNextMidnightExpiryMs( + nowMs: number = Date.now(), + timeZone: string = FanavaranAuthService.TOKEN_TIME_ZONE, + ): number { + const parts = Object.fromEntries( + new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }) + .formatToParts(new Date(nowMs)) + .filter((p) => p.type !== "literal") + .map((p) => [p.type, p.value]), + ) as Record; + + const hour = Number(parts.hour); + const minute = Number(parts.minute); + const second = Number(parts.second); + const msIntoDay = ((hour * 60 + minute) * 60 + second) * 1000; + const msPerDay = 24 * 60 * 60 * 1000; + const remaining = msPerDay - msIntoDay; + // Exactly at midnight → treat as expired for current day; expire at next midnight. + return nowMs + (remaining === 0 ? msPerDay : remaining); + } + + getBackoffRemainingMs(clientKey: FanavaranClientKey): number { + const state = this.backoffByTenant.get(clientKey); + if (!state) return 0; + return Math.max(0, state.until - Date.now()); + } + + isInBackoff(clientKey: FanavaranClientKey): boolean { + return this.getBackoffRemainingMs(clientKey) > 0; + } + + /** + * Call after any Fanavaran HTTP failure. Sets a tenant-wide pause when the + * error is the familiar “try again later” overload response. + */ + registerFailure(clientKey: FanavaranClientKey, error: unknown): void { + if (!FanavaranAuthService.isTransientTryLaterError(error)) return; + + const until = Date.now() + FanavaranAuthService.TRANSIENT_BACKOFF_MS; + const reason = FanavaranAuthService.extractMessage(error).slice(0, 500); + this.backoffByTenant.set(clientKey, { until, reason }); + // Token may still be valid, but Fanavaran is rejecting work — keep token, + // just stop hammering Login/business APIs. + this.logger.warn( + `[${clientKey}] Fanavaran transient backoff until ${new Date(until).toISOString()}: ${reason}`, + ); + } + + clearBackoff(clientKey: FanavaranClientKey): void { + this.backoffByTenant.delete(clientKey); + } + + invalidateToken(clientKey: FanavaranClientKey): void { + this.tokenCache.delete(clientKey); + } + + assertNotInBackoff(clientKey: FanavaranClientKey): void { + const remaining = this.getBackoffRemainingMs(clientKey); + if (remaining <= 0) return; + const state = this.backoffByTenant.get(clientKey); + throw new ServiceUnavailableException( + `Fanavaran tenant "${clientKey}" is in backoff for ${Math.ceil(remaining / 1000)}s after transient errors. ${state?.reason ?? ""}`.trim(), + ); + } + + async getAuthenticationToken( + clientKey: FanavaranClientKey, + options?: { + auditSession?: FanavaranAuditSession; + forceRefresh?: boolean; + }, + ): Promise { + this.assertNotInBackoff(clientKey); + + if (!options?.forceRefresh) { + const cached = this.tokenCache.get(clientKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.authenticationToken; + } + } else { + this.invalidateToken(clientKey); + } + + const existing = this.inflightLogin.get(clientKey); + if (existing) { + return existing; + } + + const loginPromise = this.loginFresh(clientKey, options?.auditSession); + this.inflightLogin.set(clientKey, loginPromise); + try { + return await loginPromise; + } finally { + this.inflightLogin.delete(clientKey); + } + } + + async getRequestHeaders( + clientKey: FanavaranClientKey, + options?: { + auditSession?: FanavaranAuditSession; + forceRefresh?: boolean; + }, + ): Promise<{ + authenticationToken: string; + CorpId: string; + ContractId: string; + Location: string; + }> { + const profile = getFanavaranClientProfile(clientKey); + const authenticationToken = await this.getAuthenticationToken( + clientKey, + options, + ); + return { + authenticationToken, + CorpId: profile.auth.corpId, + ContractId: profile.auth.contractId, + Location: profile.auth.location, + }; + } + + private async loginFresh( + clientKey: FanavaranClientKey, + auditSession?: FanavaranAuditSession, + ): Promise { + const profile = getFanavaranClientProfile(clientKey); + const appToken = await this.fetchAppToken(profile.auth, auditSession); + const authenticationToken = await this.fetchLoginToken( + appToken, + profile.auth, + auditSession, + ); + + const expiresAt = FanavaranAuthService.getNextMidnightExpiryMs(); + this.tokenCache.set(clientKey, { + authenticationToken, + expiresAt, + }); + this.clearBackoff(clientKey); + this.logger.log( + `[${clientKey}] Cached Fanavaran authenticationToken until ${new Date( + expiresAt, + ).toISOString()} (${FanavaranAuthService.TOKEN_TIME_ZONE} midnight)`, + ); + return authenticationToken; + } + + private emptyBodyTransformRequest() { + return [ + (_data: unknown, headers?: Record) => { + if (headers) { + delete headers["Content-Type"]; + delete headers["content-type"]; + } + return _data; + }, + ]; + } + + private async fetchAppToken( + config: Pick, + auditSession?: FanavaranAuditSession, + ): Promise { + const startedAt = Date.now(); + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.GET_APP_TOKEN, + status: FanavaranAuditStatus.STARTED, + requestUrl: this.getAppTokenUrl, + requestMeta: { appName: config.appName, cached: false }, + }); + } + + try { + const response = await firstValueFrom( + this.httpService.post(this.getAppTokenUrl, "", { + headers: { + appname: config.appName, + secret: config.secret, + "Content-Length": "0", + }, + transformRequest: this.emptyBodyTransformRequest(), + }), + ); + + const appToken = + response.headers.apptoken || + response.headers.appToken || + response.headers["apptoken"] || + response.headers["appToken"]; + + if (!appToken) { + throw new BadGatewayException("Failed to get Fanavaran appToken"); + } + + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.GET_APP_TOKEN, + status: FanavaranAuditStatus.SUCCESS, + requestUrl: this.getAppTokenUrl, + httpStatus: response.status, + responseMeta: { hasAppToken: true }, + durationMs: Date.now() - startedAt, + }); + } + + return appToken; + } catch (error) { + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.GET_APP_TOKEN, + status: FanavaranAuditStatus.FAILURE, + requestUrl: this.getAppTokenUrl, + httpStatus: isAxiosError(error) ? error.response?.status : undefined, + errorMessage: this.fanavaranAuditService.extractErrorMessage(error), + errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), + durationMs: Date.now() - startedAt, + }); + } + throw this.toGatewayError(error, "Failed to get appToken from external API", auditSession); + } + } + + private async fetchLoginToken( + appToken: string, + config: Pick, + auditSession?: FanavaranAuditSession, + ): Promise { + const startedAt = Date.now(); + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.LOGIN, + status: FanavaranAuditStatus.STARTED, + requestUrl: this.loginUrl, + requestMeta: { userName: config.username, cached: false }, + }); + } + + try { + const response = await firstValueFrom( + this.httpService.post(this.loginUrl, "", { + headers: { + appToken, + userName: config.username, + password: config.password, + "Content-Length": "0", + }, + transformRequest: this.emptyBodyTransformRequest(), + }), + ); + + const authenticationToken = + response.headers.authenticationtoken || + response.headers.authenticationToken || + response.headers["authenticationtoken"] || + response.headers["authenticationToken"] || + response.data?.authenticationtoken || + response.data?.authenticationToken || + response.data?.authentication_token; + + if (!authenticationToken) { + throw new BadGatewayException( + "Failed to get Fanavaran authenticationToken", + ); + } + + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.LOGIN, + status: FanavaranAuditStatus.SUCCESS, + requestUrl: this.loginUrl, + httpStatus: response.status, + responseMeta: { hasAuthenticationToken: true }, + durationMs: Date.now() - startedAt, + }); + } + + return authenticationToken; + } catch (error) { + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.LOGIN, + status: FanavaranAuditStatus.FAILURE, + requestUrl: this.loginUrl, + httpStatus: isAxiosError(error) ? error.response?.status : undefined, + errorMessage: this.fanavaranAuditService.extractErrorMessage(error), + errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), + durationMs: Date.now() - startedAt, + }); + } + throw this.toGatewayError(error, "Failed to login to external API", auditSession); + } + } + + private toGatewayError( + error: unknown, + fallback: string, + auditSession?: FanavaranAuditSession, + ): HttpException { + if (error instanceof HttpException) { + return error; + } + const message = isAxiosError(error) + ? error.response?.data?.Message || + error.response?.data?.message || + error.message || + fallback + : fallback; + return new BadGatewayException( + this.fanavaranAuditService.formatErrorWithTrackingCode( + String(message), + auditSession?.trackingCode, + ), + ); + } +} diff --git a/src/fanavaran/fanavaran-lookup.module.ts b/src/fanavaran/fanavaran-lookup.module.ts index 7033bcb..4d628bc 100644 --- a/src/fanavaran/fanavaran-lookup.module.ts +++ b/src/fanavaran/fanavaran-lookup.module.ts @@ -2,6 +2,8 @@ import { Module } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { createHttpModuleOptions } from "src/core/config/http-proxy.factory"; +import { FanavaranAuditModule } from "./fanavaran-audit.module"; +import { FanavaranAuthService } from "./fanavaran-auth.service"; import { FanavaranLookupService } from "./fanavaran-lookup.service"; @Module({ @@ -11,8 +13,9 @@ import { FanavaranLookupService } from "./fanavaran-lookup.service"; inject: [ConfigService], useFactory: createHttpModuleOptions, }), + FanavaranAuditModule, ], - providers: [FanavaranLookupService], - exports: [FanavaranLookupService], + providers: [FanavaranAuthService, FanavaranLookupService], + exports: [FanavaranAuthService, FanavaranLookupService], }) export class FanavaranLookupModule {} diff --git a/src/fanavaran/fanavaran-lookup.service.ts b/src/fanavaran/fanavaran-lookup.service.ts index b4d0588..0b073b3 100644 --- a/src/fanavaran/fanavaran-lookup.service.ts +++ b/src/fanavaran/fanavaran-lookup.service.ts @@ -9,26 +9,22 @@ import { } from "@nestjs/common"; import { firstValueFrom } from "rxjs"; import { isAxiosError } from "axios"; -import { - getFanavaranClientProfile, - type FanavaranClientKey, -} from "src/core/config/fanavaran-client.config"; +import { type FanavaranClientKey } from "src/core/config/fanavaran-client.config"; import { FANAVARAN_LOOKUP_BASE_URL, fanavaranLookupCacheDir, tejaratStaticAccidentFilePath, } from "./fanavaran-lookup.config"; +import { FanavaranAuthService } from "./fanavaran-auth.service"; @Injectable() export class FanavaranLookupService { private readonly logger = new Logger(FanavaranLookupService.name); - private readonly getAppTokenUrl = - "https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken"; - private readonly loginUrl = - "https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login"; - - constructor(private readonly httpService: HttpService) {} + constructor( + private readonly httpService: HttpService, + private readonly fanavaranAuthService: FanavaranAuthService, + ) {} private cacheFilePath(clientKey: FanavaranClientKey, fileName: string): string { return join(fanavaranLookupCacheDir(clientKey), fileName); @@ -71,91 +67,12 @@ export class FanavaranLookupService { return JSON.parse(cached) as T; } - private async getAppToken(config: { - appName: string; - secret: string; - }): Promise { - const response = await firstValueFrom( - this.httpService.post(this.getAppTokenUrl, "", { - headers: { - appname: config.appName, - secret: config.secret, - "Content-Length": "0", - }, - transformRequest: [ - (_data, headers) => { - if (headers) { - delete headers["Content-Type"]; - delete headers["content-type"]; - } - return _data; - }, - ], - }), - ); - - const appToken = - response.headers.apptoken || - response.headers.appToken || - response.headers["apptoken"] || - response.headers["appToken"]; - - if (!appToken) { - throw new BadGatewayException("Failed to get Fanavaran appToken"); - } - - return appToken; - } - - private async login( - appToken: string, - config: { username: string; password: string }, - ): Promise { - const response = await firstValueFrom( - this.httpService.post(this.loginUrl, "", { - headers: { - appToken, - userName: config.username, - password: config.password, - "Content-Length": "0", - }, - transformRequest: [ - (_data, headers) => { - if (headers) { - delete headers["Content-Type"]; - delete headers["content-type"]; - } - return _data; - }, - ], - }), - ); - - const authenticationToken = - response.headers.authenticationtoken || - response.headers.authenticationToken || - response.headers["authenticationtoken"] || - response.headers["authenticationToken"] || - response.data?.authenticationtoken || - response.data?.authenticationToken || - response.data?.authentication_token; - - if (!authenticationToken) { - throw new BadGatewayException("Failed to get Fanavaran authenticationToken"); - } - - return authenticationToken; - } - async fetchFromFanavaran( clientKey: FanavaranClientKey, url: string, ): Promise { - const profile = getFanavaranClientProfile(clientKey); - try { - const appToken = await this.getAppToken(profile.auth); - const authenticationToken = await this.login(appToken, profile.auth); + const headers = await this.fanavaranAuthService.getRequestHeaders(clientKey); this.logger.log( `[${clientKey}] Calling Fanavaran lookup API: ${url}`, @@ -164,10 +81,7 @@ export class FanavaranLookupService { const response = await firstValueFrom( this.httpService.get(url, { headers: { - authenticationToken, - CorpId: profile.auth.corpId, - ContractId: profile.auth.contractId, - Location: profile.auth.location, + ...headers, "Content-Type": "application/json", }, timeout: 20000, @@ -184,8 +98,10 @@ export class FanavaranLookupService { `[${clientKey}] Fanavaran lookup response status=${response.status} dataCount=${dataCount}`, ); + this.fanavaranAuthService.clearBackoff(clientKey); return response.data; } catch (error) { + this.fanavaranAuthService.registerFailure(clientKey, error); const message = isAxiosError(error) ? error.response?.data?.Message || error.response?.data?.message || diff --git a/src/fanavaran/fanavaran.controller.ts b/src/fanavaran/fanavaran.controller.ts index 4c4869b..99d4f4b 100644 --- a/src/fanavaran/fanavaran.controller.ts +++ b/src/fanavaran/fanavaran.controller.ts @@ -55,7 +55,7 @@ export class FanavaranController { @ApiOperation({ summary: "Preview Fanavaran base claim create payload", description: - "Builds the GEN.03 third-party-car-financial-claims payload from local claimCases + blameCases without creating a Fanavaran claim.", + "Builds the GEN.03 payload from local claim/blame data. Fanavaran-sourced PolicyId is resolve-once: first call may inquire and caches on the claim; later calls reuse the cache. Auth token is shared until Asia/Tehran midnight. Pass forceRefreshPolicy=true to re-inquire.", }) @ApiParam({ name: "client", @@ -71,16 +71,32 @@ export class FanavaranController { required: false, description: "When true, returns payload plus mapping debug steps", }) + @ApiQuery({ + name: "forceRefreshPolicy", + required: false, + description: + "When true, ignores cached PolicyId and performs a live Fanavaran policy inquiry again", + }) async preview( @Param("client") client: string, @Param("claimCaseId") claimCaseId: string, @Query("debug") debug?: string, + @Query("forceRefreshPolicy") forceRefreshPolicy?: string, + @Query("resolvePolicy") resolvePolicy?: string, ) { const clientKey = this.parseClientParam(client); return await this.claimRequestManagementService.previewFanavaranSubmitV2( claimCaseId, clientKey, - { debug: debug === "1" || debug === "true" }, + { + debug: debug === "1" || debug === "true", + forceRefreshPolicy: + forceRefreshPolicy === "1" || + forceRefreshPolicy === "true" || + resolvePolicy === "1" || + resolvePolicy === "true", + requirePolicyId: false, + }, ); } diff --git a/src/sms-orchestration/provider/parsian-template-messages.ts b/src/sms-orchestration/provider/parsian-template-messages.ts index 167d166..bb36cbf 100644 --- a/src/sms-orchestration/provider/parsian-template-messages.ts +++ b/src/sms-orchestration/provider/parsian-template-messages.ts @@ -18,6 +18,8 @@ const PARSIAN_TEMPLATE_BODIES: Record = { "لطفاً مدارک پرونده {token} ({token2}) را مجدداً ارسال کنید.\nلینک: {token3}", "yara-signature": "امضای پرونده {token} ({token2}) توسط کارشناس {token3} بررسی شد.\nلینک: {token10}", + "yara-fanavaran-claim": + "کاربر گرامی پرونده شما به شماره {token3} در فناوران با شناسه {token2} و شماره {token} ثبت شده است. جهت پیگیری های آتی پرونده خود باید از این اطلاعات استفاده کنید.", }; function applyTokens( diff --git a/src/sms-orchestration/sms-orchestration.service.ts b/src/sms-orchestration/sms-orchestration.service.ts index ae040f6..fdd1597 100644 --- a/src/sms-orchestration/sms-orchestration.service.ts +++ b/src/sms-orchestration/sms-orchestration.service.ts @@ -191,6 +191,40 @@ export class SmsOrchestrationService implements OnModuleInit { }); } + /** + * Notify the claim owner (damaged party) that the Fanavaran base claim was + * created. Uses the same SMS gateway/provider as login for this deployment. + * + * token = ClaimNo, token2 = claimId (Fanavaran Id), token3 = Yara publicId + * + * Parsian body: + * کاربر گرامی پرونده شما به شماره {publicId} در فناوران با شناسه {claimId} + * و شماره {claimNo} ثبت شده است. جهت پیگیری های آتی پرونده خود باید از این + * اطلاعات استفاده کنید. + */ + async sendFanavaranBaseClaimRegisteredNotice(params: { + receptor: string; + publicId: string; + claimNo?: string | number | null; + claimId?: string | number | null; + }): Promise { + const claimNo = + params.claimNo != null && String(params.claimNo).trim() + ? String(params.claimNo) + : "-"; + const claimId = + params.claimId != null && String(params.claimId).trim() + ? String(params.claimId) + : "-"; + return this.sendTemplate({ + template: "yara-fanavaran-claim", + receptor: params.receptor, + token: params.publicId || "-", + token2: claimId, + token3: claimNo, + }); + } + private async sendTemplate(args: TemplateArgs): Promise { try { await this.smsGatewayService.verifyLookUp(args); From b345818d4342ca587362b9ab35d62d52ffcabca8 Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Sun, 2 Aug 2026 17:00:19 +0330 Subject: [PATCH 2/2] fanavaran duplication request problems fixed. --- .../claim-request-management.service.ts | 329 ++++++++++++++++-- src/core/config/fanavaran-client.config.ts | 80 +++-- src/fanavaran/fanavaran-audit.service.ts | 205 ++++++++++- src/fanavaran/fanavaran-auth.service.spec.ts | 118 +++++-- src/fanavaran/fanavaran-auth.service.ts | 178 ++++++++-- src/fanavaran/fanavaran-lookup.module.ts | 8 + src/fanavaran/fanavaran.controller.ts | 43 ++- .../schema/fanavaran-audit-log.schema.ts | 22 ++ .../schema/fanavaran-auth-token.schema.ts | 24 ++ 9 files changed, 884 insertions(+), 123 deletions(-) create mode 100644 src/fanavaran/schema/fanavaran-auth-token.schema.ts diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 2ed849d..2f14c3e 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -4290,20 +4290,24 @@ export class ClaimRequestManagementService { const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`; const startedAt = Date.now(); + const requestMeta = { + corpId: config.corpId, + contractId: config.contractId, + location: config.location, + nationalCode: this.fanavaranAuditService.maskNationalCode( + nationalCodeOfInsurer, + ), + InsuranceLineId: 5, + }; + if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.POLICY_INQUIRY, status: FanavaranAuditStatus.STARTED, requestUrl: policyInquiryUrl, - requestMeta: { - corpId: config.corpId, - contractId: config.contractId, - location: config.location, - nationalCode: this.fanavaranAuditService.maskNationalCode( - nationalCodeOfInsurer, - ), - }, + requestMethod: "GET", + requestMeta, }); } @@ -4313,6 +4317,10 @@ export class ClaimRequestManagementService { clientKey, auditSession, ); + const requestHeaders = { + ...headers, + "Content-Type": "application/json", + }; this.logger.log( `${logPrefix} Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`, @@ -4320,10 +4328,7 @@ export class ClaimRequestManagementService { const response = await firstValueFrom( this.httpService.get(policyInquiryUrl, { - headers: { - ...headers, - "Content-Type": "application/json", - }, + headers: requestHeaders, timeout: 15000, }), ); @@ -4351,17 +4356,27 @@ export class ClaimRequestManagementService { this.fanavaranAuthService.clearBackoff(clientKey); if (auditSession) { + const exchange = this.fanavaranAuditService.captureAxiosExchange( + response, + requestHeaders, + ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.POLICY_INQUIRY, status: FanavaranAuditStatus.SUCCESS, requestUrl: policyInquiryUrl, + requestMethod: "GET", httpStatus: response.status, + requestHeaders: exchange.requestHeaders, + requestMeta, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, responseMeta: { policyId: selectedPolicy.policyId, policyEndDate: selectedPolicy.endDate, policyEndDateGregorian: selectedPolicy.endDateGregorian, policyCount, + fromCache: false, }, durationMs: Date.now() - startedAt, }); @@ -4372,12 +4387,19 @@ export class ClaimRequestManagementService { const errorMessage = error instanceof Error ? error.message : "unknown policy inquiry error"; if (auditSession) { + const exchange = + this.fanavaranAuditService.captureAxiosExchange(error); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.POLICY_INQUIRY, status: FanavaranAuditStatus.FAILURE, requestUrl: policyInquiryUrl, - httpStatus: isAxiosError(error) ? error.response?.status : undefined, + requestMethod: "GET", + httpStatus: exchange.httpStatus, + requestHeaders: exchange.requestHeaders, + requestMeta, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, errorMessage, errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, @@ -4624,7 +4646,9 @@ export class ClaimRequestManagementService { } const cachedPolicyId = - (claimCase as any)?.fanavaranSync?.baseClaim?.policyId ?? null; + (claimCase as any)?.fanavaranSync?.baseClaim?.policyId ?? + (claimCase as any)?.fanavaranSync?.baseClaim?.lastPayload?.PolicyId ?? + null; const payload = await this.buildFanavaranSubmitPayload({ accidentReason: selectedAccidentReason, @@ -4674,6 +4698,7 @@ export class ClaimRequestManagementService { if (cachedPolicyId != null && !forceRefreshPolicy) { debug.steps.policyIdFromCache = true; debug.values.policyId = Number(cachedPolicyId); + // Silent reuse — no Fanavaran HTTP and no audit noise on warm preview. return Number(cachedPolicyId); } @@ -5018,7 +5043,37 @@ export class ClaimRequestManagementService { } const profile = getFanavaranClientProfile(clientKey); - const claimCase = await this.claimCaseDbService.findById(claimCaseId); + let claimCase = await this.claimCaseDbService.findById(claimCaseId); + if (!claimCase?.claimId) { + try { + await this.ensureFanavaranBaseClaim( + claimCaseId, + clientKey, + FanavaranAuditSource.SUBMIT, + ); + claimCase = await this.claimCaseDbService.findById(claimCaseId); + } catch (error) { + const warning = this.extractFanavaranErrorMessage(error); + return { + clientKey, + claimCaseId, + totalLocalImages: candidates.length, + skippedAlreadySubmitted: candidates.length - pending.length, + attempted: 0, + submitted: 0, + failed: 0, + skipped: pending.length, + warning, + results: pending.map((c) => ({ + attempted: false, + submitted: false, + skipped: true, + skipReason: warning, + fileName: c.fileName, + })), + }; + } + } if (!claimCase?.claimId) { return { clientKey, @@ -5219,13 +5274,14 @@ export class ClaimRequestManagementService { step: FanavaranAuditStep.SUBMIT_ATTACHMENT, status: FanavaranAuditStatus.STARTED, requestUrl: url, + requestMethod: "POST", + requestBody: content, requestMeta: { claimId: claimCase.claimId, claimNo: claimCase.claimNo, dmgCaseId: claimCase.dmgCaseId, fileName, source: file.source, - content, }, }); @@ -5234,18 +5290,25 @@ export class ClaimRequestManagementService { content, [{ path: filePath, fileName }], clientKey, + auditSession, ); this.logger.log(`${logPrefix} Fanavaran response: ${JSON.stringify(response.data)}`); const fileId = response.data?.Id; + const exchange = + this.fanavaranAuditService.captureAxiosExchange(response); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_ATTACHMENT, status: FanavaranAuditStatus.SUCCESS, requestUrl: url, + requestMethod: "POST", httpStatus: response.status, + requestBody: content, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, responseMeta: { claimId: claimCase.claimId, fileId, fileName }, durationMs: Date.now() - startedAt, }); @@ -5693,6 +5756,11 @@ export class ClaimRequestManagementService { bodyOverride?: Record, ): Promise { try { + await this.ensureFanavaranDamageCase( + claimCaseId, + clientKey, + FanavaranAuditSource.SUBMIT, + ); const claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) throw new NotFoundException("Claim case not found"); let payload: Record; @@ -5728,12 +5796,30 @@ export class ClaimRequestManagementService { payload: Record, auditSource: FanavaranAuditSource, ): Promise { - const claimCase = await this.claimCaseDbService.findById(claimCaseId); + let claimCase = await this.claimCaseDbService.findById(claimCaseId); + if (!claimCase?.claimId || claimCase.dmgCaseId == null) { + await this.ensureFanavaranDamageCase( + claimCaseId, + clientKey, + auditSource, + ); + claimCase = await this.claimCaseDbService.findById(claimCaseId); + } if (!claimCase?.claimId) { throw new BadRequestException( "Fanavaran claimId is required before submitting expertise", ); } + if (claimCase.expertiseId != null) { + this.logger.log( + `[executeFanavaranExpertiseSubmit] Already have expertiseId=${claimCase.expertiseId}; skipping Fanavaran POST`, + ); + return ( + (claimCase as any)?.fanavaranSync?.expertise?.response ?? { + Id: claimCase.expertiseId, + } + ); + } const url = `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/expertise`; const startedAt = Date.now(); const auditSession: FanavaranAuditSession = { @@ -5748,19 +5834,32 @@ export class ClaimRequestManagementService { step: FanavaranAuditStep.SUBMIT_EXPERTISE, status: FanavaranAuditStatus.STARTED, requestUrl: url, - requestMeta: { claimId: claimCase.claimId, payload }, + requestMethod: "POST", + requestBody: payload, + requestMeta: { claimId: claimCase.claimId }, }); try { - const response = await this.postFanavaranJson(url, payload, clientKey); + const response = await this.postFanavaranJson( + url, + payload, + clientKey, + auditSession, + ); const expertiseId = response.data?.Id; + const exchange = + this.fanavaranAuditService.captureAxiosExchange(response); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_EXPERTISE, status: FanavaranAuditStatus.SUCCESS, requestUrl: url, + requestMethod: "POST", httpStatus: response.status, + requestBody: payload, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, responseMeta: { claimId: claimCase.claimId, expertiseId }, durationMs: Date.now() - startedAt, }); @@ -5781,12 +5880,17 @@ export class ClaimRequestManagementService { return response.data; } catch (error) { + const exchange = this.fanavaranAuditService.captureAxiosExchange(error); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_EXPERTISE, status: FanavaranAuditStatus.FAILURE, requestUrl: url, - httpStatus: isAxiosError(error) ? error.response?.status : undefined, + requestMethod: "POST", + httpStatus: exchange.httpStatus, + requestBody: payload, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, errorMessage: this.fanavaranAuditService.extractErrorMessage(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, @@ -5952,13 +6056,104 @@ export class ClaimRequestManagementService { return null; } + /** + * Soft-ensure Fanavaran base claim exists before a later stage (damage / + * attachments / expertise). Reuses claimId when present; otherwise submits + * GEN.03 once. Does not invent a fake claimId on failure. + */ + private async ensureFanavaranBaseClaim( + claimCaseId: string, + clientKey: FanavaranClientKey, + auditSource: FanavaranAuditSource, + ): Promise<{ claimId: number; claimNo?: string; created: boolean }> { + const existing = await this.claimCaseDbService.findById(claimCaseId); + if (!existing) { + throw new NotFoundException("Claim case not found"); + } + if (existing.claimId != null) { + return { + claimId: Number(existing.claimId), + claimNo: + existing.claimNo != null ? String(existing.claimNo) : undefined, + created: false, + }; + } + + this.logger.log( + `[Fanavaran ${clientKey}] Soft-ensuring base claim before next stage claimCaseId=${claimCaseId}`, + ); + await this.executeFanavaranV2Submit( + claimCaseId, + clientKey, + undefined, + auditSource, + ); + + const refreshed = await this.claimCaseDbService.findById(claimCaseId); + if (refreshed?.claimId == null) { + throw new BadRequestException( + "Fanavaran base claim is missing and could not be created before the next stage. Fix base claim first.", + ); + } + return { + claimId: Number(refreshed.claimId), + claimNo: + refreshed.claimNo != null ? String(refreshed.claimNo) : undefined, + created: true, + }; + } + + /** + * Soft-ensure damage case exists (creates base claim first if needed). + */ + private async ensureFanavaranDamageCase( + claimCaseId: string, + clientKey: FanavaranClientKey, + auditSource: FanavaranAuditSource, + selectedParts?: unknown[], + ): Promise<{ claimId: number; dmgCaseId: number; created: boolean }> { + await this.ensureFanavaranBaseClaim(claimCaseId, clientKey, auditSource); + + const existing = await this.claimCaseDbService.findById(claimCaseId); + if (existing?.dmgCaseId != null && existing.claimId != null) { + return { + claimId: Number(existing.claimId), + dmgCaseId: Number(existing.dmgCaseId), + created: false, + }; + } + + this.logger.log( + `[Fanavaran ${clientKey}] Soft-ensuring damage case before next stage claimCaseId=${claimCaseId}`, + ); + await this.executeFanavaranDamageCaseSubmit( + claimCaseId, + clientKey, + selectedParts ?? existing?.damage?.selectedParts ?? [], + auditSource, + ); + + const refreshed = await this.claimCaseDbService.findById(claimCaseId); + if (refreshed?.claimId == null || refreshed.dmgCaseId == null) { + throw new BadRequestException( + "Fanavaran damage case is missing and could not be created before the next stage.", + ); + } + return { + claimId: Number(refreshed.claimId), + dmgCaseId: Number(refreshed.dmgCaseId), + created: true, + }; + } + private async postFanavaranJson( url: string, payload: Record, clientKey: FanavaranClientKey, + auditSession?: FanavaranAuditSession, ) { this.fanavaranAuthService.assertNotInBackoff(clientKey); - const headers = await this.getFanavaranAuthHeaders(clientKey); + const headers = await this.getFanavaranAuthHeaders(clientKey, auditSession); try { const response = await firstValueFrom( @@ -5982,9 +6177,10 @@ export class ClaimRequestManagementService { content: Record, files: Array<{ path: string; fileName: string }>, clientKey: FanavaranClientKey, + auditSession?: FanavaranAuditSession, ) { this.fanavaranAuthService.assertNotInBackoff(clientKey); - const headers = await this.getFanavaranAuthHeaders(clientKey); + const headers = await this.getFanavaranAuthHeaders(clientKey, auditSession); const form = new FormData(); form.append("Param", JSON.stringify(content), { @@ -6043,13 +6239,27 @@ export class ClaimRequestManagementService { bodyOverride?: Record, ): Promise { const profile = getFanavaranClientProfile(clientKey); - const claimCase = await this.claimCaseDbService.findById(claimCaseId); + let claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) { throw new NotFoundException("Claim case not found"); } if (!claimCase.claimId) { - throw new BadRequestException( - "Fanavaran claimId is required before submitting damage case", + await this.ensureFanavaranBaseClaim(claimCaseId, clientKey, auditSource); + claimCase = await this.claimCaseDbService.findById(claimCaseId); + if (!claimCase?.claimId) { + throw new BadRequestException( + "Fanavaran claimId is required before submitting damage case", + ); + } + } + if (claimCase.dmgCaseId != null && !bodyOverride) { + this.logger.log( + `[executeFanavaranDamageCaseSubmit] Already have dmgCaseId=${claimCase.dmgCaseId}; skipping Fanavaran POST`, + ); + return ( + (claimCase as any)?.fanavaranSync?.damageCase?.response ?? { + Id: claimCase.dmgCaseId, + } ); } if (!claimCase.blameRequestId) { @@ -6092,19 +6302,32 @@ export class ClaimRequestManagementService { step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE, status: FanavaranAuditStatus.STARTED, requestUrl: url, - requestMeta: { claimId: claimCase.claimId, payload }, + requestMethod: "POST", + requestBody: payload, + requestMeta: { claimId: claimCase.claimId }, }); try { - const response = await this.postFanavaranJson(url, payload, clientKey); + const response = await this.postFanavaranJson( + url, + payload, + clientKey, + auditSession, + ); const dmgCaseId = response.data?.Id; + const exchange = + this.fanavaranAuditService.captureAxiosExchange(response); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE, status: FanavaranAuditStatus.SUCCESS, requestUrl: url, + requestMethod: "POST", httpStatus: response.status, + requestBody: payload, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, responseMeta: { dmgCaseId, claimId: claimCase.claimId }, durationMs: Date.now() - startedAt, }); @@ -6134,12 +6357,17 @@ export class ClaimRequestManagementService { this.logger.error( `[executeFanavaranDamageCaseSubmit] FAILED claimCaseId=${claimCaseId} claimId=${claimCase.claimId} payload.DriverId=${payload.DriverId ?? "NULL"} error: ${errDetail}`, ); + const exchange = this.fanavaranAuditService.captureAxiosExchange(error); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE, status: FanavaranAuditStatus.FAILURE, requestUrl: url, - httpStatus: isAxiosError(error) ? error.response?.status : undefined, + requestMethod: "POST", + httpStatus: exchange.httpStatus, + requestBody: payload, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, errorMessage: this.fanavaranAuditService.extractErrorMessage(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, @@ -6830,6 +7058,19 @@ export class ClaimRequestManagementService { `${logPrefix} Starting submission for claimCaseId: ${claimCaseId}`, ); + const existing = await this.claimCaseDbService.findById(claimCaseId); + if (existing?.claimId != null && !bodyOverride) { + this.logger.log( + `${logPrefix} Base claim already exists claimId=${existing.claimId}; skipping Fanavaran POST`, + ); + return ( + (existing as any)?.fanavaranSync?.baseClaim?.response ?? { + Id: existing.claimId, + ClaimNo: existing.claimNo, + } + ); + } + const auditSession: FanavaranAuditSession = { trackingCode: this.fanavaranAuditService.generateTrackingCode(), clientKey, @@ -6868,11 +7109,18 @@ export class ClaimRequestManagementService { auditSession, ); + const requestHeaders = { + ...headers, + "Content-Type": "application/json", + }; await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_CLAIM, status: FanavaranAuditStatus.STARTED, requestUrl: this.FANAVARAN_SUBMIT_URL, + requestMethod: "POST", + requestHeaders, + requestBody: fanavaranData, requestMeta: { policyId: fanavaranData?.PolicyId ?? null }, }); const startedAt = Date.now(); @@ -6880,10 +7128,7 @@ export class ClaimRequestManagementService { try { const response = await firstValueFrom( this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, { - headers: { - ...headers, - "Content-Type": "application/json", - }, + headers: requestHeaders, }), ); @@ -6895,12 +7140,21 @@ export class ClaimRequestManagementService { this.fanavaranAuthService.clearBackoff(clientKey); + const exchange = this.fanavaranAuditService.captureAxiosExchange( + response, + requestHeaders, + ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_CLAIM, status: FanavaranAuditStatus.SUCCESS, requestUrl: this.FANAVARAN_SUBMIT_URL, + requestMethod: "POST", httpStatus: response.status, + requestHeaders: exchange.requestHeaders, + requestBody: fanavaranData, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, responseMeta: { claimId: response.data?.Id, claimNo: response.data?.ClaimNo, @@ -6946,12 +7200,21 @@ export class ClaimRequestManagementService { return response.data; } catch (error) { this.fanavaranAuthService.registerFailure(clientKey, error); + const exchange = this.fanavaranAuditService.captureAxiosExchange( + error, + requestHeaders, + ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_CLAIM, status: FanavaranAuditStatus.FAILURE, requestUrl: this.FANAVARAN_SUBMIT_URL, - httpStatus: isAxiosError(error) ? error.response?.status : undefined, + requestMethod: "POST", + httpStatus: exchange.httpStatus, + requestHeaders: exchange.requestHeaders, + requestBody: fanavaranData, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, errorMessage: this.fanavaranAuditService.extractErrorMessage(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, diff --git a/src/core/config/fanavaran-client.config.ts b/src/core/config/fanavaran-client.config.ts index 48a4406..52f8e23 100644 --- a/src/core/config/fanavaran-client.config.ts +++ b/src/core/config/fanavaran-client.config.ts @@ -1,20 +1,26 @@ -export type FanavaranClientKey = "parsian" | "tejaratno"; +export type FanavaranClientKey = "parsian" | "tejaratno" | "moallem"; export const FANAVARAN_CLIENT_KEYS: readonly FanavaranClientKey[] = [ "parsian", "tejaratno", + "moallem", ] as const; +/** Swagger `@ApiParam({ enum })` value — keep in sync with {@link FANAVARAN_CLIENT_KEYS}. */ +export const FANAVARAN_CLIENT_SWAGGER_ENUM: FanavaranClientKey[] = [ + ...FANAVARAN_CLIENT_KEYS, +]; + export function isFanavaranClientKey( value: string, ): value is FanavaranClientKey { const normalized = value?.trim().toLowerCase(); - return normalized === "parsian" || normalized === "tejaratno"; + return (FANAVARAN_CLIENT_KEYS as readonly string[]).includes(normalized); } export function normalizeFanavaranClientKey(value: string): FanavaranClientKey { const normalized = value?.trim().toLowerCase(); - if (normalized === "parsian" || normalized === "tejaratno") { + if (isFanavaranClientKey(normalized)) { return normalized; } throw new Error( @@ -56,6 +62,29 @@ export interface FanavaranClientProfile { defaults: FanavaranPayloadDefaults; } +/** + * Shared codebook-ish defaults used when a tenant has not supplied its own + * ClaimExpertId / plaque ids yet. Moallem auth is real; ClaimExpertId may need + * a Moallem-specific value from Fanavaran lookups after first deploy. + */ +const SHARED_FANAVARAN_DEFAULTS: FanavaranPayloadDefaults = { + AccidentCityId: 701, + AccidentReportTypeId: 155, + AccidentVehicleUsedId: 1, + ClaimExpertId: 4543092, + ExpertiseClaimExpertId: 4543092, + CompensationReferenceId: 167, + CulpritLicenceTypeId: 2, + CulpritTypeId: 337, + DmgCaseTypeId: 175, + DmgHistoryStatus: 5214, + PlaqueKindId: 8, + PlaqueSampleId: 10, + DriverIsOwner: 0, + FaultPercent: 100, + ClaimFileTypeId: 23, +}; + const FANAVARAN_CLIENT_PROFILES: Record< FanavaranClientKey, FanavaranClientProfile @@ -72,20 +101,9 @@ const FANAVARAN_CLIENT_PROFILES: Record< location: "100", }, defaults: { - AccidentCityId: 701, - AccidentReportTypeId: 155, - AccidentVehicleUsedId: 1, + ...SHARED_FANAVARAN_DEFAULTS, ClaimExpertId: 4543092, ExpertiseClaimExpertId: 4543092, - CompensationReferenceId: 167, - CulpritLicenceTypeId: 2, - CulpritTypeId: 337, - DmgCaseTypeId: 175, - DmgHistoryStatus: 5214, - PlaqueKindId: 8, - PlaqueSampleId: 10, - DriverIsOwner: 0, - FaultPercent: 100, ClaimFileTypeId: 23, }, }, @@ -101,29 +119,35 @@ const FANAVARAN_CLIENT_PROFILES: Record< location: "210050", }, defaults: { - AccidentCityId: 701, - AccidentReportTypeId: 155, - AccidentVehicleUsedId: 1, + ...SHARED_FANAVARAN_DEFAULTS, ClaimExpertId: 154, ExpertiseClaimExpertId: 29, - CompensationReferenceId: 167, - CulpritLicenceTypeId: 2, - CulpritTypeId: 337, - DmgCaseTypeId: 175, - DmgHistoryStatus: 5214, - PlaqueKindId: 8, - PlaqueSampleId: 10, - DriverIsOwner: 0, - FaultPercent: 100, ClaimFileTypeId: 70, }, }, + moallem: { + key: "moallem", + auth: { + appName: "ItTalie", + secret: "itT@l!3@api", + username: "itTalieUser", + password: "itT@l!3@user", + corpId: "5650", + contractId: "304", + location: "30900", + }, + // ClaimExpertId / ClaimFileTypeId not yet confirmed for Moallem — start from + // shared Fanavaran codebook defaults and override after lookup. + defaults: { + ...SHARED_FANAVARAN_DEFAULTS, + }, + }, }; /** Resolve active Fanavaran tenant from env (`FANAVARAN_CLIENT`) with optional CLIENT_ID fallback. */ export function resolveFanavaranClientKey(): FanavaranClientKey { const explicit = process.env.FANAVARAN_CLIENT?.trim().toLowerCase(); - if (explicit === "parsian" || explicit === "tejaratno") { + if (explicit && isFanavaranClientKey(explicit)) { return explicit; } diff --git a/src/fanavaran/fanavaran-audit.service.ts b/src/fanavaran/fanavaran-audit.service.ts index 2ed314f..6117cff 100644 --- a/src/fanavaran/fanavaran-audit.service.ts +++ b/src/fanavaran/fanavaran-audit.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { InjectModel } from "@nestjs/mongoose"; -import { isAxiosError } from "axios"; +import { isAxiosError, type AxiosResponse } from "axios"; import { randomBytes } from "node:crypto"; import { Model, Types } from "mongoose"; import { @@ -16,18 +16,54 @@ export interface RecordFanavaranAuditStepInput { step: FanavaranAuditStep; status: FanavaranAuditStatus; requestUrl?: string; + requestMethod?: string; httpStatus?: number; + requestHeaders?: Record; + requestBody?: unknown; requestMeta?: Record; + responseHeaders?: Record; + responseBody?: unknown; responseMeta?: Record; errorMessage?: string; errorDetails?: Record; durationMs?: number; } +export interface FanavaranHttpExchange { + httpStatus?: number; + requestHeaders?: Record; + responseHeaders?: Record; + responseBody?: unknown; +} + @Injectable() export class FanavaranAuditService { private readonly logger = new Logger(FanavaranAuditService.name); + /** Max serialized chars kept for request/response bodies in audit docs. */ + static readonly BODY_MAX_CHARS = 80_000; + + private static readonly SENSITIVE_HEADER_KEYS = new Set([ + "password", + "secret", + "authorization", + "authenticationtoken", + "apptoken", + "app-token", + "x-api-key", + "cookie", + "set-cookie", + ]); + + private static readonly SENSITIVE_BODY_KEYS = new Set([ + "password", + "secret", + "authenticationtoken", + "apptoken", + "token", + "files", + ]); + constructor( @InjectModel(FanavaranAuditLog.name) private readonly auditModel: Model, @@ -47,6 +83,138 @@ export class FanavaranAuditService { return `${trimmed.slice(0, 3)}****${trimmed.slice(-2)}`; } + maskToken(value: string): string { + const trimmed = value.trim(); + if (trimmed.length <= 8) { + return "****"; + } + return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)} (len=${trimmed.length})`; + } + + sanitizeHeaders( + headers?: Record | null, + ): Record | undefined { + if (!headers || typeof headers !== "object") { + return undefined; + } + const out: Record = {}; + for (const [rawKey, rawValue] of Object.entries(headers)) { + if (rawValue === undefined) continue; + const key = String(rawKey); + const lower = key.toLowerCase(); + if (FanavaranAuditService.SENSITIVE_HEADER_KEYS.has(lower)) { + out[key] = + typeof rawValue === "string" + ? this.maskToken(rawValue) + : "***"; + continue; + } + if ( + typeof rawValue === "string" || + typeof rawValue === "number" || + typeof rawValue === "boolean" || + rawValue === null + ) { + out[key] = rawValue; + } else { + out[key] = String(rawValue); + } + } + return out; + } + + sanitizeBody(body: unknown): unknown { + if (body === undefined) { + return undefined; + } + const masked = this.maskSensitiveDeep(body); + try { + const serialized = JSON.stringify(masked); + if (serialized.length <= FanavaranAuditService.BODY_MAX_CHARS) { + return masked; + } + return { + _truncated: true, + maxChars: FanavaranAuditService.BODY_MAX_CHARS, + preview: serialized.slice(0, FanavaranAuditService.BODY_MAX_CHARS), + }; + } catch { + const asString = String(masked); + return asString.length <= FanavaranAuditService.BODY_MAX_CHARS + ? asString + : { + _truncated: true, + preview: asString.slice(0, FanavaranAuditService.BODY_MAX_CHARS), + }; + } + } + + /** + * Extract request/response headers + body from an Axios response or Axios + * error (uses `config.headers` when present). Values are returned raw; + * `recordStep` applies sanitisation before persistence. + */ + captureAxiosExchange( + source: unknown, + overrideRequestHeaders?: Record, + ): FanavaranHttpExchange { + const toPlainHeaders = ( + headers?: Record | null, + ): Record | undefined => { + if (!headers || typeof headers !== "object") return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue; + out[key] = + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + value === null + ? value + : String(value); + } + return out; + }; + + if (isAxiosError(source)) { + return { + httpStatus: source.response?.status, + requestHeaders: toPlainHeaders( + overrideRequestHeaders ?? + (source.config?.headers as Record | undefined), + ), + responseHeaders: toPlainHeaders( + source.response?.headers as Record | undefined, + ), + responseBody: source.response?.data, + }; + } + + const response = source as AxiosResponse | null; + if ( + response && + typeof response === "object" && + "status" in response && + "headers" in response + ) { + return { + httpStatus: response.status, + requestHeaders: toPlainHeaders( + overrideRequestHeaders ?? + (response.config?.headers as Record | undefined), + ), + responseHeaders: toPlainHeaders( + response.headers as Record | undefined, + ), + responseBody: response.data, + }; + } + + return { + requestHeaders: toPlainHeaders(overrideRequestHeaders), + }; + } + sanitizeErrorDetails(error: unknown): Record { if (isAxiosError(error)) { const data = error.response?.data; @@ -54,12 +222,13 @@ export class FanavaranAuditService { type: "axios", status: error.response?.status, statusText: error.response?.statusText, - data: + data: this.sanitizeBody( typeof data === "object" && data !== null ? data : typeof data === "string" ? data.slice(0, 2000) : data, + ), }; } if (error instanceof Error) { @@ -112,8 +281,13 @@ export class FanavaranAuditService { ? { claimRequestId: new Types.ObjectId(input.session.claimRequestId) } : {}), requestUrl: input.requestUrl, + requestMethod: input.requestMethod, httpStatus: input.httpStatus, + requestHeaders: this.sanitizeHeaders(input.requestHeaders), + requestBody: this.sanitizeBody(input.requestBody), requestMeta: input.requestMeta, + responseHeaders: this.sanitizeHeaders(input.responseHeaders), + responseBody: this.sanitizeBody(input.responseBody), responseMeta: input.responseMeta, errorMessage: input.errorMessage, errorDetails: input.errorDetails, @@ -134,4 +308,31 @@ export class FanavaranAuditService { .lean() .exec(); } + + private maskSensitiveDeep(value: unknown, depth = 0): unknown { + if (depth > 8) { + return "[max-depth]"; + } + if (value === null || value === undefined) { + return value; + } + if (Array.isArray(value)) { + return value.map((item) => this.maskSensitiveDeep(item, depth + 1)); + } + if (typeof value === "object") { + const out: Record = {}; + for (const [key, child] of Object.entries( + value as Record, + )) { + if (FanavaranAuditService.SENSITIVE_BODY_KEYS.has(key.toLowerCase())) { + out[key] = + typeof child === "string" ? this.maskToken(child) : "[redacted]"; + continue; + } + out[key] = this.maskSensitiveDeep(child, depth + 1); + } + return out; + } + return value; + } } diff --git a/src/fanavaran/fanavaran-auth.service.spec.ts b/src/fanavaran/fanavaran-auth.service.spec.ts index 3af85ea..2191a69 100644 --- a/src/fanavaran/fanavaran-auth.service.spec.ts +++ b/src/fanavaran/fanavaran-auth.service.spec.ts @@ -1,6 +1,43 @@ import { FanavaranAuthService } from "./fanavaran-auth.service"; describe("FanavaranAuthService", () => { + const createAuthTokenModel = () => { + const store = new Map< + string, + { clientKey: string; authenticationToken: string; expiresAt: Date } + >(); + return { + findOne: jest.fn((query: { clientKey: string }) => ({ + lean: () => ({ + exec: async () => store.get(query.clientKey) ?? null, + }), + })), + findOneAndUpdate: jest.fn( + ( + query: { clientKey: string }, + update: { $set: { authenticationToken: string; expiresAt: Date } }, + ) => ({ + exec: async () => { + const next = { + clientKey: query.clientKey, + authenticationToken: update.$set.authenticationToken, + expiresAt: update.$set.expiresAt, + }; + store.set(query.clientKey, next); + return next; + }, + }), + ), + deleteOne: jest.fn((query: { clientKey: string }) => ({ + exec: async () => { + store.delete(query.clientKey); + return { deletedCount: 1 }; + }, + })), + _store: store, + }; + }; + it("detects Fanavaran transient try-later messages", () => { expect( FanavaranAuthService.isTransientTryLaterError( @@ -24,22 +61,11 @@ describe("FanavaranAuthService", () => { e instanceof Error ? e.message : String(e), sanitizeErrorDetails: () => ({}), formatErrorWithTrackingCode: (m: string) => m, + captureAxiosExchange: () => ({}), }; + const authTokenModel = createAuthTokenModel(); let loginCalls = 0; - http.post.mockImplementation((url: string) => { - if (url.includes("GetAppToken")) { - return { - toPromise: undefined, - pipe: undefined, - subscribe: undefined, - // firstValueFrom uses Observable — mock as Observable-like via rxjs - }; - } - return {}; - }); - - // Use real firstValueFrom path by mocking httpService.post to return an Observable const { of, delay } = await import("rxjs"); http.post.mockImplementation((url: string) => { if (url.includes("GetAppToken")) { @@ -57,7 +83,11 @@ describe("FanavaranAuthService", () => { }).pipe(delay(20)); }); - const service = new FanavaranAuthService(http as any, audit as any); + const service = new FanavaranAuthService( + http as any, + audit as any, + authTokenModel as any, + ); const [a, b, c] = await Promise.all([ service.getAuthenticationToken("parsian"), @@ -75,28 +105,74 @@ describe("FanavaranAuthService", () => { expect(loginCalls).toBe(1); }); + it("reuses persisted token across service instances", async () => { + const http = { post: jest.fn() }; + const audit = { + recordStep: jest.fn().mockResolvedValue(undefined), + extractErrorMessage: (e: unknown) => + e instanceof Error ? e.message : String(e), + sanitizeErrorDetails: () => ({}), + formatErrorWithTrackingCode: (m: string) => m, + captureAxiosExchange: () => ({}), + }; + const authTokenModel = createAuthTokenModel(); + const { of } = await import("rxjs"); + let loginCalls = 0; + http.post.mockImplementation((url: string) => { + if (url.includes("GetAppToken")) { + return of({ + status: 200, + headers: { apptoken: "app-1" }, + data: {}, + }); + } + loginCalls += 1; + return of({ + status: 200, + headers: { authenticationtoken: "auth-persisted" }, + data: {}, + }); + }); + + const first = new FanavaranAuthService( + http as any, + audit as any, + authTokenModel as any, + ); + await first.getAuthenticationToken("tejaratno"); + expect(loginCalls).toBe(1); + + const second = new FanavaranAuthService( + http as any, + audit as any, + authTokenModel as any, + ); + const token = await second.getAuthenticationToken("tejaratno"); + expect(token).toBe("auth-persisted"); + expect(loginCalls).toBe(1); + }); + it("enters tenant backoff on try-later errors", () => { - const service = new FanavaranAuthService({} as any, {} as any); + const service = new FanavaranAuthService( + {} as any, + {} as any, + createAuthTokenModel() as any, + ); service.registerFailure( "parsian", "کد پیگیری خطا: 1\r\n.لطفا پس از چند لحظه مجدد تلاش فرمایید.", ); expect(service.isInBackoff("parsian")).toBe(true); - expect(() => service.assertNotInBackoff("parsian")).toThrow( - /backoff/i, - ); + expect(() => service.assertNotInBackoff("parsian")).toThrow(/backoff/i); }); it("computes next Asia/Tehran midnight expiry after now", () => { - // 2026-08-02 10:00:00 UTC ≈ 13:30 Tehran (UTC+3:30) → same calendar day midnight const now = Date.parse("2026-08-02T10:00:00.000Z"); const expiry = FanavaranAuthService.getNextMidnightExpiryMs(now); expect(expiry).toBeGreaterThan(now); - // Must land within ~14h (before next Tehran midnight) expect(expiry - now).toBeLessThanOrEqual(24 * 60 * 60 * 1000); expect(expiry - now).toBeGreaterThan(0); - // Just after Tehran midnight: 2026-08-01 20:30:01 UTC = 2026-08-02 00:00:01 Tehran const justAfterMidnight = Date.parse("2026-08-01T20:30:01.000Z"); const next = FanavaranAuthService.getNextMidnightExpiryMs(justAfterMidnight); expect(next - justAfterMidnight).toBeGreaterThan(23 * 60 * 60 * 1000); diff --git a/src/fanavaran/fanavaran-auth.service.ts b/src/fanavaran/fanavaran-auth.service.ts index 0ac88ec..cac9a15 100644 --- a/src/fanavaran/fanavaran-auth.service.ts +++ b/src/fanavaran/fanavaran-auth.service.ts @@ -6,7 +6,9 @@ import { Logger, ServiceUnavailableException, } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; import { isAxiosError } from "axios"; +import { Model } from "mongoose"; import { firstValueFrom } from "rxjs"; import { getFanavaranClientProfile, @@ -19,6 +21,10 @@ import { FanavaranAuditStatus, FanavaranAuditStep, } from "./schema/fanavaran-audit-log.schema"; +import { + FanavaranAuthToken, + FanavaranAuthTokenDocument, +} from "./schema/fanavaran-auth-token.schema"; interface CachedFanavaranAuth { authenticationToken: string; @@ -63,6 +69,8 @@ export class FanavaranAuthService { constructor( private readonly httpService: HttpService, private readonly fanavaranAuditService: FanavaranAuditService, + @InjectModel(FanavaranAuthToken.name) + private readonly authTokenModel: Model, ) {} /** True when Fanavaran asked us to wait (Persian “try again later” / tracking-code 500). */ @@ -162,6 +170,12 @@ export class FanavaranAuthService { invalidateToken(clientKey: FanavaranClientKey): void { this.tokenCache.delete(clientKey); + void this.authTokenModel.deleteOne({ clientKey }).exec().catch((error) => { + this.logger.warn( + `[${clientKey}] Failed to clear persisted Fanavaran auth token`, + error, + ); + }); } assertNotInBackoff(clientKey: FanavaranClientKey): void { @@ -183,9 +197,13 @@ export class FanavaranAuthService { this.assertNotInBackoff(clientKey); if (!options?.forceRefresh) { - const cached = this.tokenCache.get(clientKey); - if (cached && cached.expiresAt > Date.now()) { - return cached.authenticationToken; + const memoryHit = this.readMemoryCache(clientKey); + if (memoryHit) { + return memoryHit; + } + const persisted = await this.readPersistedCache(clientKey); + if (persisted) { + return persisted; } } else { this.invalidateToken(clientKey); @@ -243,10 +261,7 @@ export class FanavaranAuthService { ); const expiresAt = FanavaranAuthService.getNextMidnightExpiryMs(); - this.tokenCache.set(clientKey, { - authenticationToken, - expiresAt, - }); + await this.persistToken(clientKey, authenticationToken, expiresAt); this.clearBackoff(clientKey); this.logger.log( `[${clientKey}] Cached Fanavaran authenticationToken until ${new Date( @@ -256,6 +271,76 @@ export class FanavaranAuthService { return authenticationToken; } + private readMemoryCache(clientKey: FanavaranClientKey): string | null { + const cached = this.tokenCache.get(clientKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.authenticationToken; + } + if (cached) { + this.tokenCache.delete(clientKey); + } + return null; + } + + private async readPersistedCache( + clientKey: FanavaranClientKey, + ): Promise { + try { + const doc = await this.authTokenModel.findOne({ clientKey }).lean().exec(); + if (!doc?.authenticationToken || !doc.expiresAt) { + return null; + } + const expiresAt = new Date(doc.expiresAt).getTime(); + if (!(expiresAt > Date.now())) { + await this.authTokenModel.deleteOne({ clientKey }).exec(); + return null; + } + this.tokenCache.set(clientKey, { + authenticationToken: doc.authenticationToken, + expiresAt, + }); + this.logger.log( + `[${clientKey}] Reused persisted Fanavaran authenticationToken until ${new Date( + expiresAt, + ).toISOString()}`, + ); + return doc.authenticationToken; + } catch (error) { + this.logger.warn( + `[${clientKey}] Failed to read persisted Fanavaran auth token`, + error, + ); + return null; + } + } + + private async persistToken( + clientKey: FanavaranClientKey, + authenticationToken: string, + expiresAt: number, + ): Promise { + this.tokenCache.set(clientKey, { authenticationToken, expiresAt }); + try { + await this.authTokenModel + .findOneAndUpdate( + { clientKey }, + { + $set: { + authenticationToken, + expiresAt: new Date(expiresAt), + }, + }, + { upsert: true, new: true }, + ) + .exec(); + } catch (error) { + this.logger.warn( + `[${clientKey}] Failed to persist Fanavaran auth token (memory cache still active)`, + error, + ); + } + } + private emptyBodyTransformRequest() { return [ (_data: unknown, headers?: Record) => { @@ -273,12 +358,20 @@ export class FanavaranAuthService { auditSession?: FanavaranAuditSession, ): Promise { const startedAt = Date.now(); + const requestHeaders = { + appname: config.appName, + secret: config.secret, + "Content-Length": "0", + }; if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.GET_APP_TOKEN, status: FanavaranAuditStatus.STARTED, requestUrl: this.getAppTokenUrl, + requestMethod: "POST", + requestHeaders, + requestBody: "", requestMeta: { appName: config.appName, cached: false }, }); } @@ -286,11 +379,7 @@ export class FanavaranAuthService { try { const response = await firstValueFrom( this.httpService.post(this.getAppTokenUrl, "", { - headers: { - appname: config.appName, - secret: config.secret, - "Content-Length": "0", - }, + headers: requestHeaders, transformRequest: this.emptyBodyTransformRequest(), }), ); @@ -306,13 +395,23 @@ export class FanavaranAuthService { } if (auditSession) { + const exchange = + this.fanavaranAuditService.captureAxiosExchange( + response, + requestHeaders, + ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.GET_APP_TOKEN, status: FanavaranAuditStatus.SUCCESS, requestUrl: this.getAppTokenUrl, + requestMethod: "POST", httpStatus: response.status, - responseMeta: { hasAppToken: true }, + requestHeaders: exchange.requestHeaders, + requestBody: "", + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, + responseMeta: { hasAppToken: true, cached: false }, durationMs: Date.now() - startedAt, }); } @@ -320,12 +419,21 @@ export class FanavaranAuthService { return appToken; } catch (error) { if (auditSession) { + const exchange = this.fanavaranAuditService.captureAxiosExchange( + error, + requestHeaders, + ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.GET_APP_TOKEN, status: FanavaranAuditStatus.FAILURE, requestUrl: this.getAppTokenUrl, - httpStatus: isAxiosError(error) ? error.response?.status : undefined, + requestMethod: "POST", + httpStatus: exchange.httpStatus, + requestHeaders: exchange.requestHeaders, + requestBody: "", + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, errorMessage: this.fanavaranAuditService.extractErrorMessage(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, @@ -341,12 +449,21 @@ export class FanavaranAuthService { auditSession?: FanavaranAuditSession, ): Promise { const startedAt = Date.now(); + const requestHeaders = { + appToken, + userName: config.username, + password: config.password, + "Content-Length": "0", + }; if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.LOGIN, status: FanavaranAuditStatus.STARTED, requestUrl: this.loginUrl, + requestMethod: "POST", + requestHeaders, + requestBody: "", requestMeta: { userName: config.username, cached: false }, }); } @@ -354,12 +471,7 @@ export class FanavaranAuthService { try { const response = await firstValueFrom( this.httpService.post(this.loginUrl, "", { - headers: { - appToken, - userName: config.username, - password: config.password, - "Content-Length": "0", - }, + headers: requestHeaders, transformRequest: this.emptyBodyTransformRequest(), }), ); @@ -380,13 +492,26 @@ export class FanavaranAuthService { } if (auditSession) { + const exchange = + this.fanavaranAuditService.captureAxiosExchange( + response, + requestHeaders, + ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.LOGIN, status: FanavaranAuditStatus.SUCCESS, requestUrl: this.loginUrl, + requestMethod: "POST", httpStatus: response.status, - responseMeta: { hasAuthenticationToken: true }, + requestHeaders: exchange.requestHeaders, + requestBody: "", + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, + responseMeta: { + hasAuthenticationToken: true, + cached: false, + }, durationMs: Date.now() - startedAt, }); } @@ -394,12 +519,21 @@ export class FanavaranAuthService { return authenticationToken; } catch (error) { if (auditSession) { + const exchange = this.fanavaranAuditService.captureAxiosExchange( + error, + requestHeaders, + ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.LOGIN, status: FanavaranAuditStatus.FAILURE, requestUrl: this.loginUrl, - httpStatus: isAxiosError(error) ? error.response?.status : undefined, + requestMethod: "POST", + httpStatus: exchange.httpStatus, + requestHeaders: exchange.requestHeaders, + requestBody: "", + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, errorMessage: this.fanavaranAuditService.extractErrorMessage(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, diff --git a/src/fanavaran/fanavaran-lookup.module.ts b/src/fanavaran/fanavaran-lookup.module.ts index 4d628bc..bc1d645 100644 --- a/src/fanavaran/fanavaran-lookup.module.ts +++ b/src/fanavaran/fanavaran-lookup.module.ts @@ -1,10 +1,15 @@ import { Module } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; import { ConfigModule, ConfigService } from "@nestjs/config"; +import { MongooseModule } from "@nestjs/mongoose"; import { createHttpModuleOptions } from "src/core/config/http-proxy.factory"; import { FanavaranAuditModule } from "./fanavaran-audit.module"; import { FanavaranAuthService } from "./fanavaran-auth.service"; import { FanavaranLookupService } from "./fanavaran-lookup.service"; +import { + FanavaranAuthToken, + FanavaranAuthTokenSchema, +} from "./schema/fanavaran-auth-token.schema"; @Module({ imports: [ @@ -13,6 +18,9 @@ import { FanavaranLookupService } from "./fanavaran-lookup.service"; inject: [ConfigService], useFactory: createHttpModuleOptions, }), + MongooseModule.forFeature([ + { name: FanavaranAuthToken.name, schema: FanavaranAuthTokenSchema }, + ]), FanavaranAuditModule, ], providers: [FanavaranAuthService, FanavaranLookupService], diff --git a/src/fanavaran/fanavaran.controller.ts b/src/fanavaran/fanavaran.controller.ts index 99d4f4b..de58a13 100644 --- a/src/fanavaran/fanavaran.controller.ts +++ b/src/fanavaran/fanavaran.controller.ts @@ -18,6 +18,8 @@ import { import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service"; import { + FANAVARAN_CLIENT_KEYS, + FANAVARAN_CLIENT_SWAGGER_ENUM, isFanavaranClientKey, listFanavaranClientProfiles, normalizeFanavaranClientKey, @@ -37,7 +39,7 @@ export class FanavaranController { @ApiOperation({ summary: "List supported Fanavaran insurance clients", description: - "Returns configured Fanavaran tenants (parsian, tejaratno) and which client is active for this deployment.", + "Returns configured Fanavaran tenants (parsian, tejaratno, moallem) and which client is active for this deployment.", }) listClients() { const activeClient = resolveFanavaranClientKey(); @@ -60,7 +62,7 @@ export class FanavaranController { @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -75,26 +77,33 @@ export class FanavaranController { name: "forceRefreshPolicy", required: false, description: - "When true, ignores cached PolicyId and performs a live Fanavaran policy inquiry again", + "When true, ignores cached PolicyId and performs a live Fanavaran policy inquiry again. Do not pass this from normal UI loads.", + }) + @ApiQuery({ + name: "resolvePolicy", + required: false, + deprecated: true, + description: + "Deprecated. Ignored for cache-busting. PolicyId is resolve-once from fanavaranSync.baseClaim.policyId; use forceRefreshPolicy=true only to re-inquire.", }) async preview( @Param("client") client: string, @Param("claimCaseId") claimCaseId: string, @Query("debug") debug?: string, @Query("forceRefreshPolicy") forceRefreshPolicy?: string, - @Query("resolvePolicy") resolvePolicy?: string, + @Query("resolvePolicy") _resolvePolicy?: string, ) { const clientKey = this.parseClientParam(client); + // IMPORTANT: resolvePolicy must NOT force a live inquiry. Older UI clients + // send resolvePolicy=true on every preview load; that used to defeat the + // PolicyId cache and re-Login Fanavaran on every click. return await this.claimRequestManagementService.previewFanavaranSubmitV2( claimCaseId, clientKey, { debug: debug === "1" || debug === "true", forceRefreshPolicy: - forceRefreshPolicy === "1" || - forceRefreshPolicy === "true" || - resolvePolicy === "1" || - resolvePolicy === "true", + forceRefreshPolicy === "1" || forceRefreshPolicy === "true", requirePolicyId: false, }, ); @@ -109,7 +118,7 @@ export class FanavaranController { @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -137,7 +146,7 @@ export class FanavaranController { @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -158,12 +167,12 @@ export class FanavaranController { @ApiOperation({ summary: "Submit Fanavaran damage-case request", description: - "Submits the GEN.12 dmg-cases request for the already-created Fanavaran claim and stores returned Id as local dmgCaseId.", + "Submits the GEN.12 dmg-cases request. If base claim (claimId) is missing, soft-ensures GEN.03 base claim first, then submits damage. Skips when dmgCaseId already exists.", }) @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -191,7 +200,7 @@ export class FanavaranController { @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -217,7 +226,7 @@ export class FanavaranController { @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -243,7 +252,7 @@ export class FanavaranController { @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -269,7 +278,7 @@ export class FanavaranController { @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -291,7 +300,7 @@ export class FanavaranController { private parseClientParam(client: string) { if (!isFanavaranClientKey(client)) { throw new BadRequestException( - `Invalid Fanavaran client "${client}". Expected one of: parsian, tejaratno`, + `Invalid Fanavaran client "${client}". Expected one of: ${FANAVARAN_CLIENT_KEYS.join(", ")}`, ); } return normalizeFanavaranClientKey(client); diff --git a/src/fanavaran/schema/fanavaran-audit-log.schema.ts b/src/fanavaran/schema/fanavaran-audit-log.schema.ts index 84c8fa1..c718881 100644 --- a/src/fanavaran/schema/fanavaran-audit-log.schema.ts +++ b/src/fanavaran/schema/fanavaran-audit-log.schema.ts @@ -62,12 +62,33 @@ export class FanavaranAuditLog { @Prop({ type: String, required: false }) requestUrl?: string; + @Prop({ type: String, required: false }) + requestMethod?: string; + @Prop({ type: Number, required: false }) httpStatus?: number; + /** Sanitized outbound headers (secrets masked). */ + @Prop({ type: Object, required: false }) + requestHeaders?: Record; + + /** Outbound JSON/body (truncated; secrets masked). */ + @Prop({ type: Object, required: false }) + requestBody?: unknown; + + /** Compact structured facts (ids, flags) — kept for filtering/dashboards. */ @Prop({ type: Object, required: false }) requestMeta?: Record; + /** Sanitized inbound response headers. */ + @Prop({ type: Object, required: false }) + responseHeaders?: Record; + + /** Inbound response body (truncated). */ + @Prop({ type: Object, required: false }) + responseBody?: unknown; + + /** Compact structured facts from the response. */ @Prop({ type: Object, required: false }) responseMeta?: Record; @@ -86,3 +107,4 @@ export const FanavaranAuditLogSchema = SchemaFactory.createForClass(FanavaranAuditLog); FanavaranAuditLogSchema.index({ trackingCode: 1, createdAt: 1 }); +FanavaranAuditLogSchema.index({ claimCaseId: 1, createdAt: 1 }); diff --git a/src/fanavaran/schema/fanavaran-auth-token.schema.ts b/src/fanavaran/schema/fanavaran-auth-token.schema.ts new file mode 100644 index 0000000..0ef93ba --- /dev/null +++ b/src/fanavaran/schema/fanavaran-auth-token.schema.ts @@ -0,0 +1,24 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { HydratedDocument } from "mongoose"; +import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config"; + +/** + * Shared Fanavaran authenticationToken per tenant. + * Survives process restarts / multi-instance so we do not Login on every request. + */ +@Schema({ collection: "fanavaranAuthTokens", timestamps: true }) +export class FanavaranAuthToken { + @Prop({ type: String, required: true, unique: true, index: true }) + clientKey: FanavaranClientKey; + + @Prop({ type: String, required: true }) + authenticationToken: string; + + /** When this token should be refreshed (Asia/Tehran midnight). */ + @Prop({ type: Date, required: true, index: true }) + expiresAt: Date; +} + +export type FanavaranAuthTokenDocument = HydratedDocument; +export const FanavaranAuthTokenSchema = + SchemaFactory.createForClass(FanavaranAuthToken);