From 1ef17ce3370358d113cbc78624ddac0a65233ecf Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Wed, 17 Jun 2026 16:57:21 +0330 Subject: [PATCH 1/2] fanavaran parsian done --- .../claim-request-management.service.ts | 732 ++++++++++++++++-- .../claim-request-management.v2.controller.ts | 40 + src/constants/repair-amount-limits.ts | 24 + src/core/config/config.schema.ts | 22 + src/expert-claim/expert-claim.service.ts | 117 ++- 5 files changed, 838 insertions(+), 97 deletions(-) diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 89e32d3..f384348 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -145,6 +145,17 @@ import { HttpService } from "@nestjs/axios"; import { firstValueFrom } from "rxjs"; import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher"; +export interface FanavaranAutoSubmitResult { + attempted: boolean; + submitted: boolean; + skipped?: boolean; + skipReason?: string; + warning?: string; + claimNo?: number; + claimId?: number; + fanavaranResponse?: unknown; +} + @Injectable() export class ClaimRequestManagementService { private readonly logger = new Logger(ClaimRequestManagementService.name); @@ -168,6 +179,41 @@ export class ClaimRequestManagementService { private readonly CONTRACT_ID = "263"; private readonly LOCATION = "100"; + private readonly PARSIAN_FANAVARAN_CONFIG = { + appName: "ParsianService", + secret: "P@r30@n$erv!ce", + username: "ParsianServiceUser", + password: "P@r30@n123", + corpId: "543", + contractId: "28", + location: "210050", + } as const; + + /** Tejaratno hardcoded Fanavaran lookup defaults (legacy). */ + private readonly TEJARATNO_FANAVARAN_DEFAULTS = { + AccidentCityId: 701, + AccidentReportTypeId: 155, + AccidentVehicleUsedId: 1, + ClaimExpertId: 1589, + CompensationReferenceId: 167, + CulpritLicenceTypeId: 2, + CulpritTypeId: 337, + } as const; + + /** Parsian-validated Fanavaran lookup defaults (verified via direct submit). */ + private readonly PARSIAN_FANAVARAN_DEFAULTS = { + AccidentCityId: 701, + AccidentReportTypeId: 155, + AccidentVehicleUsedId: 1, + ClaimExpertId: 154, + CompensationReferenceId: 167, + CulpritLicenceTypeId: 2, + CulpritTypeId: 337, + } as const; + + private readonly MAP_IR_API_KEY = + "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2In0.eyJhdWQiOiIyMTcxOCIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2IiwiaWF0IjoxNjgwNjA4NTkxLCJuYmYiOjE2ODA2MDg1OTEsImV4cCI6MTY4MzIwMDU5MSwic3ViIjoiIiwic2NvcGVzIjpbImJhc2ljIl19.rTviLd8b5yTHUDa3ODZyva593eMnL0d3XPg3sKkZxMOf_jNIH6lFQyIfbId-wsd1EAdsOdsL3CME_Y8t332PWJbxMNgnEq4Rf2IkClkvkSx6Sb5_4bmlhBM75zw2SmccvgbFUn4xkTOw0FT4vABC2Y3-MKctjMpmO8QOrVULSKt4psrmQhr7hBu7YRDnAAEc6muZ1VpRvdB1kqNKddoSIrfDaq6aDRJ-BNbGRAaFFvP_kH4cgSCKV4dU0TknL3mRKUiVy6_TDkjtzAN8fE2wsdvNo2pGTJPzKFsR2ipgGNTvB__g3bOnVpKsgFXPBH0e_Qa7ff1tZ3VGWy3jRNh9Lg"; + constructor( private readonly blameDbService: RequestManagementDbService, private readonly claimDbService: ClaimRequestManagementDbService, @@ -3164,15 +3210,13 @@ export class ClaimRequestManagementService { lon: number, ): Promise { try { - const apiKey = - "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2In0.eyJhdWQiOiIyMTcxOCIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2IiwiaWF0IjoxNjgwNjA4NTkxLCJuYmYiOjE2ODA2MDg1OTEsImV4cCI6MTY4MzIwMDU5MSwic3ViIjoiIiwic2NvcGVzIjpbImJhc2ljIl19.rTviLd8b5yTHUDa3ODZyva593eMnL0d3XPg3sKkZxMOf_jNIH6lFQyIfbId-wsd1EAdsOdsL3CME_Y8t332PWJbxMNgnEq4Rf2IkClkvkSx6Sb5_4bmlhBM75zw2SmccvgbFUn4xkTOw0FT4vABC2Y3-MKctjMpmO8QOrVULSKt4psrmQhr7hBu7YRDnAAEc6muZ1VpRvdB1kqNKddoSIrfDaq6aDRJ-BNbGRAaFFvP_kH4cgSCKV4dU0TknL3mRKUiVy6_TDkjtzAN8fE2wsdvNo2pGTJPzKFsR2ipgGNTvB__g3bOnVpKsgFXPBH0e_Qa7ff1tZ3VGWy3jRNh9Lg"; const response = await firstValueFrom( this.httpService.get( `https://map.ir/fast-reverse?lat=${lat}&lon=${lon}`, { headers: { accept: "application/json", - "x-api-key": apiKey, + "x-api-key": this.MAP_IR_API_KEY, }, }, ), @@ -3220,6 +3264,140 @@ export class ClaimRequestManagementService { return ids; } + private getTejaratnoFanavaranConfig() { + return { + appName: this.APP_NAME, + secret: this.SECRET, + username: this.USERNAME, + password: this.PASSWORD, + corpId: this.CORP_ID, + contractId: this.CONTRACT_ID, + location: this.LOCATION, + }; + } + + private resolveAccidentCauseId(accidentReason?: { + id?: string | number | null; + fanavaran?: number | null; + }): number | null { + const fanavaranValue = accidentReason?.fanavaran; + const idValue = accidentReason?.id; + + if (fanavaranValue !== undefined && fanavaranValue !== null) { + if (fanavaranValue === 0) { + return idValue !== undefined && idValue !== null ? Number(idValue) : null; + } + return fanavaranValue; + } + + return null; + } + + private applyFanavaranDefaultFields(result: Record): void { + result.AccidentCulpritId = null; + result.CouponNo = null; + result.CourtArchiveNo = null; + result.CulpritLicenceCityId = null; + result.CulpritLicenceCountryId = null; + result.CulpritLicenceForeignCityName = null; + result.CulpritLicenceIssuDate = "1394/10/13"; + result.CulpritLicenceNo = "1124242"; + result.DamagedCount = 1; + result.DmgAssessorFirstCreationTime = null; + result.EntryDate = null; + result.IsLicenseMatchWithVehicleKind = 1; + result.HasOtherCulprit = 0; + result.IsAccidentOutOfBorder = 0; + result.IsFatalAccident = 0; + result.IsPlaqueChanged = 0; + result.PlaqueCityId = null; + result.PlaqueKindId = null; + result.PlaqueLeftNo = null; + result.PlaqueMiddleCodeId = null; + result.PlaqueNo = null; + result.PlaqueRightNo = null; + result.PlaqueSampleId = null; + result.PlaqueSerial = null; + result.PoliceOfficerId = 1; + result.PoliceReportDesc = null; + result.PoliceReportSeri = null; + result.PoliceReportSerial = null; + result.PolicyId = null; + result.PreviousPolicyEndDate = ""; + result.TrackingCode = null; + result.IsLicenseReplacement = 0; + result.UnknownCulpritCauseId = null; + } + + private async buildFanavaranSubmitPayload(input: { + accidentReason?: { id?: string | number | null; fanavaran?: number | null }; + createdAt?: Date | string; + location?: { lat?: number; lon?: number }; + damageParts?: any[]; + resolvePolicyId: () => Promise; + logPrefix: string; + defaults?: { + AccidentCityId: number; + AccidentReportTypeId: number; + AccidentVehicleUsedId: number; + ClaimExpertId: number; + CompensationReferenceId: number; + CulpritLicenceTypeId: number; + CulpritTypeId: number; + }; + }): Promise> { + const lookupDefaults = + input.defaults ?? this.TEJARATNO_FANAVARAN_DEFAULTS; + const result: Record = { + ...lookupDefaults, + AccidentCauseId: this.resolveAccidentCauseId(input.accidentReason), + }; + + if (input.accidentReason && result.AccidentCauseId === null) { + this.logger.warn( + `${input.logPrefix} accidentReason exists but fanavaran is null or undefined`, + ); + } else if (!input.accidentReason) { + this.logger.error( + `${input.logPrefix} expert decision / accidentReason is missing`, + ); + } + + if (input.createdAt) { + result.AccidentDate = this.convertToPersianDate(input.createdAt); + result.AnnouncementDate = result.AccidentDate; + result.DocReceivedDate = result.AccidentDate; + result.AccidentTime = this.getTime24Hour(input.createdAt); + } + + if (input.location?.lat && input.location?.lon) { + result.AccidentLocationAddress = await this.getAddressFromCoordinates( + input.location.lat, + input.location.lon, + ); + } + + result.EstimateAmount = input.damageParts + ? this.calculateEstimateAmount(input.damageParts) + : 0; + + this.applyFanavaranDefaultFields(result); + + try { + const policyId = await input.resolvePolicyId(); + if (policyId !== undefined && policyId !== null) { + result.PolicyId = policyId; + } + } catch (error) { + this.logger.error( + `${input.logPrefix} Failed to get PolicyId from external API:`, + error, + ); + } + + return result; + } + /** * Fanavaran Submit - Map data from claim-request-management and request-management to third-party-car-financial-claims format */ @@ -3438,11 +3616,16 @@ export class ClaimRequestManagementService { /** * Step 1: Get appToken from GetAppToken API */ - private async getAppToken(): Promise { + private async getAppToken( + config: { + appName: string; + secret: string; + } = this.getTejaratnoFanavaranConfig(), + ): Promise { try { const requestHeaders: any = { - appname: this.APP_NAME, - secret: this.SECRET, + appname: config.appName, + secret: config.secret, "Content-Length": "0", }; delete requestHeaders["Content-Type"]; @@ -3494,12 +3677,18 @@ export class ClaimRequestManagementService { /** * Step 2: Login to get authenticationToken */ - private async login(appToken: string): Promise { + private async login( + appToken: string, + config: { + username: string; + password: string; + } = this.getTejaratnoFanavaranConfig(), + ): Promise { try { const requestHeaders: any = { appToken: appToken, - userName: this.USERNAME, - password: this.PASSWORD, + userName: config.username, + password: config.password, "Content-Length": "0", }; delete requestHeaders["Content-Type"]; @@ -3553,6 +3742,65 @@ export class ClaimRequestManagementService { } } + private async getPolicyIdFromNationalCode( + nationalCodeOfInsurer: string, + config: { + appName: string; + secret: string; + username: string; + password: string; + corpId: string; + contractId: string; + location: string; + }, + logPrefix: string, + ): Promise { + try { + const appToken = await this.getAppToken(config); + const authenticationToken = await this.login(appToken, config); + + const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`; + + this.logger.log( + `${logPrefix} Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`, + ); + + const response = await firstValueFrom( + this.httpService.get(policyInquiryUrl, { + headers: { + authenticationToken: authenticationToken, + CorpId: config.corpId, + ContractId: config.contractId, + Location: config.location, + "Content-Type": "application/json", + }, + timeout: 15000, + }), + ); + + if (Array.isArray(response.data) && response.data.length > 0) { + const lastPolicy = response.data[response.data.length - 1]; + const policyId = lastPolicy?.PolicyId; + if (policyId !== undefined && policyId !== null) { + this.logger.log( + `${logPrefix} Successfully retrieved PolicyId from last policy entry: ${policyId}`, + ); + return policyId; + } + } + + this.logger.warn(`${logPrefix} No PolicyId found in API response`); + return null; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "unknown policy inquiry error"; + this.logger.warn( + `${logPrefix} Policy inquiry failed; continuing with empty PolicyId: ${errorMessage}`, + ); + return null; + } + } + /** * Get PolicyId from external API */ @@ -3600,63 +3848,422 @@ export class ClaimRequestManagementService { return null; } - // Get authenticationToken - const appToken = await this.getAppToken(); - const authenticationToken = await this.login(appToken); - - // Call external API to get PolicyId - const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`; - - this.logger.log( - `[Fanavaran Submit] Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`, + return await this.getPolicyIdFromNationalCode( + nationalCodeOfInsurer, + this.getTejaratnoFanavaranConfig(), + `[Fanavaran Submit] claimRequestId=${claimRequestId}`, ); - - const response = await firstValueFrom( - this.httpService.get(policyInquiryUrl, { - headers: { - authenticationToken: authenticationToken, - CorpId: this.CORP_ID, - ContractId: this.CONTRACT_ID, - Location: this.LOCATION, - "Content-Type": "application/json", - }, - }), - ); - - if ( - Array.isArray(response.data) && - response.data.length > 0 && - response.data[0].PolicyId - ) { - const policyId = response.data[0].PolicyId; - this.logger.log( - `[Fanavaran Submit] Successfully retrieved PolicyId: ${policyId}`, - ); - return policyId; - } else { - this.logger.warn( - `[Fanavaran Submit] No PolicyId found in API response`, - ); - return null; - } } catch (error) { this.logger.error( `[Fanavaran Submit] Error getting PolicyId from external API:`, error, ); - if (isAxiosError(error)) { - const errorMessage = - error.response?.data?.Message || - error.response?.data?.message || - error.response?.data || - error.message || - "Failed to get PolicyId from external API"; - this.logger.error(`[Fanavaran Submit] Error message: ${errorMessage}`); + return null; + } + } + + private getNationalCodeOfInsurerForGuiltyPartyV2( + parties: Array<{ + role?: PartyRole; + person?: { userId?: Types.ObjectId; nationalCodeOfInsurer?: string }; + statement?: { admitsGuilt?: boolean }; + }>, + guiltyPartyId?: Types.ObjectId | string | null, + ): string | null { + if (!guiltyPartyId) { + return null; + } + + const guiltyParty = parties.find( + (party) => party.person?.userId?.toString() === guiltyPartyId.toString(), + ); + + return guiltyParty?.person?.nationalCodeOfInsurer ?? null; + } + + private resolveGuiltyPartyIdV2( + parties: Array<{ + role?: PartyRole; + person?: { userId?: Types.ObjectId }; + statement?: { admitsGuilt?: boolean }; + }>, + decisionGuiltyPartyId?: Types.ObjectId | string | null, + ): Types.ObjectId | string | null { + if (decisionGuiltyPartyId) { + return decisionGuiltyPartyId; + } + + const admitsGuiltParty = parties.find( + (party) => party.statement?.admitsGuilt && party.person?.userId, + ); + if (admitsGuiltParty?.person?.userId) { + return admitsGuiltParty.person.userId; + } + + // Legacy-compatible safety fallback: when no explicit guilty marker exists, + // use FIRST party if available (same heuristic used by older flows). + const firstParty = parties.find( + (party) => party.role === PartyRole.FIRST && party.person?.userId, + ); + return firstParty?.person?.userId ?? null; + } + + /** + * Fanavaran Submit (Parsian / V2) - Map data from claimCases + blameCases + */ + async fanavaranSubmitParsianV2( + claimCaseId: string, + options?: { debug?: boolean }, + ): Promise { + try { + const debug = { + claimCaseId, + steps: { + claimCaseFound: false, + blameRequestLinked: false, + blameCaseFound: false, + expertDecisionFound: false, + accidentReasonFound: false, + firstPartyFound: false, + firstPartyLocationFound: false, + damageReplyFound: false, + guiltyPartyIdFound: false, + nationalCodeOfInsurerFound: false, + policyInquiryAttempted: false, + policyInquirySucceeded: false, + accidentReasonFallbackFromSnapshot: false, + guiltyPartyFallbackUsed: false, + }, + values: { + blameRequestId: null as string | null, + guiltyPartyId: null as string | null, + nationalCodeOfInsurer: null as string | null, + policyId: null as number | null, + estimateAmount: null as number | null, + }, + failureReason: null as string | null, + }; + + const claimCase = await this.claimCaseDbService.findById(claimCaseId); + if (!claimCase) { + throw new NotFoundException("Claim case not found"); } + debug.steps.claimCaseFound = true; + + if (!claimCase.blameRequestId) { + debug.failureReason = "claimCase.blameRequestId is missing"; + throw new BadRequestException("Blame case not linked to claim case"); + } + debug.steps.blameRequestLinked = true; + debug.values.blameRequestId = claimCase.blameRequestId.toString(); + + const blameCase = await this.blameRequestDbService.findById( + claimCase.blameRequestId, + ); + if (!blameCase) { + throw new NotFoundException("Blame case not found"); + } + debug.steps.blameCaseFound = true; + + const expertDecision = blameCase.expert?.decision; + debug.steps.expertDecisionFound = !!expertDecision; + const fallbackAccidentReason = claimCase.snapshot?.accident?.classification?.accidentReason; + if (!expertDecision?.fields?.accidentReason && fallbackAccidentReason) { + debug.steps.accidentReasonFallbackFromSnapshot = true; + } + const selectedAccidentReason = + expertDecision?.fields?.accidentReason ?? fallbackAccidentReason; + debug.steps.accidentReasonFound = !!selectedAccidentReason; + + const firstParty = blameCase.parties?.find( + (party) => party.role === PartyRole.FIRST, + ); + debug.steps.firstPartyFound = !!firstParty; + debug.steps.firstPartyLocationFound = !!firstParty?.location; + const activeExpertReply = getActiveV2ExpertReply( + claimCase as unknown as { evaluation?: Record }, + ); + const damageParts = activeExpertReply?.parts; + debug.steps.damageReplyFound = !!damageParts?.length; + if (options?.debug) { + debug.values.estimateAmount = damageParts + ? this.calculateEstimateAmount(damageParts) + : 0; + } + + const payload = await this.buildFanavaranSubmitPayload({ + accidentReason: selectedAccidentReason, + createdAt: (blameCase as { createdAt?: Date }).createdAt, + location: firstParty?.location, + damageParts, + defaults: this.PARSIAN_FANAVARAN_DEFAULTS, + logPrefix: `[Fanavaran Parsian V2] claimCaseId=${claimCaseId}`, + resolvePolicyId: async () => { + const guiltyPartyId = this.resolveGuiltyPartyIdV2( + blameCase.parties ?? [], + expertDecision?.guiltyPartyId, + ); + if (!expertDecision?.guiltyPartyId && guiltyPartyId) { + debug.steps.guiltyPartyFallbackUsed = true; + } + debug.steps.guiltyPartyIdFound = !!guiltyPartyId; + debug.values.guiltyPartyId = guiltyPartyId + ? guiltyPartyId.toString() + : null; + const nationalCodeOfInsurer = this.getNationalCodeOfInsurerForGuiltyPartyV2( + blameCase.parties ?? [], + guiltyPartyId, + ); + debug.steps.nationalCodeOfInsurerFound = !!nationalCodeOfInsurer; + debug.values.nationalCodeOfInsurer = nationalCodeOfInsurer; + + if (!guiltyPartyId) { + this.logger.warn( + `[Fanavaran Parsian V2] guiltyPartyId not found in expert.decision`, + ); + debug.failureReason = "expert.decision.guiltyPartyId is missing"; + return null; + } + + if (!nationalCodeOfInsurer) { + this.logger.warn( + `[Fanavaran Parsian V2] nationalCodeOfInsurer not found for guiltyPartyId: ${guiltyPartyId}`, + ); + debug.failureReason = + "nationalCodeOfInsurer not found for guilty party in blameCase.parties"; + return null; + } + + debug.steps.policyInquiryAttempted = true; + const policyId = await this.getPolicyIdFromNationalCode( + nationalCodeOfInsurer, + this.PARSIAN_FANAVARAN_CONFIG, + `[Fanavaran Parsian V2] claimCaseId=${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)"; + } + return policyId; + }, + }); + + if (options?.debug) { + return { + payload, + debug, + }; + } + + return payload; + } catch (error) { + this.logger.error("Error in fanavaranSubmitParsianV2", error); throw error; } } + /** + * Submit Parsian fanavaran data (V2 collections) to external API + */ + async submitToFanavaranParsianV2(claimCaseId: string): Promise { + try { + return await this.executeFanavaranParsianV2Submit(claimCaseId); + } catch (error) { + this.logger.error( + `[Fanavaran Parsian V2] Error submitting to Fanavaran`, + error, + ); + throw new BadGatewayException(this.extractFanavaranErrorMessage(error)); + } + } + + /** + * Auto-submit to Fanavaran when a claim case reaches COMPLETED. + * Never throws — failures are returned as warnings so the main flow continues. + */ + async autoSubmitToFanavaranParsianV2OnClaimCompleted( + claimCaseId: string, + ): Promise { + const logPrefix = `[Fanavaran Parsian V2 Auto] claimCaseId=${claimCaseId}`; + + try { + const claimCase = await this.claimCaseDbService.findById(claimCaseId); + if (!claimCase) { + return { + attempted: false, + submitted: false, + warning: + "Claim case not found for Fanavaran auto-submit. Retry manually when available.", + }; + } + + if (claimCase.claimId != null || claimCase.claimNo != null) { + return { + attempted: false, + submitted: false, + skipped: true, + skipReason: "Already submitted to Fanavaran", + claimId: claimCase.claimId, + claimNo: claimCase.claimNo, + }; + } + + const fanavaranResponse = await this.executeFanavaranParsianV2Submit( + claimCaseId, + ); + + await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { + $push: { + history: { + type: "FANAVARAN_AUTO_SUBMIT_SUCCEEDED", + actor: { actorType: "system" }, + timestamp: new Date(), + metadata: { + claimNo: fanavaranResponse?.ClaimNo, + claimId: fanavaranResponse?.Id, + }, + }, + }, + }); + + return { + attempted: true, + submitted: true, + claimNo: fanavaranResponse?.ClaimNo, + claimId: fanavaranResponse?.Id, + fanavaranResponse, + }; + } catch (error) { + const warning = this.extractFanavaranErrorMessage(error); + this.logger.warn(`${logPrefix} Auto-submit failed: ${warning}`); + + try { + await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { + $push: { + history: { + type: "FANAVARAN_AUTO_SUBMIT_FAILED", + actor: { actorType: "system" }, + timestamp: new Date(), + metadata: { error: warning }, + }, + }, + }); + } catch (historyError) { + this.logger.error( + `${logPrefix} Failed to record Fanavaran auto-submit failure history`, + historyError, + ); + } + + return { + attempted: true, + submitted: false, + warning: `${warning} Case was not sent to Fanavaran. Retry manually via POST /v2/claim-request-management/fanavaran-submit/parsian/${claimCaseId}.`, + }; + } + } + + private extractFanavaranErrorMessage(error: unknown): string { + if (isAxiosError(error)) { + return ( + error.response?.data?.message || + error.response?.data?.Message || + (typeof error.response?.data === "string" + ? error.response.data + : undefined) || + error.message || + "Failed to submit data to Fanavaran API" + ); + } + if (error instanceof HttpException) { + const response = error.getResponse(); + if (typeof response === "string") return response; + if (response && typeof response === "object" && "message" in response) { + const message = (response as { message?: string | string[] }).message; + return Array.isArray(message) ? message.join(", ") : String(message); + } + } + if (error instanceof Error) { + return error.message; + } + return "Failed to submit data to Fanavaran API"; + } + + private async executeFanavaranParsianV2Submit( + claimCaseId: string, + ): Promise { + const config = this.PARSIAN_FANAVARAN_CONFIG; + const logPrefix = `[Fanavaran Parsian V2]`; + + this.logger.log( + `${logPrefix} Starting submission for claimCaseId: ${claimCaseId}`, + ); + const fanavaranData = await this.fanavaranSubmitParsianV2(claimCaseId); + this.logger.log( + `${logPrefix} Mapped data prepared:`, + JSON.stringify(fanavaranData, null, 2), + ); + + if ( + fanavaranData.AccidentCauseId === undefined || + fanavaranData.AccidentCauseId === null + ) { + this.logger.error( + `${logPrefix} CRITICAL: AccidentCauseId is missing or null!`, + ); + } else { + this.logger.log( + `${logPrefix} AccidentCauseId validated: ${fanavaranData.AccidentCauseId}`, + ); + } + + const appToken = await this.getAppToken(config); + const authenticationToken = await this.login(appToken, config); + + const response = await firstValueFrom( + this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, { + headers: { + authenticationToken: authenticationToken, + CorpId: config.corpId, + ContractId: config.contractId, + Location: config.location, + "Content-Type": "application/json", + }, + }), + ); + + this.logger.log(`${logPrefix} API Response Status: ${response.status}`); + this.logger.log( + `${logPrefix} API Response Data:`, + JSON.stringify(response.data, null, 2), + ); + + if (response.data) { + const claimNo = response.data.ClaimNo; + const claimId = response.data.Id; + + if (claimNo !== undefined || claimId !== undefined) { + const updateData: any = { $set: {} as Record }; + if (claimNo !== undefined) { + updateData.$set.claimNo = claimNo; + } + if (claimId !== undefined) { + updateData.$set.claimId = claimId; + } + + await this.claimCaseDbService.findByIdAndUpdate( + claimCaseId, + updateData, + ); + } + } + + return response.data; + } + /** * Submit fanavaran data to external API */ @@ -6241,6 +6848,7 @@ export class ClaimRequestManagementService { currentStep?: ClaimWorkflowStep; accepted: boolean; phase?: "PRICED_PARTS_FOR_FACTORS" | "FINAL_APPROVAL"; + fanavaran?: FanavaranAutoSubmitResult; }> { if (!Types.ObjectId.isValid(claimRequestId)) { throw new BadRequestException("Invalid claim request id"); @@ -6493,14 +7101,26 @@ export class ClaimRequestManagementService { }, }); + const fanavaran = + await this.autoSubmitToFanavaranParsianV2OnClaimCompleted(claimRequestId); + + let message = "Your signature has been recorded. The claim is completed."; + if (fanavaran.submitted) { + message += + " The claim was sent to Fanavaran successfully."; + } else if (fanavaran.warning) { + message += ` ${fanavaran.warning}`; + } + return { - message: "Your signature has been recorded. The claim is completed.", + message, claimRequestId, status: ClaimCaseStatus.COMPLETED, claimStatus: ClaimStatus.APPROVED, currentStep: ClaimWorkflowStep.CLAIM_COMPLETED, accepted: true, phase: "FINAL_APPROVAL", + fanavaran, }; } diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts index c1edcf8..70fcbd5 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -1029,4 +1029,44 @@ Returns status of each item (uploaded/captured or not). ); } } + + @Get("fanavaran-submit/parsian/:claimCaseId") + @ApiOperation({ + summary: "Preview Fanavaran submit body (Parsian / V2)", + description: + "Builds the third-party-car-financial-claims payload from claimCases + blameCases without calling Fanavaran.", + }) + @ApiParam({ + name: "claimCaseId", + description: "The claim case ID (MongoDB ObjectId)", + }) + async fanavaranSubmitParsianV2( + @Param("claimCaseId") claimCaseId: string, + @Query("debug") debug?: string, + ) { + return await this.claimRequestManagementService.fanavaranSubmitParsianV2( + claimCaseId, + { + debug: debug === "1" || debug === "true", + }, + ); + } + + @Post("fanavaran-submit/parsian/:claimCaseId") + @ApiOperation({ + summary: "Submit claim to Fanavaran (Parsian / V2)", + description: + "Authenticates with Parsian Fanavaran credentials and submits the mapped claimCases + blameCases payload.", + }) + @ApiParam({ + name: "claimCaseId", + description: "The claim case ID (MongoDB ObjectId)", + }) + async submitToFanavaranParsianV2( + @Param("claimCaseId") claimCaseId: string, + ) { + return await this.claimRequestManagementService.submitToFanavaranParsianV2( + claimCaseId, + ); + } } diff --git a/src/constants/repair-amount-limits.ts b/src/constants/repair-amount-limits.ts index 9166cc8..76b4a8c 100644 --- a/src/constants/repair-amount-limits.ts +++ b/src/constants/repair-amount-limits.ts @@ -10,3 +10,27 @@ export const REPAIR_LINE_AMOUNT_TOMAN = { /** Max sum of all priced + factor lines in one expert reply / validation (Toman). */ export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX; + +const ENABLED_VALUES = new Set(["1", "true", "yes", "on", "enabled"]); + +/** + * Returns null when the claim v2 total cap is disabled. + * + * Set CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=true to enforce the cap again. + * Optionally set CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN to override the amount. + */ +export function getClaimV2TotalPaymentCapToman(): number | null { + const capEnabled = ENABLED_VALUES.has( + String(process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED ?? "") + .trim() + .toLowerCase(), + ); + + if (!capEnabled) return null; + + const configuredCap = Number(process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN); + + return Number.isFinite(configuredCap) && configuredCap > 0 + ? configuredCap + : CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN; +} diff --git a/src/core/config/config.schema.ts b/src/core/config/config.schema.ts index 3f414d5..e0c6804 100644 --- a/src/core/config/config.schema.ts +++ b/src/core/config/config.schema.ts @@ -84,4 +84,26 @@ export class EnvironmentVariables { @IsOptional() HASH_PEPPER?: string; // --------------------------------------------------------- // + + @IsOptional() + @IsString({ message: "CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED must be string" }) + CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED?: string; + + // --------------------------------------------------------- // + + @IsOptional() + @IsNumber( + { + allowNaN: false, + allowInfinity: false, + }, + { + message: "CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN must be a valid number", + }, + ) + @IsPositive({ + message: "CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN must be a positive number", + }) + CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN?: number; + // --------------------------------------------------------- // } diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 594a80c..139076f 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -16,6 +16,10 @@ import { distance as stringDistance } from "fastest-levenshtein"; // حتما ن import { Types } from "mongoose"; import { lastValueFrom } from "rxjs"; import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service"; +import { + ClaimRequestManagementService, + FanavaranAutoSubmitResult, +} from "src/claim-request-management/claim-request-management.service"; import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service"; import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service"; import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service"; @@ -135,14 +139,12 @@ import { } from "src/users/entities/schema/expert-file-activity.schema"; /** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */ -import { CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN } from "src/constants/repair-amount-limits"; +import { getClaimV2TotalPaymentCapToman } from "src/constants/repair-amount-limits"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { applyListQueryV2 } from "src/helpers/list-query-v2"; import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher"; import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys"; -const CLAIM_V2_TOTAL_PAYMENT_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN; - @Injectable() export class ExpertClaimService { private readonly logger = new Logger(ExpertClaimService.name); @@ -268,8 +270,22 @@ export class ExpertClaimService { private readonly userDbService: UserDbService, private readonly expertFileActivityDbService: ExpertFileActivityDbService, private readonly claimSignDbService: ClaimSignDbService, + private readonly claimRequestManagementService: ClaimRequestManagementService, ) {} + private appendFanavaranAutoSubmitToMessage( + baseMessage: string, + fanavaran: FanavaranAutoSubmitResult, + ): string { + if (fanavaran.submitted) { + return `${baseMessage} The claim was sent to Fanavaran successfully.`; + } + if (fanavaran.warning) { + return `${baseMessage} ${fanavaran.warning}`; + } + return baseMessage; + } + /** * Resolve a `claim-sign` document id to a downloadable file URL. * Mirrors the helper used in `ExpertInsurerService` so signature links are @@ -1539,9 +1555,9 @@ export class ExpertClaimService { ); } - // Validate total price cap (priced lines sum) - if (reply.parts && reply.parts.length > 0) { - const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP; + // Validate total price cap (priced lines sum), when enabled. + const priceCap = getClaimV2TotalPaymentCapToman(); + if (priceCap !== null && reply.parts && reply.parts.length > 0) { let totalPrice = 0; for (const part of reply.parts) { @@ -1570,13 +1586,13 @@ export class ExpertClaimService { } } - if (totalPrice > PRICE_CAP) { + if (totalPrice > priceCap) { throw new BadRequestException({ - message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`, + message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`, error: "PRICE_CAP_ERROR", code: "PRICE_CAP_ERROR", totalPrice: totalPrice, - priceCap: PRICE_CAP, + priceCap, }); } } @@ -2138,7 +2154,7 @@ export class ExpertClaimService { * Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION. * — All approved → COMPLETED + APPROVED (expert-entered line totals; no extra owner signature). * — Any rejected (repriced) → COMPLETED + APPROVED (auto-close for now; owner sign may be added later). - * Total of all repair lines must be ≤ `CLAIM_V2_TOTAL_PAYMENT_CAP` Toman (53_000_000; same as initial expert reply). + * When enabled by env, total of all repair lines must be ≤ the claim v2 total payment cap. * Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`. */ async validateClaimFactorsV2( @@ -2273,25 +2289,27 @@ export class ExpertClaimService { }; } - const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP; - let totalPrice = 0; - for (const part of updatedReply.parts || []) { - const line = this.claimReplyPartLineTotalForCap(part); - if (isNaN(line)) { + const priceCap = getClaimV2TotalPaymentCapToman(); + if (priceCap !== null) { + let totalPrice = 0; + for (const part of updatedReply.parts || []) { + const line = this.claimReplyPartLineTotalForCap(part); + if (isNaN(line)) { + throw new BadRequestException({ + message: `Part ${String(part.partId ?? "")}: invalid amount for price total — use totalPayment or price and salary.`, + error: "PRICE_CAP_ERROR", + }); + } + totalPrice += line; + } + if (totalPrice > priceCap) { throw new BadRequestException({ - message: `Part ${String(part.partId ?? "")}: invalid amount for price total — use totalPayment or price and salary.`, + message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`, error: "PRICE_CAP_ERROR", + totalPrice, + priceCap, }); } - totalPrice += line; - } - if (totalPrice > PRICE_CAP) { - throw new BadRequestException({ - message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`, - error: "PRICE_CAP_ERROR", - totalPrice, - priceCap: PRICE_CAP, - }); } const historyActor = { @@ -2309,13 +2327,20 @@ export class ExpertClaimService { historyActor, { replyField }, ); + const fanavaran = + await this.claimRequestManagementService.autoSubmitToFanavaranParsianV2OnClaimCompleted( + claimRequestId, + ); return { - message: + message: this.appendFanavaranAutoSubmitToMessage( "Factors were reviewed with expert repricing on rejected lines. The claim is completed without an owner signature (temporary policy; may require owner acceptance later).", + fanavaran, + ), claimRequestId, claimStatus: ClaimStatus.APPROVED, caseStatus: ClaimCaseStatus.COMPLETED, outcome: "REJECTED_REPRICED_AUTO_COMPLETED", + fanavaran, }; } @@ -2328,13 +2353,21 @@ export class ExpertClaimService { { replyField }, ); + const fanavaran = + await this.claimRequestManagementService.autoSubmitToFanavaranParsianV2OnClaimCompleted( + claimRequestId, + ); + return { - message: + message: this.appendFanavaranAutoSubmitToMessage( "All factors were approved by the expert. The claim is completed without an additional owner signature.", + fanavaran, + ), claimRequestId, claimStatus: ClaimStatus.APPROVED, caseStatus: ClaimCaseStatus.COMPLETED, outcome: "ALL_APPROVED_AUTO_COMPLETED", + fanavaran, }; } @@ -2878,20 +2911,22 @@ export class ExpertClaimService { throw new ForbiddenException("This claim is locked by another expert"); } - // Price cap validation - const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP; - let totalPrice = 0; - for (const part of reply.parts || []) { - const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0")); - if (!isNaN(parsed)) totalPrice += parsed; - } - if (totalPrice > PRICE_CAP) { - throw new BadRequestException({ - message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`, - error: "PRICE_CAP_ERROR", - totalPrice, - priceCap: PRICE_CAP, - }); + // Price cap validation, when enabled. + const priceCap = getClaimV2TotalPaymentCapToman(); + if (priceCap !== null) { + let totalPrice = 0; + for (const part of reply.parts || []) { + const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0")); + if (!isNaN(parsed)) totalPrice += parsed; + } + if (totalPrice > priceCap) { + throw new BadRequestException({ + message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`, + error: "PRICE_CAP_ERROR", + totalPrice, + priceCap, + }); + } } const carTypeSubmit = claim.vehicle?.carType as From fa188862e5dc63988475b658e6d368f445538565 Mon Sep 17 00:00:00 2001 From: Sepehr Yahyaee Date: Thu, 18 Jun 2026 13:31:56 +0330 Subject: [PATCH 2/2] Fixed Lock for Field expert + user view of files --- package-lock.json | 191 ++++-------------- .../claim-request-management.service.ts | 40 +++- src/expert-blame/expert-blame.service.ts | 75 ++++++- .../expert-blame.v2.controller.ts | 2 +- src/expert-claim/expert-claim.service.ts | 104 +++++++--- src/helpers/iran-mobile.ts | 25 +++ src/helpers/tenant-scope.ts | 14 ++ .../request-management.service.ts | 111 +++++----- 8 files changed, 328 insertions(+), 234 deletions(-) diff --git a/package-lock.json b/package-lock.json index b7abe52..6915688 100644 --- a/package-lock.json +++ b/package-lock.json @@ -179,24 +179,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -204,22 +186,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@angular-devkit/schematics-cli/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -275,24 +241,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@angular-devkit/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -300,22 +248,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@angular-devkit/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -1075,9 +1007,9 @@ "license": "BSD-3-Clause" }, "node_modules/@hapi/tlds": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", - "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", + "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", "license": "BSD-3-Clause", "engines": { "node": ">=14.0.0" @@ -2175,6 +2107,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2192,6 +2127,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2209,6 +2147,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2226,6 +2167,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2243,6 +2187,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2905,24 +2852,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@nestjs/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/schematics/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -2930,22 +2859,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@nestjs/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -3340,6 +3253,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3357,6 +3273,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3374,6 +3293,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3391,6 +3313,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5794,24 +5719,6 @@ "dev": true, "license": "MIT" }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -7254,16 +7161,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -7711,9 +7618,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -8810,9 +8717,9 @@ } }, "node_modules/joi": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", - "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.2.tgz", + "integrity": "sha512-STrV933NPLPME2nfPo+lFIfgCff9T/vhObpoRtFb/vNlLQynrUJPBCS3OePJ4Lunu/Egw9lqtNms72xbPbxrxw==", "license": "BSD-3-Clause", "dependencies": { "@hapi/address": "^5.1.1", @@ -10396,22 +10303,6 @@ "node": ">= 6" } }, - "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 4237a1f..2a94cd5 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -59,6 +59,7 @@ import { } from "./dto/my-claims-v2.dto"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { applyListQueryV2 } from "src/helpers/list-query-v2"; +import { partyPersonMatchesUser } from "src/helpers/iran-mobile"; import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; @@ -4786,6 +4787,26 @@ export class ClaimRequestManagementService { return; } + if ( + (!actor?.role || actor.role === RoleEnum.USER) && + claim.blameRequestId + ) { + const blame = await this.blameRequestDbService.findById( + claim.blameRequestId.toString(), + ); + const isParty = + Array.isArray(blame?.parties) && + blame.parties.some((p: any) => + partyPersonMatchesUser(p?.person, { + sub: currentUserId, + username: (actor as { username?: string })?.username, + }), + ); + if (isParty) { + return; + } + } + if (actor?.role === RoleEnum.FIELD_EXPERT) { if (claim.initiatedByFieldExpertId?.toString() === currentUserId) { return; @@ -7223,8 +7244,25 @@ export class ClaimRequestManagementService { { lean: true }, ); } else { + const partyBlameIds = await this.blameRequestDbService + .find( + { + $or: [ + { "parties.person.userId": new Types.ObjectId(currentUserId) }, + ], + }, + { select: "_id", lean: true }, + ) + .then((docs) => docs.map((d) => (d as any)._id)); claims = await this.claimCaseDbService.find( - { "owner.userId": new Types.ObjectId(currentUserId) }, + { + $or: [ + { "owner.userId": new Types.ObjectId(currentUserId) }, + ...(partyBlameIds.length + ? [{ blameRequestId: { $in: partyBlameIds } }] + : []), + ], + }, { lean: true }, ); } diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 77d8730..6541d88 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -11,6 +11,7 @@ import { import { assertBlameCaseForExpertTenant, blameCaseAccessibleToExpert, + blameCaseInitiatedByFieldExpert, requireActorClientKey, } from "src/helpers/tenant-scope"; import { @@ -436,8 +437,9 @@ export class ExpertBlameService { } /** - * Blame review inbox for FIELD_EXPERT — expert-initiated DISAGREEMENT files only. - * IN_PERSON flows are AGREED and handled outside this panel; completed blames appear in expert-claim. + * Blame review inbox for FIELD_EXPERT — expert-initiated DISAGREEMENT files. + * Covers LINK flows (parties complete via link → WAITING_FOR_EXPERT) and in-progress + * disputes while parties are still filling. IN_PERSON AGREED blames use expert-claim instead. */ private async getFieldExpertBlameListV2( actor: { sub: string }, @@ -448,9 +450,25 @@ export class ExpertBlameService { const visibleCases = (await this.blameRequestDbService.find( { - blameStatus: BlameStatus.DISAGREEMENT, expertInitiated: true, initiatedByFieldExpertId: expertOid, + blameStatus: BlameStatus.DISAGREEMENT, + type: { $ne: BlameRequestType.CAR_BODY }, + $or: [ + { status: CaseStatus.WAITING_FOR_EXPERT }, + { + status: { + $in: [ + CaseStatus.OPEN, + CaseStatus.WAITING_FOR_SECOND_PARTY, + CaseStatus.WAITING_FOR_DOCUMENT_RESEND, + ], + }, + }, + { "workflow.lockedBy.actorId": expertOid }, + { "workflow.assignedForReviewBy.actorId": expertOid }, + { "expert.decision.decidedByExpertId": expertOid }, + ], }, { lean: true }, )) as Record[]; @@ -966,6 +984,8 @@ export class ExpertBlameService { ); } + const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(doc, actor); + const decision = (doc.expert as any)?.decision; const decidedByExpertId = decision?.decidedByExpertId ? String(decision.decidedByExpertId) @@ -978,6 +998,7 @@ export class ExpertBlameService { (doc.workflow as any)?.assignedForReviewBy?.actorId ?? "", ); + if (!isFieldExpertOwner) { // Access gates — must satisfy at least one bucket const isAvailable = doc.status === CaseStatus.WAITING_FOR_EXPERT && !decidedByExpertId; @@ -1002,6 +1023,7 @@ export class ExpertBlameService { "You do not have permission to view this request.", ); } + } // Build evidence URLs const parties = Array.isArray(doc.parties) ? doc.parties : []; @@ -1158,7 +1180,12 @@ export class ExpertBlameService { */ async assignBlameCaseForReviewV2( requestId: string, - actor: { sub: string; fullName?: string; clientKey?: string }, + actor: { + sub: string; + fullName?: string; + clientKey?: string; + role?: string; + }, ): Promise { await this.expireBlameCaseWorkflowLockV2IfStale(requestId); @@ -1169,6 +1196,8 @@ export class ExpertBlameService { assertBlameCaseForExpertTenant(request, actor); + const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(request, actor); + if (request.type === BlameRequestType.CAR_BODY) { throw new BadRequestException({ success: false, @@ -1177,9 +1206,17 @@ export class ExpertBlameService { }); } + if (request.blameStatus !== BlameStatus.DISAGREEMENT) { + throw new BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: "Request is not available for expert review", + }); + } + if ( - request.status !== CaseStatus.WAITING_FOR_EXPERT || - request.blameStatus !== BlameStatus.DISAGREEMENT + !isFieldExpertOwner && + request.status !== CaseStatus.WAITING_FOR_EXPERT ) { throw new BadRequestException({ success: false, @@ -1188,6 +1225,26 @@ export class ExpertBlameService { }); } + if (isFieldExpertOwner && request.expert?.decision) { + throw new BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: "This request already has an expert decision", + }); + } + + if ( + isFieldExpertOwner && + request.status !== CaseStatus.WAITING_FOR_EXPERT + ) { + throw new BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: + "Parties are still completing this file. Lock and review become available when status is WAITING_FOR_EXPERT (e.g. after both parties finish via link).", + }); + } + if ( request.expertInitiated && request.initiatedByFieldExpertId && @@ -1272,7 +1329,6 @@ export class ExpertBlameService { const assignFilter: Record = { _id: new Types.ObjectId(requestId), - status: CaseStatus.WAITING_FOR_EXPERT, blameStatus: BlameStatus.DISAGREEMENT, $and: [ { @@ -1293,8 +1349,11 @@ export class ExpertBlameService { ], }; - if (request.expertInitiated) { + if (isFieldExpertOwner) { assignFilter.initiatedByFieldExpertId = expertOid; + assignFilter.status = CaseStatus.WAITING_FOR_EXPERT; + } else { + assignFilter.status = CaseStatus.WAITING_FOR_EXPERT; } const updated = await this.blameRequestDbService.findOneAndUpdate( diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index 1fc6911..864c689 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -43,7 +43,7 @@ export class ExpertBlameV2Controller { summary: "List blame cases for expert review (V2)", description: "Damage experts (`expert`): tenant-scoped **DISAGREEMENT** queue (available, locked, or decided by you). " + - "Field experts (`field_expert`): only **DISAGREEMENT** files they initiated that need expert review (e.g. LINK disputes). " + + "Field experts (`field_expert`): expert-initiated **DISAGREEMENT** files — including **LINK** flows where parties complete via link and the case moves to `WAITING_FOR_EXPERT` for your review. " + "IN_PERSON expert-initiated blames are usually `AGREED` and are managed via expert-initiated / request-management APIs; after completion, use expert-claim. " + "Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`.", }) diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 139076f..cfe986f 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -2437,12 +2437,35 @@ export class ExpertClaimService { assertClaimCaseForExpertActor(claim, actor); + const isFieldExpertOwner = + (actor as any).role === RoleEnum.FIELD_EXPERT && + claimCaseInitiatedByFieldExpert(claim, actor); const isFactorValidationLock = claimIsAwaitingExpertFactorValidationV2(claim); const isDamageReviewLock = claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; + const isExpertReviewingReentry = + claim.status === ClaimCaseStatus.EXPERT_REVIEWING; + const isResendPending = + claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND; + const isTerminalClaim = + claim.status === ClaimCaseStatus.COMPLETED || + claim.status === ClaimCaseStatus.CANCELLED; + const isFieldExpertPortfolioLock = + isFieldExpertOwner && + !isTerminalClaim && + !isDamageReviewLock && + !isFactorValidationLock && + !isExpertReviewingReentry && + !isResendPending; - if (!isDamageReviewLock && !isFactorValidationLock) { + if ( + !isDamageReviewLock && + !isFactorValidationLock && + !isExpertReviewingReentry && + !isResendPending && + !isFieldExpertPortfolioLock + ) { throw new BadRequestException({ success: false, status: "unavailable" satisfies ExpertFileAssignStatus, @@ -2490,11 +2513,15 @@ export class ExpertClaimService { const now = new Date(); const lockSnapshot = await this.snapshotDamageExpert(actor.sub); const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs); + const actorType = isFieldExpertOwner ? "field_expert" : "damage_expert"; + const actorRoleLabel = isFieldExpertOwner + ? ("field_expert" as const) + : ("damage_expert" as const); const lockedByPayload = { actorId: expertOid, actorName: actor.fullName, - actorRole: "damage_expert" as const, + actorRole: actorRoleLabel, ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), }; @@ -2503,34 +2530,40 @@ export class ExpertClaimService { "workflow.lockedAt": now, "workflow.expiredAt": expiredAt, "workflow.lockedBy": lockedByPayload, - "workflow.preLockQueueSnapshot": { - claimStatus: claim.claimStatus, - currentStep: this.normalizeClaimWorkflowStepForSnapshot( - claim.workflow?.currentStep, - ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, - ), - ...(claim.workflow?.nextStep != null - ? { - nextStep: this.normalizeClaimWorkflowStepForSnapshot( - claim.workflow.nextStep, - ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + ...(isDamageReviewLock || isExpertReviewingReentry || isResendPending + ? { + "workflow.preLockQueueSnapshot": { + claimStatus: claim.claimStatus, + currentStep: this.normalizeClaimWorkflowStepForSnapshot( + claim.workflow?.currentStep, + ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, ), - } - : {}), - }, + ...(claim.workflow?.nextStep != null + ? { + nextStep: this.normalizeClaimWorkflowStepForSnapshot( + claim.workflow.nextStep, + ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + ), + } + : {}), + }, + } + : {}), $push: { history: { type: "CLAIM_ASSIGNED", actor: { actorId: expertOid, actorName: actor.fullName, - actorType: "damage_expert", + actorType, }, timestamp: now, metadata: { note: isFactorValidationLock ? "Expert assigned for factor validation review" - : "Expert assigned via review assign endpoint", + : isFieldExpertPortfolioLock + ? "Field expert portfolio lock (no status change)" + : "Expert assigned via review assign endpoint", ...(isFactorValidationLock ? { phase: "factorValidation" } : {}), }, }, @@ -2541,14 +2574,18 @@ export class ExpertClaimService { baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload; } - const lockUpdate: Record = isFactorValidationLock - ? baseLockUpdate - : { - ...baseLockUpdate, - status: ClaimCaseStatus.EXPERT_REVIEWING, - claimStatus: ClaimStatus.UNDER_REVIEW, - "workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, - }; + const lockUpdate: Record = + isFactorValidationLock || + isExpertReviewingReentry || + isResendPending || + isFieldExpertPortfolioLock + ? baseLockUpdate + : { + ...baseLockUpdate, + status: ClaimCaseStatus.EXPERT_REVIEWING, + claimStatus: ClaimStatus.UNDER_REVIEW, + "workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + }; const assignFilter: Record = { _id: new Types.ObjectId(claimRequestId), @@ -2581,6 +2618,13 @@ export class ExpertClaimService { ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, ], }; + } else if (isExpertReviewingReentry) { + assignFilter.status = ClaimCaseStatus.EXPERT_REVIEWING; + } else if (isResendPending) { + assignFilter.status = ClaimCaseStatus.WAITING_FOR_USER_RESEND; + } else if (isFieldExpertPortfolioLock) { + assignFilter.initiatedByFieldExpertId = expertOid; + assignFilter.status = claim.status; } else { assignFilter.status = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; } @@ -2634,7 +2678,13 @@ export class ExpertClaimService { }); const ownerPhone = await this.resolveClaimOwnerPhone(updated); - if (ownerPhone) { + if ( + ownerPhone && + isDamageReviewLock && + !isFieldExpertPortfolioLock && + !isExpertReviewingReentry && + !isResendPending + ) { const expertLastName = actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice( diff --git a/src/helpers/iran-mobile.ts b/src/helpers/iran-mobile.ts index 4e9e5da..9e5dde7 100644 --- a/src/helpers/iran-mobile.ts +++ b/src/helpers/iran-mobile.ts @@ -44,3 +44,28 @@ export function buildUserLookupByPhone( $or: variants.flatMap((v) => [{ username: v }, { mobile: v }]), }; } + +/** True when a blame party person row belongs to the authenticated user. */ +export function partyPersonMatchesUser( + person: + | { userId?: unknown; phoneNumber?: string } + | null + | undefined, + user: { sub?: string; username?: string } | null | undefined, +): boolean { + if (!person || !user) return false; + if ( + person.userId != null && + user.sub && + String(person.userId) === String(user.sub) + ) { + return true; + } + if (!person.phoneNumber || !user.username) return false; + const stored = + normalizeIranMobile(person.phoneNumber) ?? person.phoneNumber; + const variants = iranMobileLookupVariants(user.username); + return variants.some( + (v) => v === stored || v === person.phoneNumber, + ); +} diff --git a/src/helpers/tenant-scope.ts b/src/helpers/tenant-scope.ts index bc0e038..2c2ae8d 100644 --- a/src/helpers/tenant-scope.ts +++ b/src/helpers/tenant-scope.ts @@ -1,4 +1,5 @@ import { BadRequestException, ForbiddenException } from "@nestjs/common"; +import { RoleEnum } from "src/Types&Enums/role.enum"; /** Insurance company tenant id on the actor JWT (Mongo ObjectId string). */ export function requireActorClientKey(actor: { clientKey?: string }): string { @@ -64,6 +65,19 @@ export function blameCaseAccessibleToExpert( return blameCaseTouchesClient(doc, ck); } +/** True when the acting field expert created this expert-initiated blame file. */ +export function blameCaseInitiatedByFieldExpert( + doc: any, + actor: { sub: string; role?: string }, +): boolean { + return ( + actor.role === RoleEnum.FIELD_EXPERT && + !!doc?.expertInitiated && + !!doc?.initiatedByFieldExpertId && + String(doc.initiatedByFieldExpertId) === String(actor.sub) + ); +} + export function assertBlameCaseForExpertTenant( doc: any, actor: { sub: string; clientKey?: string }, diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index e742e50..9ca5a00 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -91,6 +91,12 @@ import { MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS, } from "src/helpers/blame-party-agreement-decision"; import { buildFileLink } from "src/helpers/urlCreator"; +import { + buildUserLookupByPhone, + iranMobileLookupVariants, + normalizeIranMobile, + partyPersonMatchesUser, +} from "src/helpers/iran-mobile"; @Injectable() export class RequestManagementService { @@ -209,14 +215,8 @@ export class RequestManagementService { private assertPartyOwner(party: any, user: any, errMsg: string, req?: any) { // The initiating field-expert/registrar fills steps on behalf of parties. if (req && this.isBlameOnBehalfActor(req, user)) return; - const partyUserId = party?.person?.userId - ? String(party.person.userId) - : null; - const ok = - (partyUserId && partyUserId === String(user?.sub)) || - (party?.person?.phoneNumber && - party.person.phoneNumber === user?.username); - if (!ok) throw new ForbiddenException(errMsg); + if (partyPersonMatchesUser(party?.person, user)) return; + throw new ForbiddenException(errMsg); } /** @@ -4434,7 +4434,8 @@ export class RequestManagementService { throw new BadRequestException("First party not found on request"); const userId = await this.getOrCreateUserByPhoneNumber(phone); if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any; - req.parties[firstIdx].person.phoneNumber = phone; + req.parties[firstIdx].person.phoneNumber = + normalizeIranMobile(phone) ?? phone; req.parties[firstIdx].person.userId = userId; const expertName = `${expert?.lastName || ""}`.trim() || "کارشناس"; @@ -4591,7 +4592,9 @@ export class RequestManagementService { throw new BadRequestException("First party not found on request"); if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any; req.parties[firstIdx].person.userId = firstUserId; - req.parties[firstIdx].person.phoneNumber = dto.firstPartyPhoneNumber; + req.parties[firstIdx].person.phoneNumber = + normalizeIranMobile(dto.firstPartyPhoneNumber) ?? + dto.firstPartyPhoneNumber; if ( req.type === BlameRequestType.THIRD_PARTY && @@ -4617,7 +4620,9 @@ export class RequestManagementService { if (!req.parties[secondIdx].person) req.parties[secondIdx].person = {} as any; req.parties[secondIdx].person.userId = secondUserId; - req.parties[secondIdx].person.phoneNumber = dto.secondPartyPhoneNumber; + req.parties[secondIdx].person.phoneNumber = + normalizeIranMobile(dto.secondPartyPhoneNumber) ?? + dto.secondPartyPhoneNumber; } } else if ( req.type === BlameRequestType.THIRD_PARTY && @@ -4794,6 +4799,7 @@ export class RequestManagementService { const valid = await this.hashService.compare(otp, u.otp); if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`); const userId = u._id as Types.ObjectId; + const canonicalPhone = normalizeIranMobile(phone) ?? phone; if (role === PartyRole.FIRST) { if (firstIdx === -1) @@ -4801,13 +4807,21 @@ export class RequestManagementService { if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any; req.parties[firstIdx].person.userId = userId; - req.parties[firstIdx].person.phoneNumber = phone; + req.parties[firstIdx].person.phoneNumber = canonicalPhone; } else { const firstPhone = firstIdx !== -1 ? (req.parties[firstIdx]?.person as any)?.phoneNumber : undefined; - if (firstPhone && firstPhone === phone) { + const firstVariants = firstPhone + ? iranMobileLookupVariants(firstPhone) + : []; + if ( + firstPhone && + (firstPhone === phone || + firstVariants.includes(phone) || + firstVariants.includes(canonicalPhone)) + ) { throw new BadRequestException( "Second party phone number cannot be the same as first party.", ); @@ -4816,13 +4830,13 @@ export class RequestManagementService { if (secondIdx === -1) { req.parties.push({ role: PartyRole.SECOND, - person: { userId, phoneNumber: phone }, + person: { userId, phoneNumber: canonicalPhone }, } as any); } else { if (!req.parties[secondIdx].person) req.parties[secondIdx].person = {} as any; req.parties[secondIdx].person.userId = userId; - req.parties[secondIdx].person.phoneNumber = phone; + req.parties[secondIdx].person.phoneNumber = canonicalPhone; } } @@ -5018,9 +5032,16 @@ export class RequestManagementService { user?.sub && Types.ObjectId.isValid(user.sub) ? { "parties.person.userId": new Types.ObjectId(user.sub) } : null; - const phoneFilter = user?.username - ? { "parties.person.phoneNumber": user.username } - : null; + const phoneVariants = + user?.role === RoleEnum.USER && user?.username + ? iranMobileLookupVariants(user.username) + : user?.username + ? [user.username] + : []; + const phoneFilter = + phoneVariants.length > 0 + ? { "parties.person.phoneNumber": { $in: phoneVariants } } + : null; const orConditions: Record[] = []; if (userIdFilter) orConditions.push(userIdFilter); @@ -5061,11 +5082,8 @@ export class RequestManagementService { req.registrarInitiated && String(req.initiatedByRegistrarId) === String(user.sub)); - const party = req.parties?.find( - (p: any) => - (p?.person?.userId && - String(p.person.userId) === String(user.sub)) || - (p?.person?.phoneNumber && p.person.phoneNumber === user?.username), + const party = req.parties?.find((p: any) => + partyPersonMatchesUser(p?.person, user), ); const obj = req.toObject(); @@ -5340,14 +5358,7 @@ export class RequestManagementService { String(req.initiatedByRegistrarId) === String(user?.sub); const isParty = Array.isArray(req.parties) && - req.parties.some((p: any) => { - const pid = p?.person?.userId ? String(p.person.userId) : null; - const phone = p?.person?.phoneNumber; - return ( - (pid && pid === String(user?.sub)) || - (phone && phone === user?.username) - ); - }); + req.parties.some((p: any) => partyPersonMatchesUser(p?.person, user)); if (!isFieldExpertOwner && !isRegistrarOwner && !isParty) { throw new ForbiddenException("You do not have access to this request"); } @@ -5359,10 +5370,22 @@ export class RequestManagementService { Types.ObjectId.isValid(user.sub) ) { let updated = false; + const phoneVariants = iranMobileLookupVariants(user?.username); for (const p of req.parties || []) { - if (p?.person?.phoneNumber === user?.username && !p.person?.userId) { + const person = p?.person; + if (!person) continue; + const phoneMatch = + person.phoneNumber && + phoneVariants.some( + (v) => v === person.phoneNumber || v === normalizeIranMobile(person.phoneNumber), + ); + if (phoneMatch && !person?.userId) { p.person = p.person || {}; (p.person as any).userId = new Types.ObjectId(user.sub); + if (user?.username) { + (p.person as any).phoneNumber = + normalizeIranMobile(user.username) ?? user.username; + } updated = true; } } @@ -5384,14 +5407,9 @@ export class RequestManagementService { const visibleParties = isFieldExpertOwner || isRegistrarOwner ? allParties - : allParties.filter((p: any) => { - const pid = p?.person?.userId ? String(p.person.userId) : null; - const phone = p?.person?.phoneNumber; - return ( - (pid && pid === String(user?.sub)) || - (phone && phone === user?.username) - ); - }); + : allParties.filter((p: any) => + partyPersonMatchesUser(p?.person, user), + ); const parties = await Promise.all( visibleParties.map((p: any) => this.sanitizePartyForBlameUserView(p)), ); @@ -7288,19 +7306,18 @@ export class RequestManagementService { private async getOrCreateUserByPhoneNumber( phoneNumber: string, ): Promise { - // Try to find existing user by phone number (username or mobile) - let user = await this.userDbService.findOne({ - $or: [{ username: phoneNumber }, { mobile: phoneNumber }], - }); + const canonical = normalizeIranMobile(phoneNumber) ?? phoneNumber.trim(); + let user = await this.userDbService.findOne( + buildUserLookupByPhone(canonical), + ); if (user) { return new Types.ObjectId((user as any)._id); } - // User doesn't exist, create a new one const newUser = await this.userDbService.createUser({ - mobile: phoneNumber, - username: phoneNumber, + mobile: canonical, + username: canonical, otp: "", tokens: { token: "",