From 8f66502c496eaa3bcdbe07f56ee8183e7dd70154 Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Sun, 2 Aug 2026 11:33:24 +0330 Subject: [PATCH] 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);