import { BadGatewayException, BadRequestException, ForbiddenException, HttpException, HttpStatus, Injectable, Logger, NotFoundException, ConflictException, InternalServerErrorException, } from "@nestjs/common"; import { Types } from "mongoose"; import { isAxiosError } from "axios"; import { unlink } from "node:fs/promises"; import { createReadStream, existsSync } from "node:fs"; import { basename, resolve } from "node:path"; import FormData = require("form-data"); import { AiService } from "src/ai/ai.service"; import { buildFileLink, resolveStoredFileUrl } from "src/helpers/urlCreator"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum"; import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum"; import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.enum"; import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema"; import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { CarDamagePartDto } from "./dto/car-part.dto"; import { ClaimPartUploadDetail } from "./dto/claim-detail"; import { CreateClaimRequestDtoRs } from "./dto/create-claim.dto"; import { ClaimRequestDtoRs } from "./dto/claim-rs-dto"; import { ImageRequiredDto } from "./dto/image-required.dto"; import { MyRequestsDto } from "./dto/my-request.dto"; import { UserObjectionDto } from "./dto/user-objection.dto"; import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto"; import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service"; import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service"; import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service"; import { fanavaranClaimReferences } from "./fanavaran-claim-references"; import { FANAVARAN_HULL_NO_DAMAGE_CASE_REASON, fanavaranClaimProductFromBlameType, fanavaranClaimsBaseUrl, fanavaranClaimStageUrl, fanavaranHasDamageCaseStage, fanavaranInsuranceLineIdForProduct, fanavaranPartyInquirySources, isFanavaranSubmitSupportedBlameType, pickCarBodyPolicyNationalCode, pickStoredCarBodyPolicyId, type FanavaranClaimProduct, } from "./fanavaran-claim-product"; import { hullExpertiseAssertFields, pickHullVehicleIdentity, toFanavaranHullExpertiseDmgSection, toFanavaranHullExpertisePayload, } from "./fanavaran-hull-expertise"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { CreateClaimFromBlameResponseDto } from "./dto/create-claim-v2.dto"; import { OuterPartCatalogItemDto, SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto, } from "./dto/select-outer-parts-v2.dto"; import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto, } from "./dto/select-other-parts-v2.dto"; import { SelectOtherPartsV3Dto } from "./dto/select-other-parts-v3.dto"; import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto"; import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto, } from "./dto/upload-document-v2.dto"; import { CapturePartV2Dto, CapturePartV2ResponseDto, VideoCaptureV2ResponseDto, } from "./dto/capture-part-v2.dto"; import { GetMyClaimsV2ResponseDto, ClaimListItemV2Dto, } 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 { buildBlamePartyAccessOrConditions, collectUserIdVariants, } from "src/helpers/party-access-queries"; import { buildOwnerUserIdAccessFilter, ownerUserIdMatchesActor, resolveLinkedUserIdStrings, } from "src/helpers/user-access-resolver"; import { blameDamagedPartyMatchesUser, resolveClaimOwnerFieldsFromBlame, resolveClaimOwnerParty, resolveDamagedPartyRow, resolveDamagedPartyUserId, } from "src/helpers/blame-damaged-party"; import { claimCaseInitiatedByFieldExpert } from "src/helpers/tenant-scope"; 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"; import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { CaseStatus as BlameCaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; import { WorkflowStep } from "src/Types&Enums/blame-request-management/blameWorkflow-steps.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service"; import { DamageImageDbService } from "./entites/db-service/damage-image.db.service"; import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service"; import { VideoCaptureDbService } from "./entites/db-service/video-capture.db.service"; import { ClaimRequiredDocumentDbService } from "./entites/db-service/claim-required-document.db.service"; import { ClaimRequestManagementModel, UserClaimRating, } from "./entites/schema/claim-request-management.schema"; import { PublicIdService } from "src/utils/public-id/public-id.service"; import { ImageRequiredModel } from "./entites/schema/image-required.schema"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service"; import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service"; import { FanavaranLocationService } from "src/fanavaran/fanavaran-location.service"; import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service"; import { SandHubService } from "src/sand-hub/sand-hub.service"; import { ExpertFileActivityType, ExpertFileKind, } from "src/users/entities/schema/expert-file-activity.schema"; import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum"; import { BranchDbService } from "src/client/entities/db-service/branch.db.service"; import { CreationMethod } from "src/request-management/entities/schema/request-management.schema"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; import { InquiryParticipantRole } from "src/common/dto/inquiry-participants.dto"; import { participantForStoredPartyRole } from "src/request-management/inquiry-participant-resolver"; import { UserRatingDto } from "./dto/user-rating.dto"; import { canFinalizeExpertResend, documentKeyAllowedForExpertResend, mapExpertResendCarPartsForClient, normalizeResendDocumentKeys, normalizeResendPartKeys, partKeyAllowedForExpertResend, } from "src/helpers/claim-expert-resend"; import { claimCaseStatusAllowsOwnerFactorUploadDuringRevision, claimCaseStatusAllowsOwnerInsurerSignStep, classifyV2ExpertPricingParts, getActiveV2ExpertReply, objectionDisallowedDueToOutstandingFactorWorkflow, } from "src/helpers/claim-v2-expert-reply-workflow"; import { buildClaimDetailsV2OwnerGuidance } from "src/helpers/claim-details-v2-owner-guidance"; import { canonicalizeClaimCarAngleKey, getClaimCarAngleCaptureBlob, getDamagedPartCaptureBlob, hasClaimCarAngleCapture, hasDamagedPartCapture, legacyAngleDamagedPartFieldKeysToUnset, type ClaimCarAngleKey, } from "src/helpers/claim-car-angle-media"; import { ClaimVehicleTypeV2, FANAVARAN_CAR_PARTS_CATALOG, OuterPartCatalogItem, } from "src/static/outer-car-parts-catalog"; import { carPartDamageLabelForFactorRecord, catalogItemToSelectedPart, catalogLikeKeyFromPart, coerceDamagedPartsMediaToArray, type DamageSelectedPartV2, migrateExpertReplyCarPartDamageToUnified, normalizeDamageSelectedParts, parseCatalogPartIdInput, partLookupKey, resolvePartCaptureIndex, } from "src/helpers/outer-damage-parts"; import { normalizeMoneyAmountString } from "src/utils/unicode-digits"; import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service"; import { CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS, capturePhaseSequenceMessage, getClaimCaptureProgress, GUILTY_DAMAGE_AREA_DOC_KEY, isCapturePhaseDamagedPartyDocKey, isClaimCaptureStepComplete, OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5, } from "src/helpers/claim-capture-phase"; import { HttpService } from "@nestjs/axios"; import { firstValueFrom } from "rxjs"; import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher"; import { type FanavaranClientKey, fanavaranSubmitPath, getFanavaranClientProfile, resolveFanavaranClientKey, resolveFanavaranProductContractId, } 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_LOOKUP_BASE_URL, FANAVARAN_REMOTE_LOOKUPS, } from "src/fanavaran/fanavaran-lookup.config"; import type { FanavaranAuditSession } from "src/fanavaran/fanavaran-audit.types"; import { FanavaranAuditSource, 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"; import { selectAccidentVehicleUsedId, vehicleGroupIdForKind, } from "./fanavaran-accident-vehicle-used"; import { asPartyInquiryRows, collectPersonBirthdays, collectPersonNationalCodes, parseJalaliDateParts, pickPersonBirthday, pickPersonNationalCode, selectFanavaranDriverId, } from "./fanavaran-driver-inquiry"; import { buildFanavaranOtherPeoplePayload, pickFanavaranRecordId, } from "./fanavaran-other-people"; import { asObjectRecord, asVehicleInquiryRows, collectPartyVinCandidates, completePlaqueParts, fanavaranVehicleMatchesDamagedIdentity, normalizeVin, applyFanavaranManualPayloadOverrides, FANAVARAN_BASE_MANUAL_OVERRIDE_KEYS, FANAVARAN_DAMAGE_MANUAL_OVERRIDE_KEYS, FANAVARAN_EXPERTISE_MANUAL_OVERRIDE_KEYS, parseFanavaranId, pickFanavaranPlaqueLookupFields, pickFanavaranVehicleFromInquiry, pickVehicleId, pickVinFromPartyVehicle, resolveDamageCasePlaqueLookupIds, selectFanavaranVehicleForDamageCase, type FanavaranDamagedVehicleIdentity, } from "src/lookups/fanavaran-last-car-policy"; export interface FanavaranAutoSubmitResult { attempted: boolean; submitted: boolean; skipped?: boolean; skipReason?: string; warning?: string; claimNo?: number; claimId?: number; fanavaranResponse?: unknown; } export interface FanavaranDamageCaseSubmitResult { attempted: boolean; submitted: boolean; skipped?: boolean; skipReason?: string; warning?: string; claimId?: number; dmgCaseId?: number; fanavaranResponse?: unknown; } export interface FanavaranAttachmentSubmitResult { attempted: boolean; submitted: boolean; skipped?: boolean; skipReason?: string; warning?: string; claimId?: number; fileId?: number; fileName?: string; fanavaranResponse?: unknown; } export interface FanavaranExpertiseSubmitResult { attempted: boolean; submitted: boolean; skipped?: boolean; skipReason?: string; warning?: string; claimId?: number; dmgCaseId?: number; expertiseId?: number; fanavaranResponse?: unknown; } interface FanavaranAttachmentCandidate { path: string; fileName: string; source: string; content: Record; alreadySubmitted: boolean; submittedFileId?: unknown; } /** * Returns today's Jalali date as a packed integer YYYYMMDD for cheap comparison. * Uses the browser/Node `fa-IR` locale which is available everywhere Node runs. */ function jalaliDateToInt(d: Date): number { const parts = d .toLocaleDateString("fa-IR", { year: "numeric", month: "2-digit", day: "2-digit" }) .split("/") .map((p) => parseInt(p.replace(/[۰-۹]/g, (c) => String(c.charCodeAt(0) - 0x06f0)), 10)); return parts[0] * 10000 + parts[1] * 100 + parts[2]; } /** * Parses a Jalali date string in "YYYY/MM/DD" or "YYYY-MM-DD" form to the same * packed integer YYYYMMDD. Returns null when the string cannot be parsed. */ function jalaliStringToInt(raw: string): number | null { const m = raw.match(/(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})/); if (!m) return null; const y = parseInt(m[1], 10); const mo = parseInt(m[2], 10); const d = parseInt(m[3], 10); if (!y || !mo || !d) return null; return y * 10000 + mo * 100 + d; } const FANAVARAN_ACCIDENT_LOCATION_ADDRESS = "استان تهران شهر تهران"; const FANAVARAN_DEFAULT_ACCIDENT_CAUSE_ID = 6; const FANAVARAN_DEFAULT_ACCIDENT_LEVEL = 5456; const FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT = 1000; /** * Fanavaran sometimes rejects missing/empty/whitespace licence numbers. * We always send either the user-entered value (from DB) or this 10-digit dummy. */ const FANAVARAN_DUMMY_LICENCE_NO = process.env.FANAVARAN_DUMMY_LICENCE_NO?.trim() || "9705463515"; /** Local carType → Fanavaran vehicle-kind Caption keywords for matching. */ const VEHICLE_KIND_KEYWORDS: Record = { [ClaimVehicleTypeV2.SEDAN]: ["سواری", "سواري", "sedan"], [ClaimVehicleTypeV2.SUV]: ["شاسی", "شاسي", "suv", "بلند"], [ClaimVehicleTypeV2.HATCHBACK]: ["هاچبک", "هاچ بک", "hatchback"], [ClaimVehicleTypeV2.PICKUP]: ["وانت", "پیکاپ", "pick", "pickup"], [ClaimVehicleTypeV2.VAN]: ["ون", "van"], }; const FANAVARAN_PLATE_LETTER_CODE: Record = { الف: 1, ب: 2, پ: 3, ج: 4, د: 5, س: 6, ص: 7, ط: 8, ع: 9, ق: 10, ل: 11, م: 12, ن: 13, و: 14, ه: 15, ی: 16, ک: 17, ژ: 18, ت: 19, ث: 20, ز: 21, ش: 22, ف: 23, گ: 24, }; @Injectable() export class ClaimRequestManagementService { private readonly logger = new Logger(ClaimRequestManagementService.name); // Legacy Tejaratno third-party URL. V2 stages use fanavaranClaimsBaseUrl(product). private readonly FANAVARAN_SUBMIT_URL = fanavaranClaimsBaseUrl("third-party"); // Authentication credentials private readonly APP_NAME = "fanhab"; private readonly SECRET = "5Fa@N#A2B"; private readonly USERNAME = "fanhabUser"; private readonly PASSWORD = "Fan#@2U$3er"; // API headers (legacy Tejaratno — prefer getFanavaranClientProfile) private readonly CORP_ID = "3539"; 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 fallback only). */ private readonly TEJARATNO_FANAVARAN_DEFAULTS = { AccidentCityId: 701, AccidentReportTypeId: 155, AccidentVehicleUsedId: 1, ClaimExpertId: 4543092, CompensationReferenceId: 167, CulpritLicenceTypeId: 2, 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"; constructor( private readonly blameDbService: RequestManagementDbService, private readonly claimDbService: ClaimRequestManagementDbService, private readonly claimCaseDbService: ClaimCaseDbService, private readonly blameRequestDbService: BlameRequestDbService, private readonly carGreenCardDbService: CarGreenCardDbService, private readonly damageImageDbService: DamageImageDbService, private readonly userDbService: UserDbService, private readonly videoCaptureDbService: VideoCaptureDbService, private readonly claimSignDbService: ClaimSignDbService, private readonly aiService: AiService, private readonly claimRequestManagementDbService: ClaimRequestManagementDbService, private readonly claimFactorsImageDbService: ClaimFactorsImageDbService, private readonly damageExpertDbService: DamageExpertDbService, private readonly expertFileActivityDbService: ExpertFileActivityDbService, private readonly branchDbService: BranchDbService, private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService, private readonly publicIdService: PublicIdService, 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, private readonly fanavaranLocationService: FanavaranLocationService, ) {} private requiredDocumentKeysV2(isCarBody: boolean): string[] { const damagedPartyKeys = [ "damaged_driving_license_front", "damaged_driving_license_back", "damaged_chassis_number", "damaged_engine_photo", "damaged_car_card_front", "damaged_car_card_back", "damaged_metal_plate", ]; if (isCarBody) return damagedPartyKeys; return [ ...damagedPartyKeys, "guilty_driving_license_front", "guilty_driving_license_back", "guilty_car_card_front", "guilty_car_card_back", "guilty_metal_plate", ]; } private isRequiredDocumentUploadedOnClaim( claimCase: any, key: string, ): boolean { const doc = (claimCase.requiredDocuments as any)?.get?.(key) ?? (claimCase.requiredDocuments as any)?.[key]; return !!doc?.uploaded; } private allV2OwnerDocumentsComplete( claimCase: any, isCarBody: boolean, assumeUploadedKey?: string, ): boolean { for (const k of this.requiredDocumentKeysV2(isCarBody)) { const ok = k === assumeUploadedKey ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, k); if (!ok) return false; } if (isCarBody) { const g = ClaimRequiredDocumentType.CAR_GREEN_CARD; const okGreen = assumeUploadedKey === g ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, g); if (!okGreen) return false; } return true; } /** V3 initial document step — excludes capture-phase vehicle evidence. */ private v3PreCaptureDocumentKeys(isCarBody: boolean): string[] { return this.requiredDocumentKeysV2(isCarBody).filter( (k) => !isCapturePhaseDamagedPartyDocKey(k), ); } private allV3PreCaptureDocumentsComplete( claimCase: any, isCarBody: boolean, assumeUploadedKey?: string, skipMetalPlate?: boolean, ): boolean { for (const k of this.v3PreCaptureDocumentKeys(isCarBody)) { if (skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) continue; const ok = k === assumeUploadedKey ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, k); if (!ok) return false; } if (isCarBody) { const g = ClaimRequiredDocumentType.CAR_GREEN_CARD; const ok = assumeUploadedKey === g ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, g); if (!ok) return false; } return true; } private countRemainingV3PreCaptureDocuments( claimCase: any, isCarBody: boolean, assumeUploadedKey?: string, skipMetalPlate?: boolean, ): number { let n = 0; for (const k of this.v3PreCaptureDocumentKeys(isCarBody)) { if (skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) continue; const ok = k === assumeUploadedKey ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, k); if (!ok) n++; } if (isCarBody) { const g = ClaimRequiredDocumentType.CAR_GREEN_CARD; const ok = assumeUploadedKey === g ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, g); if (!ok) n++; } return n; } private countRemainingV2OwnerDocuments( claimCase: any, isCarBody: boolean, assumeUploadedKey?: string, ): number { let n = 0; for (const k of this.requiredDocumentKeysV2(isCarBody)) { const ok = k === assumeUploadedKey ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, k); if (!ok) n++; } if (isCarBody) { const g = ClaimRequiredDocumentType.CAR_GREEN_CARD; const okGreen = assumeUploadedKey === g ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, g); if (!okGreen) n++; } return n; } private parsePlateFromCompactString(plateId: string | undefined): { leftDigits: number; centerAlphabet: string; centerDigits: number; ir: number; } | null { if (!plateId) return null; const parts = String(plateId).split("-"); if (parts.length !== 4) return null; const [irRaw, leftRaw, alphaRaw, centerRaw] = parts; const ir = Number(irRaw); const leftDigits = Number(leftRaw); const centerDigits = Number(centerRaw); const centerAlphabet = this.plateNormalizer.normalizePlateText(String(alphaRaw || "")); if ( !Number.isFinite(ir) || !Number.isFinite(leftDigits) || !Number.isFinite(centerDigits) || !centerAlphabet ) { return null; } return { leftDigits, centerAlphabet, centerDigits, ir }; } private resolveOwnershipPlateForClaim( claimCase: any, blameRequest?: any, ): { leftDigits: number; centerAlphabet: string; centerDigits: number; ir: number; } | null { const p = claimCase?.vehicle?.plate; if ( p && Number.isFinite(Number(p.leftDigits)) && Number.isFinite(Number(p.centerDigits)) && Number.isFinite(Number(p.ir)) && this.plateNormalizer.normalizePlateText(String(p.centerAlphabet || "")) ) { return { leftDigits: Number(p.leftDigits), centerAlphabet: this.plateNormalizer.normalizePlateText(String(p.centerAlphabet)), centerDigits: Number(p.centerDigits), ir: Number(p.ir), }; } if (!blameRequest || !Array.isArray(blameRequest.parties)) return null; const ownerUserId = claimCase?.owner?.userId?.toString?.(); const matchedParty = blameRequest.parties.find( (party: any) => String(party?.person?.userId || "") === String(ownerUserId || ""), ) || blameRequest.parties.find( (party: any) => party?.role === PartyRole.FIRST, ); const compactPlateId = matchedParty?.vehicle?.plateId; return this.parsePlateFromCompactString(compactPlateId); } private resolveVehicleOwnerNationalCodeForClaim( blameRequest: any, ): string | undefined { const damagedParty = resolveDamagedPartyRow(blameRequest as any); const owner = damagedParty ? participantForStoredPartyRole( damagedParty as any, InquiryParticipantRole.VEHICLE_OWNER, ) : undefined; const nationalCode = String(owner?.nationalCode ?? "").trim(); return nationalCode || undefined; } private delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Outer-parts catalog returned to clients (user app + expert panel). * * Fetches the live `car/base-info/car-components` lookup from Fanavaran so * the list always matches what Fanavaran accepts in DmgSections[].DmgSectionId. * The lookup service handles caching to disk; `carType` is ignored because * Fanavaran uses a single flat catalog across all vehicle types. */ async getOuterPartsCatalogV2(): Promise { const clientKey = resolveFanavaranClientKey(); const rows = await this.getFanavaranLookupRows(clientKey, "car-components"); // Build today's Jalali date as a comparable integer (YYYYMMDD) so we can // filter expired rows without any Jalali→Gregorian conversion risk. const todayJalaliInt = jalaliDateToInt(new Date()); return rows .filter( (r): r is { Id: number; Caption: string } => !!r && typeof r === "object" && typeof (r as any).Id === "number" && typeof (r as any).Caption === "string", ) .filter((r) => { const toDate = (r as any).ToDate; if (!toDate) return true; const toDateInt = jalaliStringToInt(String(toDate)); if (toDateInt === null) return true; return toDateInt >= todayJalaliInt; }) .map((r) => ({ id: r.Id, label_fa: r.Caption })); } /** * Resolve the live Fanavaran car-components catalog as `OuterPartCatalogItem` * rows for internal use (e.g. validating selectedPartIds). * Falls back to the static snapshot when the remote lookup is unavailable. */ private async getLiveFanavaranCatalogItems(): Promise { const clientKey = resolveFanavaranClientKey(); const rows = await this.getFanavaranLookupRows(clientKey, "car-components"); if (!rows.length) { this.logger.warn( "getLiveFanavaranCatalogItems: remote lookup returned empty, falling back to snapshot", ); return FANAVARAN_CAR_PARTS_CATALOG; } return rows .filter( (r): r is { Id: number; Caption: string } => !!r && typeof r === "object" && typeof (r as any).Id === "number" && typeof (r as any).Caption === "string", ) .map((r): OuterPartCatalogItem => ({ id: r.Id, key: String(r.Id), titleFa: r.Caption, side: "", })); } private userDamageDetail(blRequest: RequestManagementModel) { const { firstPartyDetails: first, secondPartyDetails: second } = blRequest; switch (blRequest.expertSubmitReply.guiltyUserId) { case first.firstPartyId: return { isGuilty: first, isNotGuilty: second }; case second.secondPartyId: return { isGuilty: second, isNotGuilty: first }; } } /** * Helper method to get the effective user ID for claim operations. * For experts accessing IN_PERSON claim files, returns the claim file's userId. * For regular users, returns their own ID. */ private async getEffectiveUserId( claimRequestId: string, actor: any, ): Promise { // If actor is a user, return their ID if (actor.role === RoleEnum.USER) { return actor.sub; } // If actor is an expert, verify they're accessing an IN_PERSON claim file // and return the claim file's userId const expertRoles = [ RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT, RoleEnum.FIELD_EXPERT, ]; if (expertRoles.includes(actor.role)) { const claim = await this.claimDbService.findOne(claimRequestId); if (!claim) { throw new NotFoundException("Claim file not found"); } const blameFile = claim.blameFile; if ( blameFile?.expertInitiated && blameFile?.creationMethod === "IN_PERSON" && String(blameFile.initiatedBy) === actor.sub ) { // Expert is accessing IN_PERSON file they initiated - use claim's userId return String(claim.userId); } throw new ForbiddenException( "Experts can only access IN_PERSON claim files they initiated", ); } throw new ForbiddenException("Invalid role"); } async createClaimRequest( requestId: string, CurrentUserId?: string, actor?: any, ) { try { const blameRequest = await this.blameDbService.findOne(requestId); if (!blameRequest) { throw new NotFoundException("Source blame request not found."); } // Ensure the blame request has a shared human-friendly id. // For legacy records, generate it lazily exactly once. let publicId = (blameRequest as any).publicId as string | undefined; if (!publicId) { publicId = await this.publicIdService.generateRequestPublicId(); await this.blameDbService.findAndUpdate( { _id: new Types.ObjectId(requestId) }, { $set: { publicId } }, ); } const existingClaimCount = await this.claimDbService.countByFilter({ "blameFile._id": new Types.ObjectId(requestId), blameRequestId: new Types.ObjectId(requestId), publicId, }); if (existingClaimCount > 0) { throw new ForbiddenException( "A claim request for this blame file already exists.", ); } const isStatusValid = blameRequest.blameStatus === ReqBlameStatus.CloseRequest; if (!isStatusValid) { throw new HttpException( `Blame request must be 'CloseRequest', but is currently '${blameRequest.blameStatus}'.`, HttpStatus.BAD_REQUEST, ); } if (!blameRequest.expertSubmitReply) { throw new HttpException( "Cannot create a claim file until the expert has submitted a reply.", HttpStatus.GONE, ); } // Determine the damaged user (non-guilty party) const guiltyUserId = String(blameRequest.expertSubmitReply.guiltyUserId); let damagedUserId: string; // For IN_PERSON expert-initiated files, expert creates the claim // For LINK method or regular files, user creates their own claim const expertRoles = [ RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT, RoleEnum.FIELD_EXPERT, ]; if ( blameRequest.expertInitiated && blameRequest.creationMethod === "IN_PERSON" && actor && expertRoles.includes(actor.role) ) { // Expert is creating claim for IN_PERSON file // Verify expert is the initiating expert if (String(blameRequest.initiatedBy) !== actor.sub) { throw new ForbiddenException( "Only the initiating expert can create claim files for IN_PERSON files.", ); } // Determine damaged user (the one who is NOT guilty) if ( String(blameRequest.firstPartyDetails.firstPartyId) === guiltyUserId ) { damagedUserId = String(blameRequest.secondPartyDetails.secondPartyId); } else { damagedUserId = String(blameRequest.firstPartyDetails.firstPartyId); } } else { // Regular user flow if (!CurrentUserId) { throw new BadRequestException("User ID is required."); } if (guiltyUserId === CurrentUserId) { throw new HttpException( "You cannot submit a claim file as you are the guilty party.", HttpStatus.FORBIDDEN, ); } damagedUserId = CurrentUserId; } const userDetail = await this.userDbService.findOne({ _id: new Types.ObjectId(damagedUserId), }); if (!userDetail) { throw new NotFoundException("User details could not be found."); } const carDetail = String(blameRequest.firstPartyDetails.firstPartyId) === damagedUserId ? blameRequest.firstPartyDetails.firstPartyCarDetail : blameRequest.secondPartyDetails.secondPartyCarDetail; const carPlate = String(blameRequest.firstPartyDetails.firstPartyId) === damagedUserId ? blameRequest.firstPartyDetails.firstPartyPlate : blameRequest.secondPartyDetails.secondPartyPlate; const newClaimPayload = { blameFile: blameRequest, userId: new Types.ObjectId(damagedUserId), claimStatus: ReqClaimStatus.WaitingForUserCompleted, steps: [ClaimStepsEnum.CreateClaimFile], currentStep: ClaimStepsEnum.CreateClaimFile, nextStep: ClaimStepsEnum.SelectDamagePart, fullName: userDetail.fullName, carDetail, carPlate, userClientKey: new Types.ObjectId(userDetail.clientKey), createdAt: new Date(), }; const response = await this.claimDbService.create(newClaimPayload as any); return new CreateClaimRequestDtoRs( response["_id"], "Claim file created successfully.", ); } catch (er) { this.logger.error(er); if (er instanceof HttpException) { throw er; } if ( typeof er === "object" && er !== null && "code" in er && er.code === 11000 ) { throw new HttpException( "Duplicate claim file reference.", HttpStatus.CONFLICT, ); } throw new HttpException( "An internal server error occurred.", HttpStatus.INTERNAL_SERVER_ERROR, ); } } private findTrueItems(userPartSelect: CarDamagePartDto) { const trueItems = []; for (const side in userPartSelect) { for (const parts in userPartSelect[side]) { if (userPartSelect[side][parts] === true) { trueItems.push({ side, part: parts }); } } } return trueItems; } /** One section of CarDamagePartDto: either a single object or an array of objects (legacy). */ private carPartDamageSectionToRows( section: unknown, ): Record[] { if (section == null) return []; if (Array.isArray(section)) { return section.filter( (row): row is Record => !!row && typeof row === "object", ); } if (typeof section === "object") { return [section as Record]; } return []; } private carPartDamageSectionHasTrue(section: unknown): boolean { for (const row of this.carPartDamageSectionToRows(section)) { for (const val of Object.values(row)) { if (val === true) return true; } } return false; } /** * At most two of left, right, front, back may contain any damaged part (true). * `top` and claim `otherParts` are not subject to this rule. */ private assertCarPartDamageAtMostTwoOfFourSides(dto: CarDamagePartDto): void { const d = dto as unknown as Record; const quadrants = [ { key: "left", section: d.left }, { key: "right", section: d.right }, { key: "front", section: d.front }, { key: "back", section: d.back }, ]; const used = quadrants.filter((q) => this.carPartDamageSectionHasTrue(q.section), ); if (used.length > 2) { throw new BadRequestException( `At most two of the four sides (left, right, front, back) may include damaged parts. ` + `Sides with selections: ${used.map((u) => u.key).join(", ")}.`, ); } } /** * Maps legacy expert CarDamagePartDto (nested booleans) to v2 outer part slugs (ClaimCase.damage.selectedParts). */ private carDamagePartDtoToOuterPartSlugs(dto: CarDamagePartDto): string[] { const out = new Set(); const add = (slug: string | undefined) => { if (slug) out.add(slug); }; const walkMainOnSide = ( side: "left" | "right", items: unknown, map: Record, ) => { for (const row of this.carPartDamageSectionToRows(items)) { for (const [key, val] of Object.entries(row)) { if (val !== true) continue; const slug = map[`${side}_${key}`] ?? map[key]; add(slug); } } }; // MainParts on left / right (side-specific door / fender) walkMainOnSide("left", (dto as any).left, { left_frontDoor: "front_left_door", left_backDoor: "rear_left_door", left_frontFender: "front_left_fender", left_backFender: "rear_left_fender", frontDoor: "front_left_door", backDoor: "rear_left_door", frontFender: "front_left_fender", backFender: "rear_left_fender", }); walkMainOnSide("right", (dto as any).right, { right_frontDoor: "front_right_door", right_backDoor: "rear_right_door", right_frontFender: "front_right_fender", right_backFender: "rear_right_fender", frontDoor: "front_right_door", backDoor: "rear_right_door", frontFender: "front_right_fender", backFender: "rear_right_fender", }); const walkSimple = (items: unknown, keyToSlug: Record) => { for (const row of this.carPartDamageSectionToRows(items)) { for (const [key, val] of Object.entries(row)) { if (val === true) add(keyToSlug[key]); } } }; walkSimple((dto as any).front, { frontBumper: "front_bumper", carHood: "hood", }); walkSimple((dto as any).back, { backBumper: "rear_bumper", carTrunk: "trunk", }); walkSimple((dto as any).top, { roof: "roof", }); return Array.from(out); } /** Normalize expert DTO otherParts (strings or legacy { label: true }[]) to string[]. */ private normalizeExpertOtherPartsInput(raw: any[] | undefined): string[] { if (!raw || !Array.isArray(raw)) return []; const out: string[] = []; for (const item of raw) { if (typeof item === "string" && item.trim()) { out.push(item.trim()); continue; } if (item && typeof item === "object") { for (const [k, v] of Object.entries(item)) { if (v === true && k) out.push(k); } } } return out; } private async assertFieldExpertInPersonBlameForClaimCase( claimCase: any, expert: any, ): Promise { const blameRequestId = claimCase?.blameRequestId?.toString?.(); if (!blameRequestId) { throw new BadRequestException("Claim case has no linked blame request."); } const blameRequest = await this.blameRequestDbService.findById(blameRequestId); if (!blameRequest) { throw new NotFoundException("Blame request not found for this claim."); } if ( !blameRequest.expertInitiated || blameRequest.creationMethod !== CreationMethod.IN_PERSON ) { throw new BadRequestException( "This endpoint is only for expert-initiated IN_PERSON claim files.", ); } if (!blameRequest.initiatedByFieldExpertId) { throw new BadRequestException( "Blame request is missing initiating field expert.", ); } if (String(blameRequest.initiatedByFieldExpertId) !== String(expert.sub)) { throw new ForbiddenException( "Only the initiating field expert can complete claim data for this file.", ); } } async setCarPartDamageAndImage(cl, trueItems) { cl.carPartDamage = trueItems; const jsonTrueItems = JSON.parse(JSON.stringify(trueItems)); cl.imageRequired = new ImageRequiredModel(jsonTrueItems); cl.save(); return cl.imageRequired; } async selectCarPartDamage( requestId: string, userPartSelect: CarDamagePartDto, actor: any, ): Promise { try { const cl = await this.claimDbService.findOne(requestId); if (!cl) { throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND); } // Verify access: user must own the claim, or expert must be accessing IN_PERSON file const effectiveUserId = await this.getEffectiveUserId(requestId, actor); if (String(cl.userId) !== effectiveUserId) { throw new ForbiddenException( "You do not have access to this claim file", ); } if (cl.carPartDamage) { throw new HttpException( "car damage part has exist", HttpStatus.CONFLICT, ); } const trueItems = this.findTrueItems(userPartSelect); const damageParts = this.setCarPartDamageAndImage(cl, trueItems); await this.claimDbService.findAndUpdate(requestId, { currentStep: ClaimStepsEnum.SelectDamagePart, nextStep: ClaimStepsEnum.SelectOtherParts, $push: { steps: ClaimStepsEnum.SelectDamagePart, }, }); return damageParts; } catch (error) { this.logger.error(error); throw error; } } /** * Field expert completes claim-needed data for expert-initiated IN_PERSON files. * Only the initiating field expert can call this. Accepts car part damage and/or sheba, nationalCodeOfInsurer, otherParts. * * V2: Resolves `ClaimCase` in `claimCases` collection (workflow). Legacy v1 claim documents are still supported. */ async expertCompleteClaimData( claimRequestId: string, expert: any, dto: { carPartDamage?: CarDamagePartDto; sheba?: string; nationalCodeOfInsurer?: string; otherParts?: any[]; }, ): Promise<{ success: boolean; claimRequestId: string }> { const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (claimCase) { return this.expertCompleteClaimDataForClaimCaseV2( claimRequestId, expert, dto, claimCase, ); } const claim = await this.claimDbService.findOne(claimRequestId); if (!claim) { throw new NotFoundException("Claim file not found"); } const blameFile = claim.blameFile as any; if ( !blameFile?.expertInitiated || blameFile?.creationMethod !== "IN_PERSON" ) { throw new BadRequestException( "This endpoint is only for expert-initiated IN_PERSON claim files.", ); } if (String(blameFile.initiatedBy) !== expert.sub) { throw new ForbiddenException( "Only the initiating field expert can complete claim data for this file.", ); } if (dto.carPartDamage) { if (claim.carPartDamage && (claim.carPartDamage as any[]).length > 0) { throw new BadRequestException("Car part damage is already set."); } this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage); const trueItems = this.findTrueItems(dto.carPartDamage); const claimDoc = (await this.claimDbService.findOne( claimRequestId, )) as any; if (claimDoc) { await this.setCarPartDamageAndImage(claimDoc, trueItems); } await this.claimDbService.findAndUpdate(claimRequestId, { currentStep: ClaimStepsEnum.SelectDamagePart, nextStep: ClaimStepsEnum.SelectOtherParts, $push: { steps: ClaimStepsEnum.SelectDamagePart }, }); } const updates: Record = {}; if (dto.sheba != null) updates.sheba = dto.sheba; if (dto.nationalCodeOfInsurer != null) updates.nationalCodeOfInsurer = dto.nationalCodeOfInsurer; if (dto.otherParts != null) updates.otherParts = dto.otherParts; if (Object.keys(updates).length > 0) { await this.claimDbService.findAndUpdate(claimRequestId, { $set: updates, }); } return { success: true, claimRequestId }; } /** * V2 ClaimCase: outer parts + optional bank/other parts (expert-initiated IN_PERSON only). */ private async expertCompleteClaimDataForClaimCaseV2( claimRequestId: string, expert: any, dto: { carPartDamage?: CarDamagePartDto; sheba?: string; nationalCodeOfInsurer?: string; otherParts?: any[]; }, claimCase: any, ): Promise<{ success: boolean; claimRequestId: string }> { await this.assertFieldExpertInPersonBlameForClaimCase(claimCase, expert); let working = claimCase; if (dto.carPartDamage) { if ( working.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS ) { throw new BadRequestException( `Cannot set outer parts: workflow step must be ${ClaimWorkflowStep.SELECT_OUTER_PARTS}. Current: ${working.workflow?.currentStep}`, ); } if ( working.damage?.selectedParts && working.damage.selectedParts.length > 0 ) { throw new BadRequestException("Car part damage is already set."); } this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage); const selectedSlugStrings = this.carDamagePartDtoToOuterPartSlugs( dto.carPartDamage, ); if (!selectedSlugStrings.length) { throw new BadRequestException( "No outer damaged parts could be derived from carPartDamage. Check part selections.", ); } const selectedPartObjs = normalizeDamageSelectedParts( selectedSlugStrings, working.vehicle?.carType as ClaimVehicleTypeV2 | undefined, undefined, ); const damagedPartsStubs = selectedPartObjs.map((p) => ({ id: p.id ?? undefined, name: p.name, side: p.side, label_fa: p.label_fa, ...(p.catalogKey ? { catalogKey: p.catalogKey } : {}), })); const updated = await this.claimCaseDbService.findByIdAndUpdate( claimRequestId, { "damage.selectedParts": selectedPartObjs, "media.damagedParts": damagedPartsStubs, status: ClaimCaseStatus.SELECTING_OTHER_PARTS, "workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS, "workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES, $push: { "workflow.completedSteps": ClaimWorkflowStep.SELECT_OUTER_PARTS, history: { type: "STEP_COMPLETED", actor: { actorId: new Types.ObjectId(expert.sub), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim() || "field_expert", actorType: "field_expert", }, timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS, selectedParts: selectedPartObjs, description: "Field expert submitted outer damage parts", }, }, }, }, ); if (!updated) { throw new InternalServerErrorException("Failed to update claim case"); } working = updated; } const hasBankOrOther = dto.sheba != null || dto.nationalCodeOfInsurer != null || dto.otherParts != null; if (hasBankOrOther) { const fresh = await this.claimCaseDbService.findById(claimRequestId); if (!fresh) throw new NotFoundException("Claim case not found"); if ( fresh.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OTHER_PARTS ) { throw new BadRequestException( `Bank/other parts can only be set at ${ClaimWorkflowStep.SELECT_OTHER_PARTS}. Current: ${fresh.workflow?.currentStep}`, ); } const mergedSheba = dto.sheba != null ? dto.sheba : fresh.money?.sheba; const mergedNational = dto.nationalCodeOfInsurer != null ? dto.nationalCodeOfInsurer : fresh.money?.nationalCodeOfInsurer; if ( fresh.money?.sheba && dto.sheba != null && dto.sheba !== fresh.money.sheba ) { throw new ConflictException("Sheba is already set for this claim."); } if ( fresh.money?.nationalCodeOfInsurer && dto.nationalCodeOfInsurer != null && dto.nationalCodeOfInsurer !== fresh.money.nationalCodeOfInsurer ) { throw new ConflictException( "National code of insurer is already set for this claim.", ); } const otherPartsArr = this.normalizeExpertOtherPartsInput(dto.otherParts); const setPatch: Record = {}; if (dto.sheba != null) setPatch["money.sheba"] = dto.sheba; if (dto.nationalCodeOfInsurer != null) { setPatch["money.nationalCodeOfInsurer"] = dto.nationalCodeOfInsurer; } if (dto.otherParts != null) { setPatch["damage.otherParts"] = otherPartsArr.length > 0 ? otherPartsArr : []; } const bankComplete = mergedSheba && mergedNational && /^[0-9]{24}$/.test(String(mergedSheba).replace(/\s/g, "")) && /^[0-9]{10}$/.test(String(mergedNational).replace(/\s/g, "")); if (bankComplete) { const shebaNorm = String(mergedSheba).replace(/\s/g, ""); const nationalNorm = String(mergedNational).replace(/\s/g, ""); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { "money.sheba": shebaNorm, "money.nationalCodeOfInsurer": nationalNorm, ...(dto.otherParts != null ? { "damage.otherParts": otherPartsArr.length > 0 ? otherPartsArr : [], } : {}), status: ClaimCaseStatus.CAPTURING_PART_DAMAGES, "workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES, "workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, $push: { "workflow.completedSteps": ClaimWorkflowStep.SELECT_OTHER_PARTS, history: { type: "STEP_COMPLETED", actor: { actorId: new Types.ObjectId(expert.sub), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim() || "field_expert", actorType: "field_expert", }, timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS, description: "Field expert submitted bank info and optional other parts", }, }, }, }); } else { if (Object.keys(setPatch).length === 0) { throw new BadRequestException( "Provide sheba (24 digits) and nationalCodeOfInsurer (10 digits) together to finish this step, or supply partial fields incrementally.", ); } await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: setPatch, }); } } return { success: true, claimRequestId }; } async selectCarOtherPartDamage( requestId: string, body: any, // Use your DTO for better type safety file: Express.Multer.File, actor: any, ) { try { // 1. Fetch the claim request and perform initial validation const claimRequest = await this.claimDbService.findOne(requestId); if (!claimRequest) { throw new NotFoundException("Claim file not found"); } // Verify access: user must own the claim, or expert must be accessing IN_PERSON file const effectiveUserId = await this.getEffectiveUserId(requestId, actor); if (String(claimRequest.userId) !== effectiveUserId) { throw new ForbiddenException( "You do not have access to this claim file", ); } if ( String(claimRequest.blameFile.expertSubmitReply?.guiltyUserId) === effectiveUserId ) { throw new ForbiddenException( "User access denied. You are the guilty party in the blame file.", ); } if (!claimRequest.carPlate) { throw new BadRequestException( "Cannot validate ownership: Plate information is missing from the claim file.", ); } // await this.sandHubService.getCarOwnershipInfo( // claimRequest.carPlate, // body.nationalCodeOfInsurer, // ); // await this.sandHubService.getShebaValidation( // body.nationalCodeOfInsurer, // body.sheba, // ); const greenCard = await this.carGreenCardDbService.create({ path: file.path, fileName: file.filename, claimId: requestId, }); if (!greenCard) { throw new BadGatewayException( "Failed to save car green card document.", ); } const updatePayload = { $set: { otherParts: JSON.parse(body.otherParts as any), nationalCodeOfInsurer: body.nationalCodeOfInsurer, sheba: body.sheba, carGreenCard: { path: buildFileLink(greenCard.path), fileName: greenCard.fileName, claimId: requestId, }, currentStep: ClaimStepsEnum.SelectOtherParts, nextStep: ClaimStepsEnum.ImageRequired, claimStatus: ReqClaimStatus.WaitingForUserCompleted, }, $push: { steps: ClaimStepsEnum.SelectOtherParts, }, }; this.logger.log( `[CLAIM] Moving to ImageRequired step for claim ${requestId}. Status set to: ${ReqClaimStatus.WaitingForUserCompleted}`, ); const updatedClaim = await this.claimDbService.findByIdAndUpdate( requestId, updatePayload, ); // 6. Return the successful response return new ClaimPartUploadDetail([updatedClaim]); } catch (error) { this.logger.error( `Error in selectCarOtherPartDamage: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined, ); // Re-throw the specific error from the validation steps or a generic one. throw error; } } /** * Get the list of required document types based on claim type and whether there's a second party car * For CAR_BODY type with accident with another object (not a car), only require 7 damaged party documents * Otherwise, require all 12 documents (7 damaged + 5 guilty party) */ private getRequiredDocumentTypes( claimRequest: any, ): ClaimRequiredDocumentType[] { const allTypes = Object.values(ClaimRequiredDocumentType); // Check if it's CAR_BODY type const isCarBody = claimRequest.blameFile?.type === "CAR_BODY"; if (isCarBody) { // Accident with object (not another car): v2 uses parties[0].carBodyFirstForm.object, v1 uses carBodyForm.carBodyForm.car === false const firstPartyCarBody = claimRequest.blameFile?.parties?.[0]?.carBodyFirstForm; const isAccidentWithOtherObject = (firstPartyCarBody && firstPartyCarBody.object === true) || claimRequest.blameFile?.carBodyForm?.carBodyForm?.car === false || !claimRequest.blameFile?.secondPartyDetails?.secondPartyCarDetail; if (isAccidentWithOtherObject) { this.logger.debug( `[getRequiredDocumentTypes] CAR_BODY claim with other object detected. ` + `Requiring only 7 damaged party documents for claim ${claimRequest._id}`, ); // Only require damaged party documents (7 documents) return allTypes.filter((type) => type.startsWith("damaged_")); } } // Default: require all 12 documents return allTypes; } async uploadRequiredDocument( requestId: string, documentType: ClaimRequiredDocumentType, file: Express.Multer.File, actor: any, ) { try { const claimRequest = await this.claimDbService.findOne(requestId); if (!claimRequest) { throw new NotFoundException("Claim file not found"); } // Verify access: user must own the claim, or expert must be accessing IN_PERSON file const effectiveUserId = await this.getEffectiveUserId(requestId, actor); if (String(claimRequest.userId) !== effectiveUserId) { throw new ForbiddenException( "You do not have access to this claim file", ); } if ( String(claimRequest.blameFile.expertSubmitReply?.guiltyUserId) === effectiveUserId ) { throw new ForbiddenException( "User access denied. You are the guilty party in the blame file.", ); } if (claimRequest.currentStep !== ClaimStepsEnum.UploadRequiredDocuments) { throw new BadRequestException( `Claim is not in the required documents upload step. Current step: ${claimRequest.currentStep}`, ); } if (!file) { throw new BadRequestException("File is required"); } // Check if this document type has already been uploaded const existingDocuments = await this.claimRequiredDocumentDbService.findAll({ claimId: new Types.ObjectId(requestId), documentType: documentType, }); // If document exists, delete the old one first (allow replacement) if (existingDocuments.length > 0) { for (const existingDoc of existingDocuments) { // Delete the file from filesystem try { if (existingDoc.path && existsSync(existingDoc.path)) { await unlink(existingDoc.path); this.logger.log( `Deleted old file: ${existingDoc.path} for document type ${documentType}`, ); } } catch (fileError) { this.logger.warn( `Failed to delete file ${existingDoc.path}: ${fileError instanceof Error ? fileError.message : String(fileError)}`, ); // Continue even if file deletion fails } // Delete from the separate collection await this.claimRequiredDocumentDbService.delete( existingDoc._id.toString(), ); // Remove from claim's requiredDocuments map await this.claimDbService.findOneAndUpdate( { _id: new Types.ObjectId(requestId) }, { $unset: { [`requiredDocuments.${documentType}`]: "", }, }, ); } } // Create the document record const documentRecord = await this.claimRequiredDocumentDbService.create({ path: file.path, fileName: file.filename, claimId: new Types.ObjectId(requestId), documentType: documentType, uploadedAt: new Date(), }); // Add document ID to claim's requiredDocuments map using documentType as key await this.claimDbService.findOneAndUpdate( { _id: new Types.ObjectId(requestId) }, { $set: { [`requiredDocuments.${documentType}`]: documentRecord._id, }, }, ); // Check if all required documents have been uploaded // For CAR_BODY with other object, only require 7 documents instead of 12 const allRequiredTypes = this.getRequiredDocumentTypes(claimRequest); const uploadedDocuments = await this.claimRequiredDocumentDbService.findAll({ claimId: new Types.ObjectId(requestId), }); const uploadedTypes = uploadedDocuments.map((doc) => doc.documentType); const allUploaded = allRequiredTypes.every((type) => uploadedTypes.includes(type), ); // If all documents are uploaded, move to next step if (allUploaded) { await this.claimDbService.findAndUpdate(requestId, { $set: { currentStep: ClaimStepsEnum.UploadRequiredDocuments, nextStep: ClaimStepsEnum.waitForDamageExpertComment, claimStatus: ReqClaimStatus.UnChecked, }, $push: { steps: ClaimStepsEnum.UploadRequiredDocuments, }, }); } else { // Ensure status is set when entering this step (first document upload) const currentClaim = await this.claimDbService.findOne(requestId); if ( currentClaim?.claimStatus !== ReqClaimStatus.UploadingRequiredDocuments ) { await this.claimDbService.findOneAndUpdate( { _id: new Types.ObjectId(requestId) }, { $set: { currentStep: ClaimStepsEnum.UploadRequiredDocuments, nextStep: ClaimStepsEnum.UploadRequiredDocuments, claimStatus: ReqClaimStatus.UploadingRequiredDocuments, }, }, ); } } return { message: "Document uploaded successfully", documentId: documentRecord._id, documentType: documentType, allDocumentsUploaded: allUploaded, nextStep: allUploaded ? ClaimStepsEnum.waitForDamageExpertComment : null, }; } catch (error) { this.logger.error( `Error in uploadRequiredDocument: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined, ); throw error; } } async getRequiredDocumentsStatus(requestId: string) { try { const claimRequest = await this.claimDbService.findOne(requestId); if (!claimRequest) { throw new NotFoundException("Claim file not found"); } // Get required document types based on claim type const allRequiredTypes = this.getRequiredDocumentTypes(claimRequest); const uploadedDocuments = await this.claimRequiredDocumentDbService.findAll({ claimId: new Types.ObjectId(requestId), }); const uploadedTypes = uploadedDocuments.map((doc) => doc.documentType); const status = allRequiredTypes.map((type) => { const doc = uploadedDocuments.find((d) => d.documentType === type); return { documentType: type, uploaded: !!doc, documentId: doc?._id || null, fileName: doc?.fileName || null, uploadedAt: doc?.uploadedAt || null, }; }); return { claimId: requestId, currentStep: claimRequest.currentStep, nextStep: claimRequest.nextStep, allUploaded: allRequiredTypes.every((type) => uploadedTypes.includes(type), ), documents: status, totalRequired: allRequiredTypes.length, totalUploaded: uploadedDocuments.length, }; } catch (error) { this.logger.error( `Error in getRequiredDocumentsStatus: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined, ); throw error; } } async getImageRequiredList(requestId: string) { return await new Promise((resolve, reject) => { this.claimDbService .findOne(requestId) .then((cl) => { if (!cl) { throw new HttpException( "Claim File Not Found", HttpStatus.NOT_FOUND, ); } // Allow access if: // 1. Currently in ImageRequired step (currentStep or nextStep is ImageRequired) // 2. OR has completed ImageRequired and moved to next step (to allow viewing data after completion) const isInImageRequiredStep = cl.currentStep === ClaimStepsEnum.ImageRequired || cl.nextStep === ClaimStepsEnum.ImageRequired; const hasCompletedImageRequired = cl.steps && Array.isArray(cl.steps) && cl.steps.includes(ClaimStepsEnum.ImageRequired); if (isInImageRequiredStep || hasCompletedImageRequired) { this.logger.debug( `[getImageRequiredList] Allowing access for claim ${requestId}. ` + `currentStep: ${cl.currentStep}, nextStep: ${cl.nextStep}, ` + `hasCompletedImageRequired: ${hasCompletedImageRequired}`, ); if ( cl.imageRequired.aroundTheCar && Array.isArray(cl.imageRequired.aroundTheCar) ) { cl.imageRequired.aroundTheCar.forEach((carPart) => { //@ts-ignore delete carPart?.aiReport; }); cl.imageRequired.selectPartOfCar.forEach((part) => { delete part.aiReport; }); } resolve(new ImageRequiredDto(cl)); } else { this.logger.warn( `[getImageRequiredList] Access denied for claim ${requestId}. ` + `currentStep: ${cl.currentStep}, nextStep: ${cl.nextStep}, ` + `steps: ${JSON.stringify(cl.steps)}`, ); throw new HttpException( "This File is Not in ImageRequired Step", HttpStatus.FORBIDDEN, ); } }) .catch((er) => { this.logger.error(er); if (er instanceof Error) { reject(er); } else { reject(new Error(`Promise rejected with non-error value: ${er}`)); } }); }); } async setImageRequiredList( requestId: string, partOfDamageId: string, fileDetail: any, ) { try { const document = await this.claimDbService.findOneDocument(requestId); if (!document) { throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND); } let result = []; const imageRequiredKeys = Object.keys(document.imageRequired); if (partOfDamageId === undefined) { if (document.videoCaptureId) { throw new HttpException("Video already exists", HttpStatus.CONFLICT); } const videoId = await this.createVideoCapture(fileDetail, requestId); await this.updateClaimWithVideoCapture(requestId, videoId); throw new HttpException("Video capture uploaded", HttpStatus.ACCEPTED); } else if (partOfDamageId) { await this.processDamageImages( document, requestId, partOfDamageId, fileDetail, imageRequiredKeys, result, ); result.forEach((item) => { if (item.aroundTheCar && Array.isArray(item.aroundTheCar)) { item.aroundTheCar.forEach((carPart) => { delete carPart.aiReport; }); item.selectPartOfCar.forEach((part) => { delete part.aiReport; }); } }); } return result; } catch (error) { this.handleError(error); } } async setDamageImage( requestId: string, partOfDamageId: string, fileDetail: Express.Multer.File, ) { try { const document = await this.claimDbService.findOneDocument(requestId); if (!document) { throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND); } if (!fileDetail) { throw new BadRequestException("Image file is required."); } if ( document.currentStep !== ClaimStepsEnum.ImageRequired && document.nextStep !== ClaimStepsEnum.ImageRequired ) { throw new BadRequestException( `Claim is not in the ImageRequired step. Current step: ${document.currentStep}, Status: ${document.claimStatus}`, ); } const result = []; const imageRequiredKeys = Object.keys(document.imageRequired); // Process images - AI will be processed in background await this.processDamageImages( document, requestId, partOfDamageId, fileDetail, imageRequiredKeys, result, ); // Clean up the response - remove AI reports since they're processing in background result.forEach((item) => { if (item.aroundTheCar && Array.isArray(item.aroundTheCar)) { item.aroundTheCar.forEach((carPart) => delete carPart.aiReport); item.selectPartOfCar.forEach((part) => delete part.aiReport); } }); this.logger.log( `[setDamageImage] Image uploaded successfully for part ${partOfDamageId}, request ${requestId}. AI processing started in background.`, ); return result; } catch (error) { this.handleError(error); } } async setVideoCapture(requestId: string, fileDetail: Express.Multer.File) { try { const document = await this.claimDbService.findOneDocument(requestId); if (!document) { throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND); } if (!fileDetail) { throw new BadRequestException("Video file is required."); } if (document.videoCaptureId) { throw new HttpException( "A video has already been uploaded for this claim.", HttpStatus.CONFLICT, ); } const videoId = await this.createVideoCapture(fileDetail, requestId); await this.updateClaimWithVideoCapture(requestId, videoId); return { statusCode: HttpStatus.ACCEPTED, message: "Video capture uploaded successfully.", videoId: videoId, }; } catch (error) { this.handleError(error); } } private async createVideoCapture(fileDetail: any, requestId: string) { const videoId = await this.videoCaptureDbService.create({ path: fileDetail.path, filName: fileDetail.filename, claimId: new Types.ObjectId(requestId), }); return videoId; } private async updateClaimWithVideoCapture(requestId: string, videoId: any) { if ( await this.claimDbService.findOneAndUpdate( { _id: new Types.ObjectId(requestId) }, { $set: { videoCaptureId: videoId }, }, ) ) { throw new HttpException("Video capture uploaded", HttpStatus.ACCEPTED); } } private async processAiRequestQueue( tasks: (() => Promise)[], delayMs = 200, ): Promise { for (const task of tasks) { await task(); await this.delay(delayMs); } } /** * Process AI requests in the background without blocking the response * This allows users to upload images quickly without waiting for AI processing */ private async processAiRequestQueueInBackground( tasks: (() => Promise)[], requestId: string, partId: string, ): Promise { if (tasks.length === 0) { return; } this.logger.log( `[BACKGROUND AI] Starting background AI processing for ${tasks.length} image(s), part ${partId}, request ${requestId}`, ); // Process in background without blocking setImmediate(async () => { try { await this.processAiRequestQueue(tasks); this.logger.log( `[BACKGROUND AI] Completed AI processing for part ${partId}, request ${requestId}`, ); } catch (error) { this.logger.error( `[BACKGROUND AI] Failed to process AI requests for part ${partId}, request ${requestId}: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined, ); // Don't throw - we're in background processing } }); } private async handleAiSuccess( response: any, key: string, partId: string, requestId: string, ): Promise { this.logger.log( `[STEP 4/4] Processing AI response for part ${partId}, requestId: ${requestId}`, ); // 1. Validate that we have the processed image if (!response?.downloadLink) { this.logger.error( `[ERROR] Missing processed image (downloadLink) in AI response`, ); this.logger.error(`[ERROR] Part ID: ${partId}, Request ID: ${requestId}`); this.logger.error( `[ERROR] Response keys: ${JSON.stringify(response ? Object.keys(response) : "null")}`, ); this.logger.error( `[ERROR] Full response: ${JSON.stringify(response, null, 2)}`, ); throw new HttpException( `AI response missing processed image (downloadLink) for part ${partId}`, HttpStatus.BAD_GATEWAY, ); } this.logger.log(`[STEP 4/4] Processed image URL: ${response.downloadLink}`); // 2. Safely access the nested properties. const reports = response?.reports; const reportText = reports?.distinct_damaged_parts_report?.report; // 3. Log warning if reports are missing but continue with image if (!reports) { this.logger.warn( `[WARNING] AI response for part ${partId} is missing the 'reports' object, but downloadLink exists.`, ); this.logger.warn(`[WARNING] Will save image but without AI report data.`); } else { this.logger.log( `[STEP 4/4] AI reports present: ${reports ? "yes" : "no"}`, ); } // 4. Build the update payload. const updatePayload = { $set: { [`imageRequired.${key}.$[elem].aiReport`]: reports || {}, [`imageRequired.${key}.$[elem].aiImage`]: response.downloadLink, [`imageRequired.${key}.$[elem].aiReportText`]: reportText || "", [`imageRequired.aiReportText`]: response.reportsText || "", }, }; // 5. Update the database using arrayFilters. try { const updateResult = await this.claimDbService.findOneAndUpdate( { _id: new Types.ObjectId(requestId) }, updatePayload, { arrayFilters: [{ "elem.partId": partId }], }, ); if (!updateResult) { this.logger.error( `[ERROR] Database update failed - no document found or update failed`, ); this.logger.error( `[ERROR] Request ID: ${requestId}, Part ID: ${partId}, Key: ${key}`, ); throw new HttpException( `Failed to update database with AI results for part ${partId}`, HttpStatus.INTERNAL_SERVER_ERROR, ); } this.logger.log( `[SUCCESS] Successfully updated AI report and image for part ${partId}`, ); } catch (dbError) { const dbErrorMessage = dbError instanceof Error ? dbError.message : String(dbError); this.logger.error(`[ERROR] Database update error for part ${partId}:`); this.logger.error(`[ERROR] ${dbErrorMessage}`); this.logger.error( `[ERROR] Stack: ${dbError instanceof Error ? dbError.stack : undefined}`, ); throw new HttpException( `Database update failed: ${dbErrorMessage}`, HttpStatus.INTERNAL_SERVER_ERROR, ); } } private async retryAiRequest( newImageDoc: any, key: string, partId: string, requestId: string, retries = 3, ): Promise { const attemptNumber = 4 - retries; // Track which attempt this is this.logger.log( `[ATTEMPT ${attemptNumber}] Processing AI request for part ${partId}, requestId: ${requestId}`, ); this.logger.log( `[ATTEMPT ${attemptNumber}] File path: ${newImageDoc.path}, File name: ${newImageDoc.fileName}`, ); try { // Use the new, robust AiService to send the request. // Pass both path and fileName to the AI service const response = await this.aiService.aiRequestImage({ path: newImageDoc.path, fileName: newImageDoc.fileName, }); // If the request succeeds, handle the success. await this.handleAiSuccess(response, key, partId, requestId); this.logger.log( `[SUCCESS] AI processing completed successfully for part ${partId}`, ); } catch (error) { // Extract error source from error message if available const errorMessage = error instanceof Error ? error.message : String(error); const errorSource = errorMessage.includes("[ERROR]") ? errorMessage.split("[ERROR]")[1]?.split("]")[0] || "UNKNOWN" : "UNKNOWN"; this.logger.error( `[ATTEMPT ${attemptNumber} FAILED] Error source: ${errorSource}`, ); this.logger.error( `[ATTEMPT ${attemptNumber} FAILED] Part ID: ${partId}, Request ID: ${requestId}`, ); this.logger.error( `[ATTEMPT ${attemptNumber} FAILED] Error: ${errorMessage}`, ); if (retries > 0) { this.logger.warn( `⚠️ Retrying AI request for part ${partId}... Attempts left: ${retries - 1}`, ); await this.delay(1000); // Wait before retrying return this.retryAiRequest( newImageDoc, key, partId, requestId, retries - 1, ); } else { this.logger.error( `🔴 [FINAL FAILURE] AI request failed after all retries`, ); this.logger.error(`🔴 [FINAL FAILURE] Request ID: ${requestId}`); this.logger.error(`🔴 [FINAL FAILURE] Part ID: ${partId}`); this.logger.error(`🔴 [FINAL FAILURE] File path: ${newImageDoc.path}`); this.logger.error(`🔴 [FINAL FAILURE] Error source: ${errorSource}`); this.logger.error(`🔴 [FINAL FAILURE] Final error: ${errorMessage}`); if (error instanceof Error && error.stack) { this.logger.error(`🔴 [FINAL FAILURE] Stack trace: ${error.stack}`); } // Optional: Update the claim status to indicate an AI failure. // You could throw here if you want to fail the entire operation throw error; // Re-throw to propagate the error } } } private async processDamageImages( document: any, requestId: string, partOfDamageId: string, fileDetail: any, imageRequiredKeys: string[], result: any[], ) { const aiRequestQueue = []; for (const key of imageRequiredKeys) { const images = document.imageRequired[key]; for (const img of images) { if (img.partId === partOfDamageId) { const newImageDoc = await this.createDamageImage( fileDetail, requestId, ); // Queue AI request for background processing const aiRequestTask = () => this.retryAiRequest(newImageDoc, key, partOfDamageId, requestId); aiRequestQueue.push(aiRequestTask); // Update the document immediately with the uploaded image await this.updateImageRequiredDocument( requestId, key, partOfDamageId, newImageDoc, ); const updatedDocs = await this.claimDbService.findOneDocument(requestId); this.handleImageUpdateResult(result, updatedDocs, requestId); } } } // Process AI requests in the background (fire and forget) // Don't await - let it run asynchronously so user doesn't have to wait this.processAiRequestQueueInBackground( aiRequestQueue, requestId, partOfDamageId, ).catch((error) => { // Log errors but don't fail the upload this.logger.error( `[BACKGROUND AI] Error processing AI requests for part ${partOfDamageId}, request ${requestId}: ${error.message}`, error.stack, ); }); } private async updateImageRequiredDocument( requestId, key, partOfDamageId, newImageDoc, ) { await this.claimDbService.findOneAndUpdate( { _id: new Types.ObjectId(requestId) }, { $set: { [`imageRequired.${key}.$[elem].upload`]: true, [`imageRequired.${key}.$[elem].imageId`]: newImageDoc._id, }, }, { arrayFilters: [{ "elem.partId": partOfDamageId }], }, ); } private async createDamageImage(fileDetail, requestId) { return await this.damageImageDbService.create({ path: fileDetail.path, fileName: fileDetail.filename, claimId: requestId, }); } private async handleImageUpdateResult(result, updatedDocs, requestId) { const aroundCarChecked = updatedDocs.imageRequired.aroundTheCar.filter( (r) => !r.upload, ); const selectPartOfCar = updatedDocs.imageRequired.selectPartOfCar.filter( (r) => !r.upload, ); result.push(updatedDocs.imageRequired); if (aroundCarChecked.length === 0 && selectPartOfCar.length === 0) { await this.claimDbService.findOneAndUpdate( { _id: new Types.ObjectId(requestId) }, { $push: { steps: ClaimStepsEnum.ImageRequired, }, $set: { currentStep: ClaimStepsEnum.UploadRequiredDocuments, nextStep: ClaimStepsEnum.UploadRequiredDocuments, claimStatus: ReqClaimStatus.UploadingRequiredDocuments, }, }, ); } } async myRequests(currentUser) { // For users, return their own claim files if (currentUser.role === RoleEnum.USER) { const claimFile = await this.claimDbService.findAllByAnyFilter({ userId: new Types.ObjectId(currentUser.sub), }); if (!claimFile) throw new NotFoundException("claim file not found"); return new MyRequestsDto(claimFile); } // For experts, return IN_PERSON claim files they initiated const expertRoles = [ RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT, RoleEnum.FIELD_EXPERT, ]; if (expertRoles.includes(currentUser.role)) { const allClaims = await this.claimDbService.findAllByAnyFilter({}); const expertClaims = allClaims.filter((claim) => { const blameFile = claim.blameFile; return ( blameFile?.expertInitiated && blameFile?.creationMethod === "IN_PERSON" && String(blameFile.initiatedBy) === currentUser.sub ); }); if (expertClaims.length === 0) { throw new NotFoundException("No IN_PERSON claim files found"); } return new MyRequestsDto(expertClaims); } throw new ForbiddenException("Invalid role"); } private waitingForUserResendRS(req: ClaimRequestManagementModel) { if (req.damageExpertResend) { return new ClaimRequestDtoRs( req.damageExpertResend as any, "این درخواست نیاز به ارسال مدارک مجدد دارد ", ReqClaimStatus.WaitingForUserToResend, ); } else { throw new ForbiddenException("damageExpertResend doesnt exist"); } } private closeRequestRS(req: ClaimRequestManagementModel) { return new ClaimRequestDtoRs( req, "این درخواست بسته شده است ", ReqClaimStatus.CloseRequest, ); } private checkAgainRS(req: ClaimRequestManagementModel) { return new ClaimRequestDtoRs( req, "در انتظار پاسخ مجدد کارشناس", ReqClaimStatus.CheckAgain, ); } private userPendingRS(req: ClaimRequestManagementModel) { return new ClaimRequestDtoRs( req, "این پرونده از طرف شما کامل نشده میباشد", ReqClaimStatus.UserPending, ); } private reviewRequestRS(req: ClaimRequestManagementModel) { return new ClaimRequestDtoRs( req, `درخواست درحال برسی توسط کارشناس میباشد ${req.unlockTime} - زمان باقی مانده لطفا صبر کنید`, ReqClaimStatus.ReviewRequest, ); } private checkedRequestRS(req: ClaimRequestManagementModel) { return new ClaimRequestDtoRs( req, "این درخواست بررسی شده است", ReqClaimStatus.CheckedRequest, ); } private uncheckedRequestRS(req: ClaimRequestManagementModel) { return new ClaimRequestDtoRs( req, "این درخواست در انتظار بررسی می باشد.", ReqClaimStatus.UnChecked, ); } private waitingForUserCompletedRS(req: ClaimRequestManagementModel) { return new ClaimRequestDtoRs( req, "این درخواست نیازمند تکمیل توسط کاربر می باشد.", ReqClaimStatus.WaitingForUserCompleted, ); } private inPersonVisitRS(req: ClaimRequestManagementModel) { return new ClaimRequestDtoRs( req, "مراجعه حضوری", ReqClaimStatus.InPersonVisit, ); } private responseController(request: ClaimRequestManagementModel) { switch (request.claimStatus) { case ReqClaimStatus.WaitingForUserCompleted: return this.waitingForUserCompletedRS(request); case ReqClaimStatus.UnChecked: return this.uncheckedRequestRS(request); case ReqClaimStatus.CheckedRequest: return this.checkedRequestRS(request); case ReqClaimStatus.ReviewRequest: return this.reviewRequestRS(request); case ReqClaimStatus.UserPending: return this.userPendingRS(request); case ReqClaimStatus.CloseRequest: return this.closeRequestRS(request); case ReqClaimStatus.WaitingForUserToResend: return this.waitingForUserResendRS(request); case ReqClaimStatus.CheckAgain: return this.checkAgainRS(request); case ReqClaimStatus.InPersonVisit: return this.inPersonVisitRS(request); case ReqClaimStatus.PendingFactorValidation: return this.pendingFactorValidationRS(request); case ReqClaimStatus.FactorRejected: return this.factorRejectedRS(request); case ReqClaimStatus.PendingFactorUpload: return this.pendingFactorUploadRS(request); case ReqClaimStatus.UploadingRequiredDocuments: return this.uploadingRequiredDocumentsRS(request); default: return "Unknown status"; } } private pendingFactorUploadRS(req: ClaimRequestManagementModel) { return new ClaimRequestDtoRs( req, "منتظر آپلود فاکتور توسط کاربر", ReqClaimStatus.PendingFactorUpload, ); } private pendingFactorValidationRS(req: ClaimRequestManagementModel) { return new ClaimRequestDtoRs( req, "منتظر تایید فاکتور توسط کارشناس", ReqClaimStatus.PendingFactorValidation, ); } private factorRejectedRS(req: ClaimRequestManagementModel) { return new ClaimRequestDtoRs( req, "فاکتور ریجکت شده و باید دوباره ارسال شود", ReqClaimStatus.FactorRejected, ); } private uploadingRequiredDocumentsRS(req: ClaimRequestManagementModel) { return new ClaimRequestDtoRs( req, "منتظر آپلود مدارک مورد نیاز توسط کاربر", ReqClaimStatus.UploadingRequiredDocuments, ); } async requestDetails(requestId: string, user?: any) { const request = await this.claimDbService.findOne(requestId); if (!request) { throw new NotFoundException("Claim request not found"); } // Verify access if user is provided if (user) { const effectiveUserId = await this.getEffectiveUserId(requestId, user); if (String(request.userId) !== effectiveUserId) { throw new ForbiddenException( "You do not have access to this claim file", ); } } if (request.imageRequired) { request.imageRequired.selectPartOfCar.forEach((r) => { delete r.aiReport; }); request.imageRequired.aroundTheCar.forEach((r) => { //@ts-ignore delete r.aiReport; }); } // Populate factorLink ObjectIds with actual file URLs await this.populateFactorLinks(request.damageExpertReply); await this.populateFactorLinks(request.damageExpertReplyFinal); return this.responseController(request); } /** * Converts factorLink ObjectIds to file URLs */ private async populateFactorLinks(reply: any): Promise { if (!reply || !Array.isArray(reply.parts)) { return; } for (const part of reply.parts) { if (part.factorLink && Types.ObjectId.isValid(part.factorLink)) { const factorDoc = await this.claimFactorsImageDbService.findById( part.factorLink.toString(), ); if (factorDoc) { part.factorLink = buildFileLink(factorDoc.path); } } } } /** * V2 response mapper: * - convert factorLink ObjectId -> public URL * - enrich legacy daghi.branchId with branchName when available */ private async mapEvaluationForClient( evaluation: any, carType?: ClaimVehicleTypeV2, ): Promise { if (!evaluation) return undefined; const mapReply = async (reply: any) => { if (!reply) return reply; const mapped = { ...(typeof reply?.toObject === "function" ? reply.toObject() : reply), }; const parts = Array.isArray(mapped.parts) ? mapped.parts : []; const factorIds = new Set(); const branchIds = new Set(); for (const p of parts) { if (p?.factorLink && Types.ObjectId.isValid(String(p.factorLink))) { factorIds.add(String(p.factorLink)); } const daghi = p?.daghi; const rawBranchId = daghi && typeof daghi === "object" ? (daghi as any).branchId : undefined; if (rawBranchId && Types.ObjectId.isValid(String(rawBranchId))) { branchIds.add(String(rawBranchId)); } } const factorMap = new Map(); await Promise.all( Array.from(factorIds).map(async (id) => { const factorDoc = await this.claimFactorsImageDbService.findById(id); if (factorDoc?.path) { factorMap.set(id, buildFileLink(factorDoc.path)); } }), ); const branchMap = new Map(); await Promise.all( Array.from(branchIds).map(async (id) => { const branch = await this.branchDbService.findById(id); if (branch?.name) { branchMap.set(id, branch.name); } }), ); mapped.parts = parts.map((part: any) => { const nextPart = { ...part }; if ( nextPart.factorLink && Types.ObjectId.isValid(String(nextPart.factorLink)) ) { const link = factorMap.get(String(nextPart.factorLink)); if (link) nextPart.factorLink = link; } const daghi = nextPart.daghi; if (daghi && typeof daghi === "object" && !Array.isArray(daghi)) { const branchId = (daghi as any).branchId; const branchName = branchId && Types.ObjectId.isValid(String(branchId)) ? branchMap.get(String(branchId)) : undefined; nextPart.daghi = { ...daghi, ...(branchName ? { branchName } : {}), }; } if (nextPart.carPartDamage != null) { nextPart.carPartDamage = migrateExpertReplyCarPartDamageToUnified( nextPart.carPartDamage, carType, ); } return nextPart; }); return mapped; }; // Resolve objection invoice paths to downloadable URLs (user-side view). const objectionRaw = evaluation.objection; let objection: any = objectionRaw ? typeof objectionRaw?.toObject === "function" ? objectionRaw.toObject() : { ...objectionRaw } : undefined; if (objection && Array.isArray(objection.invoices) && objection.invoices.length > 0) { objection = { ...objection, invoices: objection.invoices.map((inv: any) => ({ ...inv, url: inv.path ? buildFileLink(inv.path) : undefined, })), }; } return { ...evaluation, damageExpertReply: await mapReply(evaluation.damageExpertReply), damageExpertReplyFinal: await mapReply(evaluation.damageExpertReplyFinal), ...(objection !== undefined ? { objection } : {}), }; } // async submitUserReply( // requestId: string, // body: UserCommentDto, // file?: Express.Multer.File, // actor?: any, // ) { // try { // const request = await this.claimDbService.findOne(requestId); // if (!this.isValidRequest(request)) { // throw new HttpException("Invalid request", HttpStatus.NOT_ACCEPTABLE); // } // // Verify access if actor is provided // if (actor) { // const effectiveUserId = await this.getEffectiveUserId(requestId, actor); // if (String(request.userId) !== effectiveUserId) { // throw new ForbiddenException( // "You do not have access to this claim file", // ); // } // } // const finalReply = // request.damageExpertReplyFinal || request.damageExpertReply; // const replyFieldKey = request.damageExpertReplyFinal // ? "damageExpertReplyFinal" // : "damageExpertReply"; // const parts = finalReply?.parts || []; // const missingFactors = parts // .filter((p) => p.factorNeeded === true && !p.factorLink) // .map( // (p) => carPartDamageLabelForFactorRecord(p.carPartDamage) || p.partId, // ); // if (missingFactors.length > 0) { // throw new HttpException( // `Missing factor images for parts: ${missingFactors.join(", ")}`, // HttpStatus.BAD_REQUEST, // ); // } // const userComment = { // ...body, // ...(file ? { fileLink: buildFileLink(file.path) } : {}), // }; // await this.claimDbService.findAndUpdate(requestId, { // $set: { // [`${replyFieldKey}.userComment`]: { // ...userComment, // time: Date.now(), // }, // }, // }); // if (body.isAccept) { // const { partsFactorDetail, partsNeedFactor } = // await this.processAcceptance(request, file, requestId, body); // await this.claimDbService.findAndUpdate(requestId, { // $set: { // claimStatus: ReqClaimStatus.CloseRequest, // }, // }); // return new SubmitUserReplyDtoRs( // request, // partsFactorDetail, // partsNeedFactor, // ); // } else { // this.handleRejection(request, body.isAccept, requestId); // return { requestId, isAccept: body.isAccept }; // } // } catch (error) { // this.handleError(error); // } // } private isValidRequest(request: any): boolean { return ( request.damageExpertReply && request.claimStatus === ReqClaimStatus.CheckedRequest ); } // private async processAcceptance( // request: any, // file: any, // requestId: string, // body: UserCommentDto, // ): Promise<{ partsFactorDetail: any[]; partsNeedFactor: boolean }> { // const partsFactorDetail = []; // let partsNeedFactor = false; // for (let i = 0; i < request.damageExpertReply.parts.length; i++) { // const part = request.damageExpertReply.parts[i]; // if (!part || part.partCost === undefined) continue; // if (part.partCost === "FACTOR") { // partsNeedFactor = await this.handleFactorPart( // requestId, // i, // part, // partsFactorDetail, // partsNeedFactor, // ); // } else { // await this.handleNonFactorPart(requestId, file, body.isAccept, part); // } // } // return { partsFactorDetail, partsNeedFactor }; // } // private async handleFactorPart( // requestId: string, // i: number, // part: any, // partsFactorDetail: any[], // partsNeedFactor: boolean, // ): Promise { // if (part.needFactor === undefined) { // const uId = uuidv4(); // partsNeedFactor = true; // await this.claimDbService.findAndUpdate(requestId, { // $set: { // [`damageExpertReply.parts.${i}.needFactor`]: true, // [`damageExpertReply.parts.${i}.partId`]: uId, // }, // }); // partsFactorDetail.push({ partId: uId, ...part }); // } else { // partsFactorDetail.push(part); // } // return partsNeedFactor; // } // private async handleNonFactorPart( // requestId: string, // file: any, // isAccept: boolean, // part: any, // ): Promise { // const sign = await this.claimSignDbService.create(file); // await this.claimDbService.findAndUpdate(requestId, { // $set: { // "damageExpertReply.userComment.isAccept": isAccept, // "damageExpertReply.userComment.signId": sign["_id"], // }, // }); // } private async handleRejection( request: any, isAccept: boolean, requestId: string, ): Promise { await this.claimDbService.findAndUpdate(requestId, { $set: { "damageExpertReply.userComment.isAccept": isAccept, "damageExpertReply.userComment.signId": null, }, }); } /** * Allows the damaged user to rate their claim file after completion. * Only the claim owner (damaged user) can submit this rating and only * when the claim is in CloseRequest status. */ async addUserRating( claimRequestId: string, actor: any, ratingDto: UserRatingDto, ): Promise { const claim = await this.claimDbService.findOne(claimRequestId); if (!claim) { throw new NotFoundException("Claim file not found"); } // Only the damaged user (claim owner) can rate the claim const actorId = actor?.sub; if (!actorId || String(claim.userId) !== actorId) { throw new ForbiddenException( "Only the claim owner can submit a satisfaction rating.", ); } // Rating is only allowed after the claim is fully closed if (claim.claimStatus !== ReqClaimStatus.CloseRequest) { throw new BadRequestException( "You can only rate a claim after it has been closed.", ); } const userRating: UserClaimRating = { progressSpeed: ratingDto.progressSpeed, registrationEase: ratingDto.registrationEase, overallEvaluation: ratingDto.overallEvaluation, comment: ratingDto.comment, createdAt: new Date(), }; await this.claimDbService.findAndUpdate(claimRequestId, { $set: { userRating }, }); return userRating; } async uploadClaimFactor( claimId: string, partId: string, file: Express.Multer.File, actor: any, ) { try { if (!file) { throw new BadRequestException("A factor file is required for upload."); } const claim = await this.claimRequestManagementDbService.findOne(claimId); if (!claim) { throw new NotFoundException("Claim not found"); } // Verify access: user must own the claim, or expert must be accessing IN_PERSON file const effectiveUserId = await this.getEffectiveUserId(claimId, actor); if (String(claim.userId) !== effectiveUserId) { throw new ForbiddenException( "You do not have access to this claim file", ); } const finalReply = claim.damageExpertReplyFinal || claim.damageExpertReply; const replyFieldKey = claim.damageExpertReplyFinal ? "damageExpertReplyFinal" : "damageExpertReply"; if (!finalReply) { throw new BadRequestException("No expert reply found in this claim."); } const part = finalReply.parts?.find((p) => p.partId === String(partId)); if (!part) { throw new BadRequestException( "The specified part was not found in the expert's reply.", ); } const factorRecord = await this.claimFactorsImageDbService.create({ claimId, partId, partName: carPartDamageLabelForFactorRecord(part.carPartDamage), path: file.path, fileName: file.filename, uploadedAt: new Date(), }); const updatedClaim = await this.claimRequestManagementDbService.findOneAndUpdate( { _id: new Types.ObjectId(claimId), [`${replyFieldKey}.parts.partId`]: partId, }, { $set: { [`${replyFieldKey}.parts.$.factorLink`]: factorRecord._id, [`${replyFieldKey}.parts.$.factorStatus`]: FactorStatus.PENDING, }, }, ); if (!updatedClaim) { throw new NotFoundException( "Could not find the specific part to update in the database.", ); } await this.checkAndTransitionStatusAfterUpload(claimId); return { message: "Factor uploaded successfully. Awaiting expert validation.", factorId: factorRecord._id, factorLink: buildFileLink(file.path), url: buildFileLink(file.path), }; } catch (error) { this.logger.error( `Error during factor upload for claim ${claimId}:`, error instanceof Error ? error.stack : String(error), ); if (error instanceof HttpException) { throw error; } throw new HttpException( "An internal server error occurred during factor upload.", HttpStatus.INTERNAL_SERVER_ERROR, ); } } /** * V2: Upload repair factor file for a part marked `factorNeeded` on the expert reply (ClaimCase). * Same behavior as v1 {@link uploadClaimFactor} but uses `evaluation.damageExpertReply` / `damageExpertReplyFinal`. */ async uploadClaimFactorV2( claimRequestId: string, partId: string, file: Express.Multer.File, currentUserId: string, actor?: { sub: string; role?: string }, ) { try { if (!file) { throw new BadRequestException("A factor file is required for upload."); } const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim not found"); } const effectiveUserId = await this.resolveClaimEffectiveUserId( claim, currentUserId, actor?.role, ); await this.assertEffectiveUserIsDamagedPartyOnClaim( claim, effectiveUserId, actor as { username?: string }, "You do not have access to this claim file", ); const replyKey = claim.evaluation?.damageExpertReplyFinal ? "damageExpertReplyFinal" : "damageExpertReply"; const finalReply = replyKey === "damageExpertReplyFinal" ? claim.evaluation?.damageExpertReplyFinal : claim.evaluation?.damageExpertReply; if (!finalReply?.parts?.length) { throw new BadRequestException("No expert reply found in this claim."); } const part = finalReply.parts.find( (p) => String(p.partId) === String(partId), ); if (!part) { throw new BadRequestException( "The specified part was not found in the expert's reply.", ); } if (!part.factorNeeded) { throw new BadRequestException( "This part does not require a factor upload.", ); } if (part.factorLink) { throw new ConflictException( "A factor has already been uploaded for this part.", ); } if (!claimCaseStatusAllowsOwnerFactorUploadDuringRevision(claim.status)) { throw new BadRequestException( "Factors can only be uploaded while the claim is in a repair-factor upload phase (see ClaimCaseStatus).", ); } if (claim.claimStatus !== ClaimStatus.NEEDS_REVISION) { throw new BadRequestException( "Factors can only be uploaded while the claim needs owner action (claimStatus NEEDS_REVISION).", ); } if ( claim.workflow?.currentStep !== ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS ) { throw new BadRequestException( `Repair factors are uploaded at workflow step ${ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS} (after accepting priced lines when applicable). Current: ${String(claim.workflow?.currentStep ?? "")}.`, ); } const flowParts = finalReply.parts ?? []; const pricingShape = classifyV2ExpertPricingParts(flowParts); if ( pricingShape.mixedFactorAndPrice && !claim.evaluation?.ownerPricedPartsApproval?.signedAt ) { throw new BadRequestException( "Sign acceptance of the priced repair lines first (`PUT .../owner-insurer-approval/sign` while claimStatus is NEEDS_REVISION at INSURER_REVIEW), then upload factors for factor-needed parts.", ); } const partNameForRecord = carPartDamageLabelForFactorRecord(part.carPartDamage) || String(part.partId ?? ""); const factorRecord = await this.claimFactorsImageDbService.create({ claimId: new Types.ObjectId(claimRequestId), partId: String(partId), partName: partNameForRecord, path: file.path, fileName: file.filename, uploadedAt: new Date(), }); const pathPrefix = `evaluation.${replyKey}.parts`; const updatedClaim = await this.claimCaseDbService.findOneAndUpdate( { _id: new Types.ObjectId(claimRequestId), [`${pathPrefix}.partId`]: partId, }, { $set: { [`${pathPrefix}.$.factorLink`]: factorRecord._id, [`${pathPrefix}.$.factorStatus`]: FactorStatus.PENDING, }, }, ); if (!updatedClaim) { throw new NotFoundException( "Could not find the specific part to update in the database.", ); } await this.checkAndTransitionStatusAfterFactorUploadV2(claimRequestId); return { message: "Factor uploaded successfully. Awaiting expert validation.", factorId: factorRecord._id, url: buildFileLink(file.path), }; } catch (error) { this.logger.error( `Error during V2 factor upload for claim ${claimRequestId}:`, error instanceof Error ? error.stack : String(error), ); if (error instanceof HttpException) { throw error; } throw new HttpException( "An internal server error occurred during factor upload.", HttpStatus.INTERNAL_SERVER_ERROR, ); } } /** * When every `factorNeeded` part on the active expert reply has a `factorLink`, move claim toward expert validation (v1: PendingFactorValidation). */ private async checkAndTransitionStatusAfterFactorUploadV2( claimRequestId: string, ): Promise { const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim?.evaluation) { return; } const finalReply = claim.evaluation.damageExpertReplyFinal || claim.evaluation.damageExpertReply; if (!finalReply?.parts) { return; } const requiredFactorParts = finalReply.parts.filter( (p) => p.factorNeeded === true, ); if (requiredFactorParts.length === 0) { return; } const allFactorsAreUploaded = requiredFactorParts.every( (p) => !!p.factorLink, ); if (!allFactorsAreUploaded) { return; } this.logger.log( `All required factors for claim case ${claimRequestId} are uploaded (V2). Setting claim status for validation.`, ); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { status: ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS, claimStatus: ClaimStatus.UNDER_REVIEW, "workflow.currentStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION, "workflow.nextStep": ClaimWorkflowStep.INSURER_REVIEW, }, $push: { history: { type: "ALL_FACTORS_UPLOADED_PENDING_VALIDATION", timestamp: new Date(), metadata: { claimRequestId }, }, }, }); } private async checkAndTransitionStatusAfterUpload( claimId: string, ): Promise { // Re-fetch the latest state of the claim const claim = await this.claimRequestManagementDbService.findOne(claimId); const finalReply = claim.damageExpertReplyFinal || claim.damageExpertReply; if (!finalReply?.parts) { return; } const requiredFactorParts = finalReply.parts.filter( (p) => p.factorNeeded === true, ); if (requiredFactorParts.length === 0) { return; } const allFactorsAreUploaded = requiredFactorParts.every( (p) => !!p.factorLink, ); if (allFactorsAreUploaded) { this.logger.log( `All required factors for claim ${claimId} have been uploaded. Updating status.`, ); await this.claimRequestManagementDbService.findByIdAndUpdate(claimId, { $set: { claimStatus: ReqClaimStatus.PendingFactorValidation, }, }); } } private async resendDocumentsController(claimFile, query, file) { if (!Object.keys(query).includes("documentName")) { throw new HttpException("use documentName", HttpStatus.BAD_REQUEST); } const fileRequired = claimFile.damageExpertResend.resendDocuments; if (!fileRequired.includes(query.documentName)) { throw new HttpException("wrong required file", HttpStatus.BAD_REQUEST); } try { const updated = await this.claimRequestManagementDbService.findAndUpdate( String(claimFile._id), { $push: { "userResendDocuments.documents": { [query.documentName]: buildFileLink(file.path), }, }, }, { new: true }, ); const existingKeys = updated.userResendDocuments.documents.flatMap( Object.keys, ); const allExist = fileRequired.every((key) => existingKeys.includes(key)); if (allExist) { await this.claimRequestManagementDbService.findAndUpdate( String(claimFile._id), { $set: { claimStatus: ReqClaimStatus.CheckAgain } }, ); } return { allExist, updated: updated.userResendDocuments.documents, fileRequired, }; } catch (err) { this.logger.error(err); return new HttpException( err instanceof HttpException ? err.message : String(err), err instanceof HttpException ? err.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR, ); } } private async resendCarParts(claimFile, query, file) { if (!Object.keys(query).includes("partId")) { throw new HttpException( "use resendCarParts query string", HttpStatus.BAD_REQUEST, ); } if (!Object.keys(query).includes("side")) { throw new HttpException( "side is required for car parts", HttpStatus.BAD_REQUEST, ); } const fileRequired = claimFile.damageExpertResend.resendCarParts.map( (r) => r.partId, ); if (!fileRequired.includes(query.partId)) { throw new HttpException("wrong required file", HttpStatus.BAD_REQUEST); } try { const fileEntry = { [query.partId]: { link: buildFileLink(file.path), side: query.side, }, }; const updated = await this.claimRequestManagementDbService.findAndUpdate( String(claimFile._id), { $push: { "userResendDocuments.carParts": fileEntry } }, { new: true }, ); const existingKeys = updated.userResendDocuments.carParts.flatMap( Object.keys, ); const allExist = fileRequired.every((key) => existingKeys.includes(key)); if (allExist) { await this.claimRequestManagementDbService.findAndUpdate( String(claimFile._id), { $set: { claimStatus: ReqClaimStatus.CheckAgain } }, ); } return { allExist, updated: updated.userResendDocuments.carParts, fileRequired, }; } catch (err) { this.logger.error(err); return new HttpException( err instanceof HttpException ? err.message : String(err), err instanceof HttpException ? err.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR, ); } } async resendFiles(claimId, file, query, actor) { let claimFile = await this.claimRequestManagementDbService.findOne(claimId); if (!claimFile) { throw new NotFoundException("Claim file not found"); } // Verify access: user must own the claim, or expert must be accessing IN_PERSON file const effectiveUserId = await this.getEffectiveUserId(claimId, actor); if (String(claimFile.userId) !== effectiveUserId) { throw new ForbiddenException("You do not have access to this claim file"); } switch (query.fields) { case "resendDocuments": return this.resendDocumentsController(claimFile, query, file); case "resendCarParts": return this.resendCarParts(claimFile, query, file); default: return new HttpException("wrong query string", HttpStatus.BAD_REQUEST); } } async handleUserObjectionAndParts( claimRequestId: string, userObjectionDto: UserObjectionDto, ) { const claimRequest = await this.claimDbService.findOne(claimRequestId); if (!claimRequest) { throw new NotFoundException("Claim request not found"); } if ( claimRequest.claimStatus !== ReqClaimStatus.WaitingForUserToResend && claimRequest.claimStatus !== ReqClaimStatus.CheckedRequest ) { throw new BadRequestException( "درخواست شما در این حالت نمی تواند انجام شود", ); } if (claimRequest.objection) throw new BadRequestException("Objection already exists!"); const updatePayload: any = { objection: userObjectionDto, }; if (userObjectionDto.newParts?.length) { const newPartsWithIds = userObjectionDto.newParts.map((part) => ({ ...part, partId: part.partId || new Types.ObjectId(), })); updatePayload["newParts"] = newPartsWithIds; } const updated = await this.claimDbService.findAndUpdate( claimRequestId, { ...updatePayload, claimStatus: ReqClaimStatus.CheckAgain, }, { new: true }, ); return updated.objection; } async inPersonVisit(requestId: string, actorDetail: any) { const request = await this.claimRequestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Claim not found"); } const updated = await this.claimRequestManagementDbService.findAndUpdate( requestId, { claimStatus: ReqClaimStatus.InPersonVisit, }, ); if ( Types.ObjectId.isValid(String(actorDetail.sub)) && Types.ObjectId.isValid(String(request.userClientKey)) && Types.ObjectId.isValid(String(requestId)) ) { await this.expertFileActivityDbService.recordEvent({ expertId: String(actorDetail.sub), tenantId: String(request.userClientKey), fileId: String(requestId), fileType: ExpertFileKind.CLAIM, eventType: ExpertFileActivityType.HANDLED, idempotencyKey: `claim:${requestId}:handled:inperson:${actorDetail.sub}`, }); } return updated; } async retrieveInsuranceBranches(insuranceId: string) { return await this.branchDbService.findAll(insuranceId); } private handleError(error) { this.logger.error(error); throw error; } /** * Convert Gregorian date to Persian date format (1404/08/13) */ private convertToPersianDate(date: Date | string): string { const d = new Date(date); const persianDate = new Date(d).toLocaleDateString("fa-IR", { year: "numeric", month: "2-digit", day: "2-digit", }); // Convert Persian digits to English and format as YYYY/MM/DD const persianToEnglish: { [key: string]: string } = { "۰": "0", "۱": "1", "۲": "2", "۳": "3", "۴": "4", "۵": "5", "۶": "6", "۷": "7", "۸": "8", "۹": "9", }; return persianDate .split("/") .map((part) => part .split("") .map((char) => persianToEnglish[char] || char) .join(""), ) .join("/"); } /** * Get time in 24-hour format (HH:mm) from date */ private getTime24Hour(date: Date | string): string { const d = new Date(date); const hours = d.getHours().toString().padStart(2, "0"); const minutes = d.getMinutes().toString().padStart(2, "0"); return `${hours}:${minutes}`; } /** * Call reverse geocoding API to get address from lat/lon */ private async getAddressFromCoordinates( lat: number, lon: number, ): Promise { try { const response = await firstValueFrom( this.httpService.get( `https://map.ir/fast-reverse?lat=${lat}&lon=${lon}`, { headers: { accept: "application/json", "x-api-key": this.MAP_IR_API_KEY, }, }, ), ); return response.data?.address_compact || ""; } catch (error) { this.logger.error("Failed to get address from coordinates", error); return ""; } } /** * Calculate EstimateAmount from damageExpertReply parts */ private calculateEstimateAmount(parts: any[]): number { if (!parts || !Array.isArray(parts) || parts.length === 0) { return 0; } return parts.reduce((sum, part) => { const totalPayment = part.totalPayment ? parseFloat(part.totalPayment.toString()) : 0; return sum + (isNaN(totalPayment) ? 0 : totalPayment); }, 0); } private normalizeFanavaranEstimateAmount(amount: number): number { return amount > 0 ? amount : FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT; } 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, }; } /** * Extract digits-only licence number from a possibly-null/empty value. * Returns `null` when the value is "null", "undefined", "", '' or whitespace. */ private extractNonEmptyLicenceDigits( value: unknown, ): string | null { if (value === null || value === undefined) return null; const asString = typeof value === "number" ? String(value) : String(value); const trimmed = asString.trim(); if (!trimmed) return null; const lowered = trimmed.toLowerCase(); if (lowered === "null" || lowered === "undefined") return null; const digits = trimmed.replace(/[^\d]/g, ""); return digits ? digits : null; } /** * Resolve licence number to send to Fanavaran. * Preference order: * - use the first non-empty value from DB (driverLicense/insurerLicense) * - else use the configured 10-digit dummy */ private resolveFanavaranLicenceNoFromParty(input: { driverIsInsurer?: boolean; driverLicense?: unknown; insurerLicense?: unknown; }): string { const { driverIsInsurer, driverLicense, insurerLicense } = input; // When driverIsInsurer === true, UI usually mirrors insurer/driver values, // but DB can still miss one of the two fields — prefer both. const candidates = driverIsInsurer ? [driverLicense, insurerLicense] : [driverLicense, insurerLicense]; for (const c of candidates) { const digits = this.extractNonEmptyLicenceDigits(c); if (digits) return digits; } return FANAVARAN_DUMMY_LICENCE_NO; } /** Last-resort sanitization for payload values coming from manual overrides. */ private ensureFanavaranNonEmptyLicenceNo(value: unknown): string { return ( this.extractNonEmptyLicenceDigits(value) ?? FANAVARAN_DUMMY_LICENCE_NO ); } private applyFanavaranDefaultFields(result: Record): void { result.ActualPremium = null; result.ArchiveNo = null; result.AuthorityCulpritId = null; result.AccidentCulpritId = null; result.ClaimCompletionDate = null; result.CostSeparationToDmgSections = null; result.CouponNo = null; result.CourtArchiveNo = null; result.CustomerFaultPercent = null; result.CulpritLicenceCityId = null; result.CulpritLicenceCountryId = null; result.CulpritLicenceForeignCityName = null; result.CulpritLicenceIssuDate = "1394/10/13"; // Must never be empty/null. result.CulpritLicenceNo = FANAVARAN_DUMMY_LICENCE_NO; result.DamagedCount = 1; result.DmgAssessorFirstCreationTime = null; result.EntryDate = null; result.GlassBreakReasonId = null; result.IsLicenseMatchWithVehicleKind = 1; result.HasOtherCulprit = 0; result.IsAccidentOutOfBorder = 0; result.IsFatalAccident = 0; result.IsOwnerChanged = null; result.IsPlaqueChanged = 0; result.IsSurplusArticleEighthLaw = null; 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.StatusChangeDate = null; 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; /** When false, missing PolicyId is left null (preview-only). Default true. */ requirePolicyId?: boolean; 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: FANAVARAN_DEFAULT_ACCIDENT_CAUSE_ID, }; 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); } result.AccidentLocationAddress = FANAVARAN_ACCIDENT_LOCATION_ADDRESS; result.EstimateAmount = this.normalizeFanavaranEstimateAmount( input.damageParts ? this.calculateEstimateAmount(input.damageParts) : 0, ); this.applyFanavaranDefaultFields(result); const policyId = await input.resolvePolicyId(); 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 ?? null; return result; } private formatFanavaranSelectedPartsDesc(selectedParts: unknown[]): string { const labels = selectedParts .map((part) => { if (typeof part === "string") return part; if (part && typeof part === "object") { const p = part as { label_fa?: unknown; titleFa?: unknown; name?: unknown; catalogKey?: unknown; }; return p.label_fa ?? p.titleFa ?? p.name ?? p.catalogKey; } return null; }) .filter( (label): label is string => typeof label === "string" && !!label.trim(), ) .map((label) => label.trim()); return labels.length ? labels.join("/") : "موارد آسیب دیده خودرو"; } private getFanavaranPlateMiddleCode( centerAlphabet?: string | number | null, ): number | null { if (centerAlphabet == null) return null; const raw = String(centerAlphabet).trim(); if (!raw) return null; const normalized = this.plateNormalizer.normalizePlateText(raw); const asNumber = Number(normalized); if (Number.isFinite(asNumber)) return asNumber; return this.plateNormalizer.resolvePlateLetterCode(normalized, FANAVARAN_PLATE_LETTER_CODE); } private formatFanavaranPlateNo( plate: { leftDigits: number; centerAlphabet: string; centerDigits: number; ir: number; } | null, ): string | null { if (!plate) return null; return `${plate.centerDigits}${plate.centerAlphabet}${plate.leftDigits}`; } /** First non-empty string from party vehicle.inquiry mapped/raw (ESG + Tejarat aliases). */ private pickPartyInquiryField( mapped: Record | null | undefined, raw: Record | null | undefined, keys: string[], ): string | null { for (const key of keys) { const value = mapped?.[key] ?? raw?.[key]; if (value == null) continue; const text = String(value).trim(); if (text) return text; } return null; } private resolvePlateFromPartyInquiry( mapped: Record | null | undefined, raw: Record | null | undefined, ): { leftDigits: number; centerAlphabet: string; centerDigits: number; ir: number; } | null { const left = this.pickPartyInquiryField(mapped, raw, ["plk1", "Plk1"]); const middle = this.pickPartyInquiryField(mapped, raw, ["plk2", "Plk2"]); const right = this.pickPartyInquiryField(mapped, raw, ["plk3", "Plk3"]); const serial = this.pickPartyInquiryField(mapped, raw, [ "plksrl", "PlkSrl", ]); if (!left || !middle || !right || !serial) return null; const leftDigits = Number(left); const centerDigits = Number(right); const ir = Number(serial); const centerAlphabet = this.plateNormalizer.normalizePlateText(middle); if ( !Number.isFinite(leftDigits) || !Number.isFinite(centerDigits) || !Number.isFinite(ir) || !centerAlphabet ) { return null; } return { leftDigits, centerAlphabet, centerDigits, ir }; } private async resolveVehicleKindId( clientKey: FanavaranClientKey, carType?: string, ): Promise { if (!carType) return null; const vehicleType = carType as ClaimVehicleTypeV2; const keywords = VEHICLE_KIND_KEYWORDS[vehicleType]; if (!keywords) return null; try { const lookup = this.fanavaranLookupService; const definition = FANAVARAN_REMOTE_LOOKUPS.find( (l) => l.name === "vehicle-kinds", ); if (!definition) return null; const data = (await lookup.getRemoteLookup( clientKey, definition.url, definition.cacheFile, )) as Array<{ Id?: number; Caption?: string; IsActive?: number }>; if (!Array.isArray(data)) return null; const normalizedKeywords = keywords.map((k) => k.toLowerCase()); const match = data.find((item) => { if (!item.Caption || item.IsActive === 0) return false; const caption = item.Caption.toLowerCase(); return normalizedKeywords.some((kw) => caption.includes(kw)); }); return match?.Id ?? null; } catch { this.logger.warn( `Failed to resolve VehicleKindId for carType=${carType}`, ); return null; } } private async resolveAccidentVehicleUsedFromVehicleKind( clientKey: FanavaranClientKey, vehicleKindId: number | null, preferredId: number | null, ): Promise { if (vehicleKindId == null) return preferredId; try { const [kinds, useTypes] = await Promise.all([ this.getFanavaranLookupRows(clientKey, "vehicle-kinds"), this.getFanavaranLookupRows(clientKey, "vehicle-use-types"), ]); const groupId = vehicleGroupIdForKind(kinds, vehicleKindId); const usedId = selectAccidentVehicleUsedId( useTypes, groupId, preferredId, ); this.logger.log( `Resolved AccidentVehicleUsedId=${usedId} from VehicleKindId=${vehicleKindId} VehicleGroupId=${groupId ?? "none"} (UsedId=${preferredId ?? "none"})`, ); return usedId; } catch (error) { this.logger.warn( `Failed to resolve AccidentVehicleUsedId from VehicleKindId=${vehicleKindId}: ${ error instanceof Error ? error.message : error }`, ); return preferredId; } } /** * GEN.03 lookup-filter: AccidentVehicleUsedId comes from Fanavaran vehicle * UsedId (VIN inquiry or vehicle GET), not Mongo tenant defaults. */ private async resolveAccidentVehicleUsedFromVehicleRecord( clientKey: FanavaranClientKey, vehicle: Record | null, ): Promise<{ usedId: number | null; vehicleKindId: number | null }> { const vehicleKindId = parseFanavaranId(vehicle?.VehicleKindId); const vehicleUsedId = parseFanavaranId(vehicle?.UsedId); const usedId = await this.resolveAccidentVehicleUsedFromVehicleKind( clientKey, vehicleKindId, vehicleUsedId, ); return { usedId, vehicleKindId }; } private async resolveAccidentVehicleUsedFromVin( clientKey: FanavaranClientKey, vin: string, vehicleId?: number | null, ): Promise<{ usedId: number | null; vehicleKindId: number | null }> { try { const vehicle = await this.fetchFanavaranVehicleByVin( clientKey, vin, vehicleId, ); if (!vehicle) { this.logger.warn(`VIN inquiry for ${vin} returned no vehicle`); return { usedId: null, vehicleKindId: null }; } this.logger.log( `VIN inquiry ${vin} VehicleId=${pickVehicleId(vehicle) ?? "none"} VehicleKindId=${parseFanavaranId(vehicle.VehicleKindId) ?? "none"} UsedId=${parseFanavaranId(vehicle.UsedId) ?? "none"} PlaqueKindId=${parseFanavaranId(vehicle.PlaqueKindId) ?? "none"}`, ); return this.resolveAccidentVehicleUsedFromVehicleRecord(clientKey, vehicle); } catch (error) { this.logger.warn( `Failed to resolve AccidentVehicleUsedId from VIN=${vin}: ${ error instanceof Error ? error.message : error }`, ); return { usedId: null, vehicleKindId: null }; } } private async fetchFanavaranVehicleByVin( clientKey: FanavaranClientKey, vin: string, vehicleId?: number | null, ): Promise | null> { const inquired = await this.fanavaranLookupService.inquiryByVin( clientKey, vin, ); return pickFanavaranVehicleFromInquiry(inquired, vehicleId); } /** * Damaged-car VIN inquiry: keep only rows that match VIN/plate/chassis. * Then GET that VehicleId so PlaqueKindId is the current version, not a * leftover row that merely had some PlaqueKindId. */ private async fetchFanavaranVehicleByVinCandidates( clientKey: FanavaranClientKey, vins: string[], identity: FanavaranDamagedVehicleIdentity, product: FanavaranClaimProduct = "third-party", ): Promise | null> { const requestOptions = { contractIdOverride: resolveFanavaranProductContractId(clientKey, product), }; let matched: Record | null = null; for (const vin of vins) { try { const inquired = await this.fanavaranLookupService.inquiryByVin( clientKey, vin, requestOptions, ); const rows = asVehicleInquiryRows(inquired); if (rows.length === 0) { this.logger.warn( `[buildFanavaranDamageCasePayload] VIN inquiry for ${vin} returned no vehicle`, ); continue; } const selected = selectFanavaranVehicleForDamageCase(rows, identity); if (!selected) { this.logger.warn( `[buildFanavaranDamageCasePayload] VIN inquiry ${vin} returned ${rows.length} row(s) but none matched damaged VIN/plate/chassis`, ); continue; } const plaqueKindId = pickFanavaranPlaqueLookupFields(selected).kindId; this.logger.log( `[buildFanavaranDamageCasePayload] VIN inquiry ${vin} matched VehicleId=${pickVehicleId(selected) ?? "none"} VehicleKindId=${parseFanavaranId(selected.VehicleKindId) ?? "none"} UsedId=${parseFanavaranId(selected.UsedId) ?? "none"} PlaqueKindId=${plaqueKindId ?? "none"} VersionNo=${parseFanavaranId(selected.VersionNo) ?? "none"}`, ); matched = selected; break; } catch (error) { this.logger.warn( `[buildFanavaranDamageCasePayload] VIN inquiry failed for ${vin}: ${ error instanceof Error ? error.message : error }`, ); } } if (!matched) return null; const vehicleId = pickVehicleId(matched); const versionNo = parseFanavaranId(matched.VersionNo); if (vehicleId == null) return matched; try { const fetched = asObjectRecord( await this.fanavaranLookupService.vehicleById( clientKey, vehicleId, versionNo ?? undefined, requestOptions, ), ); if ( fetched && fanavaranVehicleMatchesDamagedIdentity(fetched, identity) ) { this.logger.log( `[buildFanavaranDamageCasePayload] vehicle GET ${vehicleId} confirmed identity PlaqueKindId=${pickFanavaranPlaqueLookupFields(fetched).kindId ?? "none"} VehicleKindId=${parseFanavaranId(fetched.VehicleKindId) ?? "none"}`, ); return fetched; } if (fetched) { this.logger.warn( `[buildFanavaranDamageCasePayload] vehicle GET ${vehicleId} did not match damaged identity; keeping VIN-inquiry row`, ); } } catch (error) { this.logger.warn( `[buildFanavaranDamageCasePayload] vehicle GET failed for VehicleId=${vehicleId}: ${ error instanceof Error ? error.message : error }`, ); } return matched; } /** * Fallback when VIN is missing: policy GET → vehicle GET → UsedId/VehicleKindId. */ private async resolveAccidentVehicleUsedFromPolicy( clientKey: FanavaranClientKey, policyId: number, product: FanavaranClaimProduct = "third-party", ): Promise<{ usedId: number | null; vehicleKindId: number | null }> { try { const requestOptions = { contractIdOverride: resolveFanavaranProductContractId( clientKey, product, ), }; const policy = asObjectRecord( product === "car-body" ? await this.fanavaranLookupService.bodyPolicyById( clientKey, policyId, requestOptions, ) : await this.fanavaranLookupService.thirdPartyPolicyById( clientKey, policyId, requestOptions, ), ); const vehicleId = pickVehicleId(policy?.VehicleId ?? policy?.vehicleId); if (vehicleId === null) { this.logger.warn(`Policy ${policyId} has no VehicleId`); return { usedId: null, vehicleKindId: null }; } const versionNo = parseFanavaranId(policy?.VehicleVersionNo); const vehicle = asObjectRecord( await this.fanavaranLookupService.vehicleById( clientKey, vehicleId, versionNo ?? undefined, requestOptions, ), ); this.logger.log( `Policy ${policyId} VehicleId=${vehicleId} version=${versionNo ?? "latest"} VehicleKindId=${parseFanavaranId(vehicle?.VehicleKindId) ?? "none"} UsedId=${parseFanavaranId(vehicle?.UsedId) ?? "none"}`, ); return this.resolveAccidentVehicleUsedFromVehicleRecord(clientKey, vehicle); } catch (error) { this.logger.warn( `Failed to resolve AccidentVehicleUsedId from PolicyId=${policyId}: ${ error instanceof Error ? error.message : error }`, ); return { usedId: null, vehicleKindId: null }; } } private async resolveAccidentVehicleUsedFromApis(input: { clientKey: FanavaranClientKey; parties: unknown[]; guiltyPartyId?: unknown; policyId?: number | null; product?: FanavaranClaimProduct; }): Promise<{ usedId: number | null; vehicleKindId: number | null; source: "vin-inquiry" | "policy-vehicle" | null; }> { const guiltyParty = (input.parties ?? []).find( (party: any) => String(party?.person?.userId ?? "") === String(input.guiltyPartyId ?? ""), ); const vin = pickVinFromPartyVehicle(guiltyParty); if (vin) { const fromVin = await this.resolveAccidentVehicleUsedFromVin( input.clientKey, vin, ); if (fromVin.usedId != null) { return { ...fromVin, source: "vin-inquiry" }; } } if (typeof input.policyId === "number") { const fromPolicy = await this.resolveAccidentVehicleUsedFromPolicy( input.clientKey, input.policyId, input.product ?? "third-party", ); if (fromPolicy.usedId != null) { return { ...fromPolicy, source: "policy-vehicle" }; } } return { usedId: null, vehicleKindId: null, source: null }; } private parseJalaliBirthday( birthday: string | number | Record | null | undefined, ): { year: number; month: number; day: number } | null { return parseJalaliDateParts(birthday); } private async resolveDriverFanavaranId( clientKey: FanavaranClientKey, nationalCode: string | null | undefined, driverBirthday: string | number | null | undefined, driverIsInsurer: boolean | undefined, ): Promise { if (!nationalCode) { this.logger.warn( `[resolveDriverFanavaranId] SKIP: no nationalCode provided`, ); return null; } const parsed = this.parseJalaliBirthday(driverBirthday); if (!parsed) { this.logger.warn( `[resolveDriverFanavaranId] SKIP: could not parse driverBirthday "${driverBirthday}" for nationalCode=${nationalCode}`, ); return null; } const targetRoleId = driverIsInsurer ? 161 : 166; this.logger.log( `[resolveDriverFanavaranId] QUERYING Fanavaran: nationalCode=${nationalCode} birthday=${parsed.year}/${parsed.month}/${parsed.day} driverIsInsurer=${driverIsInsurer} targetRoleId=${targetRoleId}`, ); try { const response = await this.fanavaranLookupService.inquiryByUniqueIdentifier( clientKey, { nationalCode, birthYear: parsed.year, birthMonth: parsed.month, birthDay: parsed.day, }, ); this.logger.log( `[resolveDriverFanavaranId] RESPONSE: ${JSON.stringify(response)}`, ); const driverId = selectFanavaranDriverId(response, driverIsInsurer); if (driverId != null) { const rows = asPartyInquiryRows(response); const match = rows.find((row) => Number(row.Id) === driverId); this.logger.log( `[resolveDriverFanavaranId] MATCH: Id=${driverId} RoleId=${match?.RoleId ?? "none"} name=${match?.Name ?? ""} ${match?.LastName ?? ""} for nationalCode=${nationalCode}`, ); return driverId; } this.logger.warn( `[resolveDriverFanavaranId] NO MATCH for nationalCode=${nationalCode} driverIsInsurer=${driverIsInsurer}. ` + `Available entries: ${asPartyInquiryRows(response) .map((e) => `{Id=${e.Id}, RoleId=${e.RoleId}, Name=${e.Name} ${e.LastName}}`) .join("; ")}`, ); return null; } catch (error) { this.logger.error( `[resolveDriverFanavaranId] API ERROR for nationalCode=${nationalCode}: ${error instanceof Error ? error.message : JSON.stringify(error)}`, ); return null; } } private async buildFanavaranDamageCasePayload(input: { claimCase: any; blameCase?: any; selectedParts: unknown[]; clientKey: FanavaranClientKey; defaults: { AccidentVehicleUsedId: number; CulpritLicenceTypeId: number; DmgCaseTypeId: number; DmgHistoryStatus: number; PlaqueKindId: number; PlaqueSampleId: number; DriverIsOwner: number; FaultPercent: number; }; /** GEN.44 write: register the damaged party in Fanavaran when inquiry misses. */ registerMissingPerson?: boolean; }): Promise> { const damagedParty = this.pickDamagedPartyForFanavaran( input.blameCase, input.claimCase, ); const person = damagedParty?.person ?? {}; const vehicle = damagedParty?.vehicle ?? {}; const insurance = damagedParty?.insurance ?? {}; const product = fanavaranClaimProductFromBlameType(input.blameCase?.type); const { mapped: inquiryMapped, raw: inquiryRawPayload } = fanavaranPartyInquirySources(product, damagedParty); const inquiryRaw = inquiryRawPayload ?? {}; const plate = this.resolveOwnershipPlateForClaim(input.claimCase, input.blameCase) ?? this.resolvePlateFromPartyInquiry(inquiryMapped, inquiryRaw); this.logger.log( `[buildFanavaranDamageCasePayload] damagedParty resolved: userId=${damagedParty?.person?.userId ?? "NONE"}, ` + `nationalCode=${pickPersonNationalCode(person) ?? "MISSING"}, ` + `nationalCodeOfDriver=${person.nationalCodeOfDriver ?? "MISSING"}, ` + `nationalCodeOfInsurer=${person.nationalCodeOfInsurer ?? "MISSING"}, ` + `driverBirthday=${pickPersonBirthday(person) ?? "MISSING"}, ` + `driverIsInsurer=${person.driverIsInsurer ?? "MISSING"}, ` + `driverLicense=${person.driverLicense ?? "MISSING"}`, ); if (!damagedParty) { this.logger.warn( `[buildFanavaranDamageCasePayload] No matching party found for claimCase.owner.userId=${input.claimCase?.owner?.userId}. ` + `Blame parties: ${input.blameCase?.parties?.map((p: any) => `userId=${p?.person?.userId}`).join(", ") ?? "EMPTY"}`, ); } // Policy document / unique code / coverage dates from damaged party's plate inquiry const policyNo = this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [ "PrntCmpDocNo", "PrntPlcyCmpDocNo", "printNumber", "insuranceNumber", ]) ?? insurance.policyNumber ?? null; const policyCINumber = this.pickPartyInquiryField( inquiryMapped, inquiryRaw, ["PlcyUnqCod", "ThirdPolicyCode"], ); const beginDate = this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [ "HBgnDte", "StartDate", "persianStartDate", "SatrtDate", ]) ?? insurance.startDate ?? null; const endDate = this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [ "HEndDte", "EndDate", "persianEndDate", ]) ?? insurance.endDate ?? null; const builtYearRaw = this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [ "PrdDte", "ModelField", "ModelCii", ]); const builtYearFromInquiry = builtYearRaw ? Number(builtYearRaw) || null : null; const damagedVin = this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [ "VIN", "vin", "VinNumberField", ]) ?? collectPartyVinCandidates(damagedParty)[0] ?? null; const damagedChassis = this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [ "ShsNum", "ChassisNumberField", "shsNam", ]); const damagedIdentity: FanavaranDamagedVehicleIdentity = { vin: damagedVin, chassis: damagedChassis, plaque: plate ? completePlaqueParts({ plaqueLeft: String(plate.leftDigits), plaqueLetter: plate.centerAlphabet, plaqueRight: String(plate.centerDigits), plaqueSerial: String(plate.ir), }) : null, policyCINumber, }; const damagedVinCandidates: string[] = []; const seenVins = new Set(); for (const value of [ ...collectPartyVinCandidates(damagedParty), damagedVin, damagedChassis, ]) { const vin = normalizeVin(value); if (!vin || seenVins.has(vin)) continue; seenVins.add(vin); damagedVinCandidates.push(vin); } const cachedDamage = (input.claimCase as any)?.fanavaranSync?.damageCase ?? {}; this.logger.log( `[buildFanavaranDamageCasePayload] identity VIN=${damagedIdentity.vin ?? "none"} chassis=${damagedIdentity.chassis ?? "none"} plate=${damagedIdentity.plaque ? `${damagedIdentity.plaque.right}${damagedIdentity.plaque.letter}${damagedIdentity.plaque.left}/${damagedIdentity.plaque.serial}` : "none"} CI=${damagedIdentity.policyCINumber ?? "none"} VIN candidates=${damagedVinCandidates.join(",") || "none"}`, ); const apiVehicle = await this.fetchFanavaranVehicleByVinCandidates( input.clientKey, damagedVinCandidates, damagedIdentity, product, ); const apiPlaque = pickFanavaranPlaqueLookupFields(apiVehicle); const plaqueLookup = resolveDamageCasePlaqueLookupIds(apiVehicle); const plaqueKindId = plaqueLookup.kindId; const plaqueSampleId = plaqueLookup.sampleId; const plaqueKindSource = plaqueLookup.source; const plaqueSampleSource = plaqueLookup.source; if (plaqueLookup.source !== "vehicle-inquiry") { this.logger.log( `[buildFanavaranDamageCasePayload] PlaqueKindId not on a matched Fanavaran vehicle; using national-plate default ${plaqueKindId}/${plaqueSampleId}`, ); } const builtYear = parseFanavaranId(apiVehicle?.BuiltYear) ?? builtYearFromInquiry; const carType = input.claimCase?.vehicle?.carType as string | undefined; let vehicleKindId = this.firstPositiveFanavaranId(apiVehicle?.VehicleKindId); let vehicleKindSource: "vehicle-inquiry" | "car-type-lookup" | null = parseFanavaranId(apiVehicle?.VehicleKindId) != null ? "vehicle-inquiry" : null; if (vehicleKindId == null) { vehicleKindId = await this.resolveVehicleKindId( input.clientKey, carType, ); if (vehicleKindId != null) vehicleKindSource = "car-type-lookup"; } let driverFanavaranId = await this.resolveDriverFanavaranIdFromBlameCase( input.clientKey, input.blameCase?.parties ?? [], person, input.claimCase, ); let otherPersonId = this.firstPositiveFanavaranId(cachedDamage.otherPersonId); if (driverFanavaranId) { this.logger.log( `[buildFanavaranDamageCasePayload] DriverId=${driverFanavaranId} from live parties inquiry`, ); } else if (otherPersonId) { driverFanavaranId = otherPersonId; this.logger.log( `[buildFanavaranDamageCasePayload] Parties inquiry missed; reusing GEN.44 otherPersonId=${otherPersonId}`, ); } else if (input.registerMissingPerson) { const registered = await this.registerFanavaranOtherPerson({ clientKey: input.clientKey, person, claimCaseId: input.claimCase?._id ? String(input.claimCase._id) : undefined, }); if (registered != null) { driverFanavaranId = registered; otherPersonId = registered; } } if (driverFanavaranId && input.blameCase?._id && damagedParty) { const partyIndex = (input.blameCase.parties ?? []).findIndex( (p: any) => p === damagedParty || String(p?.person?.userId ?? "") === String(damagedParty?.person?.userId ?? ""), ); if (partyIndex >= 0) { await this.blameRequestDbService.findByIdAndUpdate( String(input.blameCase._id), { $set: { [`parties.${partyIndex}.person.fanavaranDriverId`]: driverFanavaranId } }, ); this.logger.log( `[buildFanavaranDamageCasePayload] stored DriverId=${driverFanavaranId} on blameCase=${input.blameCase._id} party[${partyIndex}]`, ); } } const insuranceCorpId = await this.fanavaranLookupService.resolveInsuranceCorpId( input.clientKey, ); let accidentVehicleUsedId: number | null = null; let usedSource: "vin-inquiry" | "policy-vehicle" | "vehicle-inquiry" | null = null; if (accidentVehicleUsedId == null && apiVehicle) { const fromDamagedVehicle = await this.resolveAccidentVehicleUsedFromVehicleRecord( input.clientKey, apiVehicle, ); accidentVehicleUsedId = fromDamagedVehicle.usedId; if (accidentVehicleUsedId != null) usedSource = "vehicle-inquiry"; } if (accidentVehicleUsedId == null) { accidentVehicleUsedId = await this.resolveAccidentVehicleUsedFromVehicleKind( input.clientKey, vehicleKindId, parseFanavaranId(apiVehicle?.UsedId), ); if (accidentVehicleUsedId != null && usedSource == null) { usedSource = apiVehicle ? "vehicle-inquiry" : null; } } if (accidentVehicleUsedId == null) { accidentVehicleUsedId = input.defaults.AccidentVehicleUsedId; this.logger.log( `[buildFanavaranDamageCasePayload] AccidentVehicleUsedId API miss; using config default ${accidentVehicleUsedId}`, ); } this.logger.log( `[buildFanavaranDamageCasePayload] Final DriverId=${driverFanavaranId ?? "NULL"} for nationalCode=${pickPersonNationalCode(person) ?? "MISSING"}, ` + `PolicyNo=${policyNo ?? "NULL"}, PolicyCINumber=${policyCINumber ?? "NULL"}`, ); const payload = { BeginDate: beginDate, BuiltYear: builtYear, ChassisNo: this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [ "ShsNum", "ChassisNumberField", "shsNam", ]) ?? (typeof apiVehicle?.ChassisNo === "string" ? apiVehicle.ChassisNo : null), DmgHistoryStatus: input.defaults.DmgHistoryStatus, Desc: this.formatFanavaranSelectedPartsDesc(input.selectedParts), DmgCaseTypeId: input.defaults.DmgCaseTypeId, DriverId: driverFanavaranId ?? null, EndDate: endDate, EstimateAmount: FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT, FaultPercent: input.defaults.FaultPercent, InsuranceCorpId: insuranceCorpId, DriverIsOwner: person.driverIsInsurer ? 1 : input.defaults.DriverIsOwner, LicenceCityId: null, LicenceCountryId: null, LicenceForeignCityName: null, LicenceIssuDate: person.driverBirthday ?? "1394/10/13", // Must never be empty/null; prefer stored DB value, else use dummy. LicenceNo: this.resolveFanavaranLicenceNoFromParty({ driverIsInsurer: person.driverIsInsurer, driverLicense: person.driverLicense, insurerLicense: person.insurerLicense, }), LicenceTypeId: input.defaults.CulpritLicenceTypeId, MotorNo: this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [ "MtrNum", "EngineNumberField", "mtrnum", ]) ?? (typeof apiVehicle?.MotorNo === "string" ? apiVehicle.MotorNo : null), OwnerId: null, PlaqueCityId: apiPlaque.cityId, PlaqueKindId: plaqueKindId, PlaqueLeftNo: apiPlaque.leftNo ?? (plate ? String(plate.leftDigits) : null), PlaqueMiddleCodeId: apiPlaque.middleCodeId ?? this.getFanavaranPlateMiddleCode(plate?.centerAlphabet), PlaqueNo: apiPlaque.plaqueNo ?? this.pickPartyInquiryField(inquiryMapped, inquiryRaw, ["Plk"]) ?? this.formatFanavaranPlateNo(plate), PlaqueRightNo: apiPlaque.rightNo ?? (plate ? String(plate.centerDigits) : null), PlaqueSampleId: plaqueSampleId, PlaqueSerial: apiPlaque.serial ?? (plate ? String(plate.ir) : null), PolicyNo: policyNo, PreviousPolicyEndDate: endDate ?? "", VehicleKindId: vehicleKindId, VIN: this.pickPartyInquiryField(inquiryMapped, inquiryRaw, [ "VIN", "vin", "VinNumberField", ]) ?? (typeof apiVehicle?.VIN === "string" ? apiVehicle.VIN : null), AccidentVehicleUsedId: accidentVehicleUsedId, PolicyCINumber: policyCINumber, }; const persistedPayload = payload; // 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": persistedPayload, "fanavaranSync.damageCase.lastPayloadBuiltAt": new Date(), ...(driverFanavaranId != null ? { "fanavaranSync.damageCase.driverId": driverFanavaranId } : {}), ...(otherPersonId != null ? { "fanavaranSync.damageCase.otherPersonId": otherPersonId } : {}), ...(vehicleKindId != null ? { "fanavaranSync.damageCase.vehicleKindId": vehicleKindId, "fanavaranSync.damageCase.vehicleKindSource": vehicleKindSource ?? "car-type-lookup", } : {}), "fanavaranSync.damageCase.plaqueKindId": plaqueKindId, "fanavaranSync.damageCase.plaqueKindSource": plaqueKindSource, "fanavaranSync.damageCase.plaqueSampleId": plaqueSampleId, "fanavaranSync.damageCase.plaqueSampleSource": plaqueSampleSource, ...(accidentVehicleUsedId != null ? { "fanavaranSync.damageCase.accidentVehicleUsedId": accidentVehicleUsedId, ...(usedSource ? { "fanavaranSync.damageCase.accidentVehicleUsedSource": usedSource, } : {}), } : {}), ...(insuranceCorpId != null ? { "fanavaranSync.damageCase.insuranceCorpId": insuranceCorpId } : {}), }, }); } return persistedPayload; } /** * Fanavaran Submit - Map data from claim-request-management and request-management to third-party-car-financial-claims format */ async fanavaranSubmit(claimRequestId: string): Promise { try { // Query claim-request-management with only needed fields using aggregate for field selection const claimRequestResult = await this.claimRequestManagementDbService.aggregate([ { $match: { _id: new Types.ObjectId(claimRequestId) } }, { $project: { blameFile: 1, damageExpertReply: 1, damageExpertReplyFinal: 1, }, }, ]); if (!claimRequestResult || claimRequestResult.length === 0) { throw new NotFoundException("Claim request not found"); } const claimRequest = claimRequestResult[0]; if (!claimRequest) { throw new NotFoundException("Claim request not found"); } if (!claimRequest.blameFile || !claimRequest.blameFile._id) { throw new BadRequestException("Blame file not found in claim request"); } const blameRequestId = claimRequest.blameFile._id.toString(); // Query request-management with only needed fields using aggregate for field selection const blameRequestResult = await this.blameDbService.aggregate([ { $match: { _id: new Types.ObjectId(blameRequestId) } }, { $project: { expertSubmitReplyFinal: 1, expertSubmitReply: 1, createdAt: 1, firstPartyLocation: 1, "firstPartyDetails.firstPartyId": 1, "firstPartyDetails.nationalCodeOfInsurer": 1, "secondPartyDetails.secondPartyId": 1, "secondPartyDetails.nationalCodeOfInsurer": 1, }, }, ]); if (!blameRequestResult || blameRequestResult.length === 0) { throw new NotFoundException("Blame request not found"); } const blameRequest = blameRequestResult[0]; if (!blameRequest) { throw new NotFoundException("Blame request not found"); } // Start building the response object const result: any = { AccidentCityId: 701, AccidentReportTypeId: 155, AccidentVehicleUsedId: 1, ClaimExpertId: 4543092, CompensationReferenceId: 167, CulpritLicenceTypeId: 2, CulpritTypeId: 337, AccidentCauseId: FANAVARAN_DEFAULT_ACCIDENT_CAUSE_ID, }; if ( !blameRequest.expertSubmitReplyFinal && !blameRequest.expertSubmitReply ) { this.logger.error( `[Fanavaran Submit] Both expertSubmitReplyFinal and expertSubmitReply are null/undefined`, ); } // AccidentDate: Convert createdAt to Persian date if (blameRequest.createdAt) { result.AccidentDate = this.convertToPersianDate(blameRequest.createdAt); result.AnnouncementDate = result.AccidentDate; result.DocReceivedDate = result.AccidentDate; } // AccidentTime: Extract time from createdAt in 24-hour format if (blameRequest.createdAt) { result.AccidentTime = this.getTime24Hour(blameRequest.createdAt); } result.AccidentLocationAddress = FANAVARAN_ACCIDENT_LOCATION_ADDRESS; // EstimateAmount: Sum of totalPayment from damageExpertReply parts const damageReply = claimRequest.damageExpertReplyFinal || claimRequest.damageExpertReply; if (damageReply?.parts) { result.EstimateAmount = this.normalizeFanavaranEstimateAmount( this.calculateEstimateAmount(damageReply.parts), ); } else { result.EstimateAmount = FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT; } // Keep other fields from template with default values this.applyFanavaranDefaultFields(result); // Get PolicyId from external API try { const policyId = await this.getPolicyIdFromExternalApi( blameRequest, claimRequestId, ); if (!policyId) { throw new BadRequestException( `[Fanavaran Submit] PolicyId is required for Fanavaran submit. Policy inquiry returned no valid policy; contact the administrator.`, ); } result.PolicyId = policyId; } catch (error) { this.logger.error( `[Fanavaran Submit] Failed to get PolicyId from external API:`, error, ); throw error; } return result; } catch (error) { this.logger.error("Error in fanavaranSubmit", error); throw error; } } /** * Resolve Fanavaran authenticationToken via shared tenant cache (TTL). * Prefer clientKey overload — config-based overload keeps legacy Tejaratno callers working. */ private async getAppToken( config: { appName: string; secret: string; } = this.getTejaratnoFanavaranConfig(), auditSession?: FanavaranAuditSession, ): Promise { 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, }); } private async login( _appToken: string, config: { username: string; password: string; } = this.getTejaratnoFanavaranConfig(), auditSession?: FanavaranAuditSession, ): Promise { 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(); } private resolveClientKeyFromLoginConfig(config: { username: string; }): FanavaranClientKey { if (config.username === this.PARSIAN_FANAVARAN_CONFIG.username) { return "parsian"; } return resolveFanavaranClientKey(); } private async resolveFanavaranClaimProduct( claimCaseId?: string, blameCase?: { type?: string } | null, ): Promise { if (blameCase?.type) { return fanavaranClaimProductFromBlameType(blameCase.type); } if (!claimCaseId) return "third-party"; const claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase?.blameRequestId) return "third-party"; const blame = await this.blameRequestDbService.findById( String(claimCase.blameRequestId), ); return fanavaranClaimProductFromBlameType(blame?.type); } private async fanavaranClaimsUrlForClaim( claimCaseId?: string, blameCase?: { type?: string } | null, ): Promise { return fanavaranClaimsBaseUrl( await this.resolveFanavaranClaimProduct(claimCaseId, blameCase), ); } private async getFanavaranAuthHeaders( clientKey: FanavaranClientKey, auditSession?: FanavaranAuditSession, claimCaseId?: string, ) { const caseId = claimCaseId ?? auditSession?.claimCaseId; const locationOverride = caseId ? await this.resolveFanavaranLocationForClaim(caseId, clientKey) : undefined; const product = await this.resolveFanavaranClaimProduct(caseId); return this.fanavaranAuthService.getRequestHeaders(clientKey, { auditSession, locationOverride, contractIdOverride: resolveFanavaranProductContractId(clientKey, product), }); } /** * Parsian + V4/V5 FileMaker flows: Location from file-maker.locations[].id, * else file-reviewer.locations[].id, else tenant auth.location. */ private async resolveFanavaranLocationForClaim( claimCaseId: string, clientKey: FanavaranClientKey, ): Promise { const defaultLocation = getFanavaranClientProfile(clientKey).auth.location; if (clientKey !== "parsian") { return defaultLocation; } try { const claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase?.blameRequestId) { return defaultLocation; } const blame = await this.blameRequestDbService.findById( String(claimCase.blameRequestId), ); if (!(blame as any)?.isMadeByFileMaker) { return defaultLocation; } return this.fanavaranLocationService.resolveBusinessLocation({ clientKey, isMadeByFileMaker: true, fileMakerId: (blame as any)?.initiatedByFieldExpertId ? String((blame as any).initiatedByFieldExpertId) : null, fileReviewerId: (blame as any)?.assignedFileReviewerId ? String((blame as any).assignedFileReviewerId) : null, }); } catch (error) { this.logger.warn( `[Fanavaran Location] Failed to resolve for claimCaseId=${claimCaseId}; using tenant default`, error, ); return defaultLocation; } } private async getPolicyIdFromNationalCode( nationalCodeOfInsurer: string, config: { appName: string; secret: string; username: string; password: string; corpId: string; contractId: string; location: string; }, logPrefix: string, auditSession?: FanavaranAuditSession, options?: { clientKey?: FanavaranClientKey; claimCaseId?: string; product?: FanavaranClaimProduct; insuranceLineId?: number; }, ): Promise { const clientKey = options?.clientKey ?? this.resolveClientKeyFromAuthConfig(config); const product = options?.product ?? (await this.resolveFanavaranClaimProduct(options?.claimCaseId)); const insuranceLineId = options?.insuranceLineId ?? fanavaranInsuranceLineIdForProduct(product); const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=${insuranceLineId}&NationalCode=${nationalCodeOfInsurer}`; const startedAt = Date.now(); const requestMeta = { corpId: config.corpId, contractId: resolveFanavaranProductContractId(clientKey, product), location: config.location, nationalCode: this.fanavaranAuditService.maskNationalCode( nationalCodeOfInsurer, ), InsuranceLineId: insuranceLineId, }; if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.POLICY_INQUIRY, status: FanavaranAuditStatus.STARTED, requestUrl: policyInquiryUrl, requestMethod: "GET", requestMeta, }); } try { this.fanavaranAuthService.assertNotInBackoff(clientKey); const headers = await this.getFanavaranAuthHeaders( clientKey, auditSession, options?.claimCaseId, ); const requestHeaders = { ...headers, "Content-Type": "application/json", }; this.logger.log( `${logPrefix} Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`, ); const response = await firstValueFrom( this.httpService.get(policyInquiryUrl, { headers: requestHeaders, timeout: 15000, }), ); const policyCount = Array.isArray(response.data) ? response.data.length : 0; this.logger.log( `${logPrefix} Policy inquiry response status=${response.status} count=${policyCount}`, ); 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) { const exchange = this.fanavaranAuditService.captureAxiosExchange( response, requestHeaders, ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.POLICY_INQUIRY, status: FanavaranAuditStatus.SUCCESS, requestUrl: policyInquiryUrl, requestMethod: "GET", httpStatus: response.status, requestHeaders: exchange.requestHeaders, requestMeta, responseHeaders: exchange.responseHeaders, responseBody: exchange.responseBody, responseMeta: { policyId: selectedPolicy.policyId, policyEndDate: selectedPolicy.endDate, policyEndDateGregorian: selectedPolicy.endDateGregorian, policyCount, fromCache: false, }, durationMs: Date.now() - startedAt, }); } return selectedPolicy.policyId; } catch (error) { this.fanavaranAuthService.registerFailure(clientKey, error); const errorMessage = error instanceof Error ? error.message : "unknown policy inquiry error"; if (auditSession) { const exchange = this.fanavaranAuditService.captureAxiosExchange(error); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.POLICY_INQUIRY, status: FanavaranAuditStatus.FAILURE, requestUrl: policyInquiryUrl, requestMethod: "GET", httpStatus: exchange.httpStatus, requestHeaders: exchange.requestHeaders, requestMeta, responseHeaders: exchange.responseHeaders, responseBody: exchange.responseBody, errorMessage, errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, }); } this.logger.warn(`${logPrefix} Policy inquiry failed: ${errorMessage}`); if (error instanceof BadRequestException) { throw error; } return null; } } /** * Get PolicyId from external API */ private async getPolicyIdFromExternalApi( blameRequest: any, claimRequestId: string, ): Promise { try { // Get guiltyUserId from expertSubmitReplyFinal or expertSubmitReply let guiltyUserId: string | null = null; if (blameRequest.expertSubmitReplyFinal?.guiltyUserId) { guiltyUserId = blameRequest.expertSubmitReplyFinal.guiltyUserId; } else if (blameRequest.expertSubmitReply?.guiltyUserId) { guiltyUserId = blameRequest.expertSubmitReply.guiltyUserId; } if (!guiltyUserId) { this.logger.warn( `[Fanavaran Submit] guiltyUserId not found in expertSubmitReplyFinal or expertSubmitReply`, ); return null; } // Find the nationalCodeOfInsurer by matching guiltyUserId with firstPartyId or secondPartyId let nationalCodeOfInsurer: string | null = null; if ( blameRequest.firstPartyDetails?.firstPartyId?.toString() === guiltyUserId.toString() ) { nationalCodeOfInsurer = blameRequest.firstPartyDetails?.nationalCodeOfInsurer; } else if ( blameRequest.secondPartyDetails?.secondPartyId?.toString() === guiltyUserId.toString() ) { nationalCodeOfInsurer = blameRequest.secondPartyDetails?.nationalCodeOfInsurer; } if (!nationalCodeOfInsurer) { this.logger.warn( `[Fanavaran Submit] nationalCodeOfInsurer not found for guiltyUserId: ${guiltyUserId}`, ); return null; } return await this.getPolicyIdFromNationalCode( nationalCodeOfInsurer, this.getTejaratnoFanavaranConfig(), `[Fanavaran Submit] claimRequestId=${claimRequestId}`, ); } catch (error) { this.logger.error( `[Fanavaran Submit] Error getting PolicyId from external API:`, error, ); if (error instanceof BadRequestException) { throw error; } return null; } } private firstPositiveFanavaranId(...values: unknown[]): number | null { for (const value of values) { const id = parseFanavaranId(value); if (id != null) return id; } return null; } private keepPreviousPositiveIds( previous: Record | null, next: Record, keys: string[], ): Record { const merged = { ...previous, ...next }; for (const key of keys) { if (this.firstPositiveFanavaranId(next[key]) != null) continue; const previousId = this.firstPositiveFanavaranId(previous?.[key]); if (previousId != null) { merged[key] = previousId; } } return merged; } private async resolveDriverFanavaranIdFromPerson( clientKey: FanavaranClientKey, person: { driverIsInsurer?: boolean; nationalCodeOfDriver?: unknown; nationalCodeOfInsurer?: unknown; nationalCode?: unknown; driverBirthday?: unknown; birthday?: unknown; insurerBirthday?: unknown; } | null | undefined, ): Promise { const codes = collectPersonNationalCodes(person); const birthdays = collectPersonBirthdays(person); for (const nationalCode of codes) { for (const birthday of birthdays) { const driverId = await this.resolveDriverFanavaranId( clientKey, nationalCode, birthday, person?.driverIsInsurer, ); if (driverId != null) return driverId; } } return null; } private async resolveDriverFanavaranIdFromBlameCase( clientKey: FanavaranClientKey, parties: any[], preferredPerson: any, claimCase?: any, ): Promise { const people = [ preferredPerson, ...parties.map((party) => party?.person), ].filter(Boolean); this.logger.log( `[resolveDriverFanavaranIdFromBlameCase] trying ${people.length} people then leftover codes`, ); for (const person of people) { const driverId = await this.resolveDriverFanavaranIdFromPerson( clientKey, person, ); if (driverId != null) return driverId; } const leftoverCodes = [ ...(claimCase?.money?.nationalCodeOfInsurer ? collectPersonNationalCodes({ nationalCodeOfInsurer: claimCase.money.nationalCodeOfInsurer, }) : []), ]; const leftoverBirthdays: Array = []; const seenBirthdays = new Set(); for (const person of people) { for (const birthday of collectPersonBirthdays(person)) { const parsed = parseJalaliDateParts(birthday); const key = parsed ? `${parsed.year}-${parsed.month}-${parsed.day}` : String(birthday); if (seenBirthdays.has(key)) continue; seenBirthdays.add(key); leftoverBirthdays.push(birthday); } } this.logger.log( `[resolveDriverFanavaranIdFromBlameCase] leftover codes=${leftoverCodes.join(",") || "none"} birthdays=${leftoverBirthdays.join(",") || "none"}`, ); for (const nationalCode of leftoverCodes) { for (const birthday of leftoverBirthdays) { const driverId = await this.resolveDriverFanavaranId( clientKey, nationalCode, birthday, preferredPerson?.driverIsInsurer, ); if (driverId != null) return driverId; } } return null; } private async loadFanavaranOtherPeopleLookups( clientKey: FanavaranClientKey, ): Promise<{ cities: unknown[]; gender: unknown[]; maritalStatus: unknown[]; ans: unknown[]; countries: unknown[]; personKind: unknown[]; }> { const [cities, gender, maritalStatus, ans, countries, personKind] = await Promise.all([ this.getFanavaranLookupRows(clientKey, "cities"), this.getFanavaranLookupRows(clientKey, "gender"), this.getFanavaranLookupRows(clientKey, "marital-status"), this.getFanavaranLookupRows(clientKey, "ans"), this.getFanavaranLookupRows(clientKey, "countries"), this.getFanavaranLookupRows(clientKey, "person-kind"), ]); return { cities, gender, maritalStatus, ans, countries, personKind }; } /** * GEN.44: create the damaged party in Fanavaran when parties inquiry has no row. * Failures stay best-effort — GEN.12 submit continues without DriverId. */ private async registerFanavaranOtherPerson(input: { clientKey: FanavaranClientKey; person: any; claimCaseId?: string; }): Promise { const nationalCode = pickPersonNationalCode(input.person); const birthday = pickPersonBirthday(input.person); if (!nationalCode || !parseJalaliDateParts(birthday)) { this.logger.warn( `[registerFanavaranOtherPerson] SKIP: missing nationalCode or birthday`, ); return null; } let user: { fullName?: string; mobile?: string; city?: string; address?: string; gender?: string; } | null = null; const userId = input.person?.userId; if (userId && Types.ObjectId.isValid(String(userId))) { try { user = await this.userDbService.findOne({ _id: new Types.ObjectId(String(userId)), }); } catch (error) { this.logger.warn( `[registerFanavaranOtherPerson] user lookup failed: ${ error instanceof Error ? error.message : error }`, ); } } const lookups = await this.loadFanavaranOtherPeopleLookups(input.clientKey); const payload = buildFanavaranOtherPeoplePayload( { nationalCode, birthday, fullName: input.person?.fullName ?? user?.fullName, mobile: input.person?.phoneNumber ?? user?.mobile, address: user?.address, cityName: user?.city, gender: user?.gender, }, lookups, ); if (!payload) { this.logger.warn( `[registerFanavaranOtherPerson] SKIP: could not build GEN.44 payload for nationalCode=${nationalCode}`, ); return null; } const url = `${FANAVARAN_LOOKUP_BASE_URL}/common/other-people`; const startedAt = Date.now(); const auditSession: FanavaranAuditSession = { trackingCode: this.fanavaranAuditService.generateTrackingCode(), clientKey: input.clientKey, source: FanavaranAuditSource.AUTO_SUBMIT, claimCaseId: input.claimCaseId, }; await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.CREATE_OTHER_PERSON, status: FanavaranAuditStatus.STARTED, requestUrl: url, requestMethod: "POST", requestBody: payload, requestMeta: { nationalCode }, }); try { const created = await this.fanavaranLookupService.createOtherPerson( input.clientKey, payload, ); const personId = pickFanavaranRecordId(created); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.CREATE_OTHER_PERSON, status: FanavaranAuditStatus.SUCCESS, requestUrl: url, requestMethod: "POST", requestBody: payload, responseBody: created, responseMeta: { otherPersonId: personId, nationalCode }, durationMs: Date.now() - startedAt, }); if (personId != null) { this.logger.log( `[registerFanavaranOtherPerson] CREATED Id=${personId} for nationalCode=${nationalCode}`, ); return personId; } this.logger.warn( `[registerFanavaranOtherPerson] create returned no Id for nationalCode=${nationalCode}; retrying inquiry`, ); } catch (error) { const message = error instanceof Error ? error.message : String(error); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.CREATE_OTHER_PERSON, status: FanavaranAuditStatus.FAILURE, requestUrl: url, requestMethod: "POST", requestBody: payload, errorMessage: message, durationMs: Date.now() - startedAt, }); this.logger.warn( `[registerFanavaranOtherPerson] CREATE failed for nationalCode=${nationalCode}: ${message}; retrying inquiry`, ); } return this.resolveDriverFanavaranId( input.clientKey, nationalCode, birthday, input.person?.driverIsInsurer, ); } /** * زیان‌دیده for GEN.12: claim owner when linked to a blame party, * else the party that is not the guilty one. */ private pickDamagedPartyForFanavaran( blameCase: any, claimCase: any, ): any | null { const parties: any[] = Array.isArray(blameCase?.parties) ? blameCase.parties : []; if (parties.length === 0) return null; if ( blameCase?.type === BlameRequestType.CAR_BODY || blameCase?.type === "CAR_BODY" ) { return resolveDamagedPartyRow(blameCase) ?? parties[0] ?? null; } const ownerId = claimCase?.owner?.userId; if (ownerId) { const byOwner = parties.find( (party) => String(party?.person?.userId ?? "") === String(ownerId), ); if (byOwner) return byOwner; } const guiltyPartyId = this.resolveGuiltyPartyIdV2( parties, blameCase?.expert?.decision?.guiltyPartyId, ); if (guiltyPartyId) { const injured = parties.find( (party) => String(party?.person?.userId ?? "") !== String(guiltyPartyId), ); if (injured) return injured; } return ( parties.find((party) => party?.role === PartyRole.FIRST) ?? parties[0] ); } 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; } /** * Build Fanavaran GEN.03 payload from local claim/blame data. * * HTTP preview/submit always pass forceRefreshPolicy + forceRefreshUsedId, * so PolicyId and AccidentVehicleUsedId are re-inquired every call. * Auth still uses the shared midnight-TTL token cache (not a new login per preview). */ 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; /** * Re-run VIN inquiry / vehicle GET for AccidentVehicleUsedId. * Submit always re-inquires. If the API still returns nothing, the * tenant config default is used. */ forceRefreshUsedId?: boolean; }, ): Promise { const profile = getFanavaranClientProfile(clientKey); const logPrefix = `[Fanavaran ${clientKey} V2] claimCaseId=${claimCaseId}`; const forceRefreshPolicy = options?.forceRefreshPolicy === true; const forceRefreshUsedId = options?.forceRefreshUsedId === true; const requirePolicyId = options?.requirePolicyId === true; const persistPayload = options?.persistPayload !== false; const auditSession = options?.auditSession ?? ({ trackingCode: this.fanavaranAuditService.generateTrackingCode(), clientKey, source: FanavaranAuditSource.PREVIEW, claimCaseId, } satisfies FanavaranAuditSession); try { const debug = { clientKey, claimCaseId, forceRefreshPolicy, steps: { claimCaseFound: false, blameRequestLinked: false, blameCaseFound: false, expertDecisionFound: false, accidentReasonFound: false, firstPartyFound: false, firstPartyLocationFound: false, damageReplyFound: false, guiltyPartyIdFound: false, nationalCodeOfInsurerFound: false, policyIdFromCache: 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, note: "HTTP preview/submit always re-inquire PolicyId and AccidentVehicleUsedId. Auth token is shared until Tehran midnight.", }; 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 cachedPolicyId = (claimCase as any)?.fanavaranSync?.baseClaim?.policyId ?? (claimCase as any)?.fanavaranSync?.baseClaim?.lastPayload?.PolicyId ?? null; const accidentAt = blameCase.type === BlameRequestType.CAR_BODY ? ((firstParty as { statement?: { accidentDate?: Date } })?.statement ?.accidentDate ?? (blameCase as { createdAt?: Date }).createdAt) : (blameCase as { createdAt?: Date }).createdAt; const payload = await this.buildFanavaranSubmitPayload({ accidentReason: selectedAccidentReason, createdAt: accidentAt, location: firstParty?.location, damageParts, defaults: profile.defaults, logPrefix, requirePolicyId, resolvePolicyId: async () => { const product = fanavaranClaimProductFromBlameType(blameCase.type); if (cachedPolicyId != null && !forceRefreshPolicy) { debug.steps.policyIdFromCache = true; debug.values.policyId = Number(cachedPolicyId); return Number(cachedPolicyId); } if (product === "car-body") { const storedPolicyId = pickStoredCarBodyPolicyId(blameCase); if (storedPolicyId != null) { debug.values.policyId = storedPolicyId; debug.steps.policyInquirySucceeded = true; return storedPolicyId; } const nationalCodeOfInsurer = pickCarBodyPolicyNationalCode(blameCase); debug.steps.nationalCodeOfInsurerFound = !!nationalCodeOfInsurer; debug.values.nationalCodeOfInsurer = nationalCodeOfInsurer; if (!nationalCodeOfInsurer) { this.logger.warn( `${logPrefix} car-body policyholder national code is missing`, ); debug.failureReason = "car-body policyholder national code is missing"; return null; } debug.steps.policyInquiryAttempted = true; const policyId = await this.getPolicyIdFromNationalCode( nationalCodeOfInsurer, profile.auth, logPrefix, auditSession, { clientKey, claimCaseId, product: "car-body" }, ); debug.values.policyId = policyId; debug.steps.policyInquirySucceeded = policyId !== null; if (policyId === null) { debug.failureReason = debug.failureReason ?? "car-body policy inquiry returned no PolicyId (timeout, network error, empty response, or backoff)"; } return policyId; } 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( `${logPrefix} guiltyPartyId not found in expert.decision`, ); debug.failureReason = "expert.decision.guiltyPartyId is missing"; return null; } if (!nationalCodeOfInsurer) { this.logger.warn( `${logPrefix} 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, profile.auth, logPrefix, auditSession, { clientKey, claimCaseId, product: "third-party" }, ); debug.values.policyId = policyId; debug.steps.policyInquirySucceeded = policyId !== null; if (policyId === null) { debug.failureReason = debug.failureReason ?? "policy inquiry returned no PolicyId (timeout, network error, empty response, or backoff)"; } return policyId; }, }); const carBodyAccidentTime = ( firstParty as { statement?: { accidentTime?: string } } | undefined )?.statement?.accidentTime; if ( blameCase.type === BlameRequestType.CAR_BODY && typeof carBodyAccidentTime === "string" && carBodyAccidentTime.trim() ) { payload.AccidentTime = carBodyAccidentTime.trim(); } const cachedUsedId = forceRefreshUsedId || forceRefreshPolicy ? null : (claimCase as any)?.fanavaranSync?.baseClaim?.accidentVehicleUsedId; const cachedUsedSource = (claimCase as any)?.fanavaranSync?.baseClaim ?.accidentVehicleUsedSource; const canReuseApiUsedId = !forceRefreshUsedId && !forceRefreshPolicy && cachedUsedId != null && Number(cachedUsedId) > 0 && (cachedUsedSource === "vin-inquiry" || cachedUsedSource === "policy-vehicle"); delete payload.AccidentVehicleUsedId; let usedSource: "vin-inquiry" | "policy-vehicle" | null = null; if (canReuseApiUsedId) { payload.AccidentVehicleUsedId = Number(cachedUsedId); usedSource = cachedUsedSource; } else { const guiltyPartyIdForVin = this.resolveGuiltyPartyIdV2( blameCase.parties ?? [], expertDecision?.guiltyPartyId, ); const resolved = await this.resolveAccidentVehicleUsedFromApis({ clientKey, parties: blameCase.parties ?? [], guiltyPartyId: guiltyPartyIdForVin, policyId: typeof payload.PolicyId === "number" ? payload.PolicyId : null, product: fanavaranClaimProductFromBlameType(blameCase.type), }); if (resolved.usedId != null) { payload.AccidentVehicleUsedId = resolved.usedId; usedSource = resolved.source; } } if (parseFanavaranId(payload.AccidentVehicleUsedId) == null) { payload.AccidentVehicleUsedId = profile.defaults.AccidentVehicleUsedId; this.logger.log( `${logPrefix} AccidentVehicleUsedId API miss; using config default ${payload.AccidentVehicleUsedId}`, ); } 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 } : {}), ...(typeof payload.AccidentVehicleUsedId === "number" ? { "fanavaranSync.baseClaim.accidentVehicleUsedId": payload.AccidentVehicleUsedId, ...(usedSource ? { "fanavaranSync.baseClaim.accidentVehicleUsedSource": usedSource, } : {}), } : {}), }, }); } if (options?.debug) { return { clientKey, payload, debug, }; } return payload; } catch (error) { this.logger.error( `Error in previewFanavaranSubmitV2 (${clientKey})`, error, ); throw error; } } /** @deprecated Use previewFanavaranSubmitV2(claimCaseId, "parsian", options) */ async fanavaranSubmitParsianV2( claimCaseId: string, options?: { debug?: boolean }, ): Promise { return this.previewFanavaranSubmitV2(claimCaseId, "parsian", options); } /** * Submit Fanavaran data (V2 claimCases + blameCases) for a specific client. */ async submitFanavaranV2( claimCaseId: string, clientKey: FanavaranClientKey, bodyOverride?: Record, ): Promise { try { return await this.executeFanavaranV2Submit( claimCaseId, clientKey, bodyOverride, FanavaranAuditSource.SUBMIT, ); } catch (error) { this.logger.error( `[Fanavaran ${clientKey} V2] Error submitting to Fanavaran`, error, ); throw new BadGatewayException(this.extractFanavaranErrorMessage(error)); } } /** @deprecated Use submitFanavaranV2(claimCaseId, "parsian") */ async submitToFanavaranParsianV2(claimCaseId: string): Promise { return this.submitFanavaranV2(claimCaseId, "parsian"); } async previewFanavaranDamageCaseV2( claimCaseId: string, clientKey: FanavaranClientKey, ): Promise { const profile = getFanavaranClientProfile(clientKey); const claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) { throw new NotFoundException("Claim case not found"); } if (!claimCase.blameRequestId) { throw new BadRequestException("Blame case not linked to claim case"); } const blameCase = await this.blameRequestDbService.findById( claimCase.blameRequestId, ); if (!blameCase) { throw new NotFoundException("Blame case not found"); } if (!isFanavaranSubmitSupportedBlameType(blameCase.type)) { throw new BadRequestException( "Fanavaran damage-case submit only applies to THIRD_PARTY and CAR_BODY claims", ); } const product = fanavaranClaimProductFromBlameType(blameCase.type); if (!fanavaranHasDamageCaseStage(product)) { return { clientKey, claimCaseId, claimId: claimCase.claimId ?? null, dmgCaseId: claimCase.dmgCaseId ?? null, submitUrl: null, skipped: true, skipReason: FANAVARAN_HULL_NO_DAMAGE_CASE_REASON, warning: FANAVARAN_HULL_NO_DAMAGE_CASE_REASON, payload: null, }; } const payload = await this.buildFanavaranDamageCasePayload({ claimCase, blameCase, selectedParts: claimCase.damage?.selectedParts ?? [], clientKey, defaults: profile.defaults, }); const warnings: string[] = []; if (!claimCase.claimId) { warnings.push("Fanavaran base claimId is required before submit."); } if (payload.DriverId == null) { warnings.push( "Damaged party is not in Fanavaran yet; submit will register them via other-people (GEN.44).", ); } return { clientKey, claimCaseId, claimId: claimCase.claimId ?? null, dmgCaseId: claimCase.dmgCaseId ?? null, submitUrl: claimCase.claimId ? fanavaranClaimStageUrl(product, "damage-case", claimCase.claimId) : null, warning: warnings.length ? warnings.join(" ") : undefined, payload, }; } async submitFanavaranDamageCaseV2( claimCaseId: string, clientKey: FanavaranClientKey, bodyOverride?: Record, ): Promise { try { return await this.executeFanavaranDamageCaseSubmit( claimCaseId, clientKey, undefined, FanavaranAuditSource.SUBMIT, bodyOverride, ); } catch (error) { this.logger.error( `[Fanavaran ${clientKey} V2] Error submitting damage case to Fanavaran`, error, ); throw new BadGatewayException(this.extractFanavaranErrorMessage(error)); } } private getSubmittedFanavaranAttachmentFileNames( claimCase: any, ): Map { const submitted = new Map(); const files = claimCase?.fanavaranSync?.attachments?.files; if (!Array.isArray(files)) return submitted; for (const file of files) { const fileName = (file as { fileName?: unknown })?.fileName; if (typeof fileName === "string" && fileName.trim()) { submitted.set(fileName, (file as { fileId?: unknown })?.fileId); } } return submitted; } private appendFanavaranAttachmentCandidate( candidates: FanavaranAttachmentCandidate[], seen: Set, submittedFiles: Map, input: { path?: unknown; fileName?: unknown; source: string }, fileTypeId: number, ): void { const path = typeof input.path === "string" ? input.path : ""; const fileName = typeof input.fileName === "string" && input.fileName.trim() ? input.fileName : path ? basename(path) : ""; if (!path || !fileName || seen.has(fileName)) return; seen.add(fileName); candidates.push({ path, fileName, source: input.source, content: { FileName: fileName, FileTypeId: fileTypeId, Files: [{ FileName: fileName, FileTypeId: fileTypeId }], }, alreadySubmitted: submittedFiles.has(fileName), submittedFileId: submittedFiles.get(fileName), }); } private async collectFanavaranAttachmentCandidates( claimCaseId: string, clientKey: FanavaranClientKey, ): Promise<{ claimCase: any; candidates: FanavaranAttachmentCandidate[]; }> { const profile = getFanavaranClientProfile(clientKey); const claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) { throw new NotFoundException("Claim case not found"); } const candidates: FanavaranAttachmentCandidate[] = []; const seen = new Set(); const submittedFiles = this.getSubmittedFanavaranAttachmentFileNames(claimCase); const fileTypeId = profile.defaults.ClaimFileTypeId; const documents = await this.claimRequiredDocumentDbService.findByClaimId(claimCaseId); for (const document of documents) { this.appendFanavaranAttachmentCandidate( candidates, seen, submittedFiles, { path: (document as { path?: unknown }).path, fileName: (document as { fileName?: unknown }).fileName, source: `document:${String((document as { documentType?: unknown }).documentType ?? "unknown")}`, }, fileTypeId, ); } const carAngles = claimCase.media?.carAngles; const angleEntries = carAngles instanceof Map ? Array.from(carAngles.entries()) : carAngles && typeof carAngles === "object" ? Object.entries(carAngles) : []; for (const [angleKey, image] of angleEntries) { const capture = image as { path?: unknown; fileName?: unknown }; this.appendFanavaranAttachmentCandidate( candidates, seen, submittedFiles, { path: capture?.path, fileName: capture?.fileName, source: `capture:angle:${angleKey}`, }, fileTypeId, ); } const damagedParts = claimCase.media?.damagedParts; const partEntries = Array.isArray(damagedParts) ? damagedParts.map((part, index) => [String(index), part] as const) : damagedParts && typeof damagedParts === "object" ? Object.entries(damagedParts) : []; for (const [partKey, image] of partEntries) { const capture = image as { path?: unknown; fileName?: unknown }; this.appendFanavaranAttachmentCandidate( candidates, seen, submittedFiles, { path: capture?.path, fileName: capture?.fileName, source: `capture:part:${partKey}`, }, fileTypeId, ); } return { claimCase, candidates }; } async previewFanavaranAttachmentsV2( claimCaseId: string, clientKey: FanavaranClientKey, ): Promise { const { claimCase, candidates } = await this.collectFanavaranAttachmentCandidates(claimCaseId, clientKey); const claimsUrl = await this.fanavaranClaimsUrlForClaim(claimCaseId); return { clientKey, claimCaseId, claimId: claimCase.claimId ?? null, claimNo: claimCase.claimNo ?? null, dmgCaseId: claimCase.dmgCaseId ?? null, submitUrl: claimCase.claimId ? `${claimsUrl}/${claimCase.claimId}/files` : null, warning: claimCase.claimId ? undefined : "Fanavaran base claimId is required before attachment submit.", totalLocalImages: candidates.length, alreadySubmitted: candidates.filter( (candidate) => candidate.alreadySubmitted, ).length, pendingSubmit: candidates.filter( (candidate) => !candidate.alreadySubmitted, ).length, attachments: candidates, }; } async submitFanavaranAttachmentsV2( claimCaseId: string, clientKey: FanavaranClientKey, ): Promise { const { candidates } = await this.collectFanavaranAttachmentCandidates( claimCaseId, clientKey, ); const pending = candidates.filter( (candidate) => !candidate.alreadySubmitted && existsSync(candidate.path), ); if (pending.length === 0) { return { clientKey, claimCaseId, totalLocalImages: candidates.length, skippedAlreadySubmitted: candidates.length, attempted: 0, submitted: 0, failed: 0, skipped: candidates.length, results: [], }; } const profile = getFanavaranClientProfile(clientKey); let claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase?.claimId) { try { await this.ensureFanavaranBaseClaim( claimCaseId, clientKey, FanavaranAuditSource.SUBMIT, ); claimCase = await this.claimCaseDbService.findById(claimCaseId); } catch (error) { const warning = this.extractFanavaranErrorMessage(error); return { clientKey, claimCaseId, totalLocalImages: candidates.length, skippedAlreadySubmitted: candidates.length - pending.length, attempted: 0, submitted: 0, failed: 0, skipped: pending.length, warning, results: pending.map((c) => ({ attempted: false, submitted: false, skipped: true, skipReason: warning, fileName: c.fileName, })), }; } } if (!claimCase?.claimId) { return { clientKey, claimCaseId, totalLocalImages: candidates.length, skippedAlreadySubmitted: candidates.length - pending.length, attempted: 0, submitted: 0, failed: 0, skipped: pending.length, warning: "Fanavaran base claimId is missing", results: pending.map((c) => ({ attempted: false, submitted: false, skipped: true, skipReason: "Fanavaran base claimId is missing", fileName: c.fileName, })), }; } const url = `${await this.fanavaranClaimsUrlForClaim(claimCaseId)}/${claimCase.claimId}/files`; const fileTypeId = profile.defaults.ClaimFileTypeId; const logPrefix = `[Fanavaran ${clientKey} V2 Attachment Batch] claimCaseId=${claimCaseId}`; const results: any[] = []; for (let i = 0; i < pending.length; i++) { const candidate = pending[i]; const content = { FileName: candidate.fileName, FileTypeId: fileTypeId, }; this.logger.log( `${logPrefix} [${i + 1}/${pending.length}] UPLOADING: ${candidate.fileName}`, ); try { const response = await this.postFanavaranMultipart( url, content, [{ path: candidate.path, fileName: candidate.fileName }], clientKey, undefined, claimCaseId, ); this.logger.log( `${logPrefix} [${i + 1}/${pending.length}] SUCCESS: status=${response.status} body=${JSON.stringify(response.data)}`, ); results.push({ attempted: true, submitted: true, claimId: claimCase.claimId, fileName: candidate.fileName, fanavaranResponse: response.data, }); } catch (error) { const warning = this.extractFanavaranErrorMessage(error); this.logger.warn( `${logPrefix} [${i + 1}/${pending.length}] FAILED: ${warning}`, ); results.push({ attempted: true, submitted: false, warning, fileName: candidate.fileName, }); // Schedule a retry for this individual file via autoSubmitFanavaranAttachment, // which handles idempotency (won't re-upload if already recorded as submitted) // and has its own inner retry/back-off via scheduleFanavaranRetry. await this.scheduleFanavaranRetry( claimCaseId, "attachments", () => this.autoSubmitFanavaranAttachment( claimCaseId, { path: candidate.path, fileName: candidate.fileName, source: candidate.source, }, { clientKey, auditSource: FanavaranAuditSource.AUTO_SUBMIT }, ), `${logPrefix} [${i + 1}/${pending.length}] retry`, { error, clientKey }, ); } if (i < pending.length - 1) { await new Promise((resolve) => setTimeout(resolve, 1_000)); } } return results; } async autoSubmitFanavaranAttachment( claimCaseId: string, file: { path?: string | null; fileName?: string | null; source?: string }, options?: { clientKey?: FanavaranClientKey; auditSource?: FanavaranAuditSource; }, ): Promise { const clientKey = options?.clientKey ?? resolveFanavaranClientKey(); const auditSource = options?.auditSource ?? FanavaranAuditSource.AUTO_SUBMIT; const logPrefix = `[Fanavaran ${clientKey} V2 Attachment Auto] claimCaseId=${claimCaseId}`; const filePath = file.path ? String(file.path) : ""; const fileName = file.fileName ? String(file.fileName) : filePath ? basename(filePath) : ""; const profile = getFanavaranClientProfile(clientKey); let url = ""; let content: Record = {}; try { if (!filePath || !fileName) { return { attempted: false, submitted: false, skipped: true, skipReason: "Attachment file path or filename is missing", }; } if (!existsSync(filePath)) { return { attempted: false, submitted: false, skipped: true, skipReason: `Attachment file does not exist: ${filePath}`, fileName, }; } let claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) { return { attempted: false, submitted: false, warning: "Claim case not found for Fanavaran attachment submit.", fileName, }; } if (!claimCase.claimId) { if (options?.clientKey) { await this.executeFanavaranV2Submit(claimCaseId, clientKey); } else { await this.autoSubmitToFanavaranV2OnClaimCreated(claimCaseId); } claimCase = await this.claimCaseDbService.findById(claimCaseId); } if (!claimCase?.claimId) { const warning = "Fanavaran base claim is missing, so attachment was not submitted."; await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $set: { "fanavaranSync.attachments.status": "skipped", "fanavaranSync.attachments.lastTriedAt": new Date(), "fanavaranSync.attachments.lastError": warning, }, }); return { attempted: false, submitted: false, skipped: true, skipReason: warning, warning, fileName, }; } url = `${await this.fanavaranClaimsUrlForClaim(claimCaseId)}/${claimCase.claimId}/files`; content = { FileName: fileName, FileTypeId: profile.defaults.ClaimFileTypeId, Files: [ { FileName: fileName, FileTypeId: profile.defaults.ClaimFileTypeId }, ], }; const auditSession: FanavaranAuditSession = { trackingCode: this.fanavaranAuditService.generateTrackingCode(), clientKey, source: auditSource, claimCaseId, }; const startedAt = Date.now(); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_ATTACHMENT, status: FanavaranAuditStatus.STARTED, requestUrl: url, requestMethod: "POST", requestBody: content, requestMeta: { claimId: claimCase.claimId, claimNo: claimCase.claimNo, dmgCaseId: claimCase.dmgCaseId, fileName, source: file.source, }, }); const response = await this.postFanavaranMultipart( url, content, [{ path: filePath, fileName }], clientKey, auditSession, ); this.logger.log(`${logPrefix} Fanavaran response: ${JSON.stringify(response.data)}`); const fileId = response.data?.Id; const exchange = this.fanavaranAuditService.captureAxiosExchange(response); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_ATTACHMENT, status: FanavaranAuditStatus.SUCCESS, requestUrl: url, requestMethod: "POST", httpStatus: response.status, requestBody: content, responseHeaders: exchange.responseHeaders, responseBody: exchange.responseBody, responseMeta: { claimId: claimCase.claimId, fileId, fileName }, durationMs: Date.now() - startedAt, }); await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $set: { "fanavaranSync.attachments.status": "success", "fanavaranSync.attachments.lastTriedAt": new Date(), "fanavaranSync.attachments.claimId": claimCase.claimId, "fanavaranSync.attachments.response": response.data, }, $push: { "fanavaranSync.attachments.files": { fileId, fileName, source: file.source, response: response.data, submittedAt: new Date(), }, history: { type: "FANAVARAN_ATTACHMENT_AUTO_SUBMIT_SUCCEEDED", actor: { actorType: "system" }, timestamp: new Date(), metadata: { clientKey, claimId: claimCase.claimId, fileId, fileName, source: file.source, }, }, }, }); return { attempted: true, submitted: true, claimId: claimCase.claimId, fileId, fileName, fanavaranResponse: response.data, }; } catch (error) { const warning = this.extractFanavaranErrorMessage(error); this.logger.warn(`${logPrefix} Fanavaran error: ${warning}`); try { await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $set: { "fanavaranSync.attachments.status": "failed", "fanavaranSync.attachments.lastTriedAt": new Date(), "fanavaranSync.attachments.lastError": warning, }, $push: { history: { type: "FANAVARAN_ATTACHMENT_AUTO_SUBMIT_FAILED", actor: { actorType: "system" }, timestamp: new Date(), metadata: { clientKey, error: warning, fileName, source: file.source, }, }, }, }); } catch (historyError) { this.logger.error( `${logPrefix} Failed to record Fanavaran attachment failure history`, historyError, ); } // Schedule retry for attachments await this.scheduleFanavaranRetry( claimCaseId, "attachments", () => this.autoSubmitFanavaranAttachment(claimCaseId, file, options), logPrefix, { error, clientKey }, ); return { attempted: true, submitted: false, warning, fileName, }; } } private fanavaranLookupDefinition(name: string) { const definition = FANAVARAN_REMOTE_LOOKUPS.find( (item) => item.name === name, ); if (!definition) { throw new Error(`Unknown Fanavaran lookup definition: ${name}`); } return definition; } private async getFanavaranLookupRows( clientKey: FanavaranClientKey, name: string, ): Promise { const definition = this.fanavaranLookupDefinition(name); try { const data = await this.fanavaranLookupService.getRemoteLookup( clientKey, definition.url, definition.cacheFile, ); return Array.isArray(data) ? data : []; } catch { this.logger.warn( `Lookup "${name}" unavailable for ${clientKey}; returning empty.`, ); return []; } } private normalizeFanavaranLookupText(value: unknown): string { return String(value ?? "") .toLowerCase() .replace(/[ي]/g, "ی") .replace(/[ك]/g, "ک") .replace(/[\u200c\s_\-\/]+/g, "") .replace(/[^\p{L}\p{N}]/gu, ""); } private lookupRowId(row: unknown): number | null { if (!row || typeof row !== "object") return null; const raw = (row as { Id?: unknown; id?: unknown; Code?: unknown }).Id ?? (row as { id?: unknown }).id ?? (row as { Code?: unknown }).Code; const id = Number(raw); return Number.isFinite(id) ? id : null; } private lookupRowTextCandidates(row: unknown): string[] { if (!row || typeof row !== "object") return []; const r = row as Record; return [ r.Caption, r.caption, r.Title, r.title, r.Name, r.name, r.Text, r.text, r.Description, r.description, r.Value, r.value, ] .filter((value): value is string => typeof value === "string") .filter(Boolean); } private findLookupIdByText(rows: unknown[], texts: unknown[]): number | null { const wanted = texts .map((text) => this.normalizeFanavaranLookupText(text)) .filter(Boolean); if (!wanted.length) return null; for (const row of rows) { const rowTexts = this.lookupRowTextCandidates(row).map((text) => this.normalizeFanavaranLookupText(text), ); if ( rowTexts.some((rowText) => wanted.some( (want) => rowText === want || rowText.includes(want) || want.includes(rowText), ), ) ) { return this.lookupRowId(row); } } return null; } private parseFanavaranMoney(value: unknown): number { if (value == null || value === "") return 0; if (typeof value === "number" && Number.isFinite(value)) return value; const normalized = String(value) .replace(/[۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728)) .replace(/[٠-٩]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1632)) .replace(/,/g, "") .trim(); const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : 0; } private fanavaranPartLabel(part: any): string { const damage = part?.carPartDamage; if (damage && typeof damage === "object") { const d = damage as { label_fa?: unknown; part?: unknown; name?: unknown; catalogKey?: unknown; }; return String( d.label_fa ?? d.part ?? d.name ?? d.catalogKey ?? part?.partId ?? "", ); } return String(damage ?? part?.partId ?? ""); } private fanavaranPartLookupTexts(part: any): unknown[] { const damage = part?.carPartDamage; if (damage && typeof damage === "object") { const d = damage as Record; return [ d.label_fa, d.part, d.name, d.catalogKey, part.partId, this.fanavaranPartLabel(part), ]; } return [damage, part?.partId, this.fanavaranPartLabel(part)]; } private fanavaranDaghiWasteValue(daghi: unknown): number { if (!daghi || typeof daghi !== "object") return this.parseFanavaranMoney(daghi); const d = daghi as { price?: unknown }; return this.parseFanavaranMoney(d.price); } private findPriceDropLineForPart(priceDrop: any, part: any): any { const lines = Array.isArray(priceDrop?.partLines) ? priceDrop.partLines : []; return lines.find((line: any) => { const linePartId = parseCatalogPartIdInput(line?.partId); const partId = parseCatalogPartIdInput(part?.partId); return linePartId != null && partId != null && linePartId === partId; }); } private findDropAmountStatusId( rows: unknown[], priceDropTotal: number, ): number | null { const positiveTexts = priceDropTotal > 0 ? ["مشمول", "دارد", "افت", "has", "yes"] : ["فاقد", "ندارد", "عدم", "بدون", "no"]; return this.findLookupIdByText(rows, positiveTexts); } private buildFanavaranExpertiseDmgSection(input: { part: any; warnings: string[]; }): Record { const partId = parseCatalogPartIdInput(input.part?.partId); const accidentLevel = typeof input.part?.typeOfDamage === "number" ? input.part.typeOfDamage : typeof input.part?.typeOfDamage === "string" && /^\d+$/.test(input.part.typeOfDamage) ? Number(input.part.typeOfDamage) : FANAVARAN_DEFAULT_ACCIDENT_LEVEL; const label = this.fanavaranPartLabel(input.part); if (partId == null) { input.warnings.push( `No DmgSectionId (partId) for part "${label}".`, ); } return { Desc: input.part?.typeOfDamage ?? label, DmgSectionId: partId, AccidentLevel: accidentLevel, ComponentReplacementCost: this.parseFanavaranMoney(input.part?.price), RepairWage: this.parseFanavaranMoney(input.part?.salary), WasteValue: this.fanavaranDaghiWasteValue(input.part?.daghi), }; } private async buildFanavaranExpertisePayload(input: { claimCase: any; clientKey: FanavaranClientKey; }): Promise<{ payload: Record; warnings: string[]; replyKey?: string; }> { const profile = getFanavaranClientProfile(input.clientKey); const active = getActiveV2ExpertReply(input.claimCase as any); // GEN.08 ClaimExpertId = tenant ExpertiseClaimExpertId (assessor role). // Tejaratno: 2709. Parsian: 29. Must NOT reuse GEN.03 ClaimExpertId // (مسئول پرونده مالی — Tejaratno 4543092 / Parsian 154). const claimExpertId = profile.defaults.ExpertiseClaimExpertId; const warnings: string[] = []; if (!active?.parts?.length) { warnings.push("No active damage expert reply with priced parts exists."); } const reply = active?.reply as any; const parts = (active?.parts ?? []).filter((part: any) => { if (part.factorNeeded !== true) return true; return this.parseFanavaranMoney(part.totalPayment) > 0; }) as any[]; if (!parts.length) { warnings.push("No submit-ready expert pricing lines exist yet."); } const [ inspectionPlaces, dropAmountStatuses, ] = await Promise.all([ this.getFanavaranLookupRows(input.clientKey, "inspection-place"), this.getFanavaranLookupRows(input.clientKey, "drop-amount-status"), ]); const priceDrop = input.claimCase.evaluation?.priceDrop ?? {}; const priceDropTotal = this.parseFanavaranMoney(priceDrop?.total); const inspectionPlaceId = 282; const dropAmountStatus = 5458; const dmgSections = parts.map((part) => this.buildFanavaranExpertiseDmgSection({ part, warnings, }), ); const repairWage = dmgSections.reduce( (sum, section) => sum + this.parseFanavaranMoney(section.RepairWage), 0, ); const componentReplacementCost = dmgSections.reduce( (sum, section) => sum + this.parseFanavaranMoney(section.ComponentReplacementCost), 0, ); const wasteValue = dmgSections.reduce( (sum, section) => sum + this.parseFanavaranMoney(section.WasteValue), 0, ); const submittedAt = reply?.submittedAt ?? new Date(); const product = await this.resolveFanavaranClaimProduct( input.claimCase?._id ? String(input.claimCase._id) : undefined, ); if (product === "car-body") { const blame = input.claimCase?.blameRequestId ? await this.blameRequestDbService.findById( String(input.claimCase.blameRequestId), ) : null; const hullSections = parts.map((part: any) => toFanavaranHullExpertiseDmgSection({ partId: part?.partId, desc: this.fanavaranPartLabel(part), wasteValue: this.fanavaranDaghiWasteValue(part?.daghi), amount: this.parseFanavaranMoney(part?.price) + this.parseFanavaranMoney(part?.salary), }), ); return { replyKey: active?.replyKey, warnings, payload: toFanavaranHullExpertisePayload({ claimExpertId, dmgAssessmentDate: this.convertToPersianDate(submittedAt), inspectionTime: this.getTime24Hour(submittedAt), wage: repairWage, componentReplacementCost, wasteValue, dropAmount: priceDropTotal || 0, vehicleCurrentValue: this.parseFanavaranMoney(priceDrop?.carPrice) || null, vehicle: pickHullVehicleIdentity(blame), dmgSections: hullSections, }), }; } const payload: Record = { ClaimExpertId: claimExpertId, ComponentReplacementCost: componentReplacementCost, DmgAssessmentDate: this.convertToPersianDate(submittedAt), InspectionPlaceId: inspectionPlaceId, InspectionTime: this.getTime24Hour(submittedAt), RepairWage: repairWage, WasteValue: wasteValue, WentDistanceByExpert: null, DropAmountStatus: dropAmountStatus, DropAmountAdditionsDeductions: priceDropTotal || 0, ComponentReplacementTaxAndToll: 0, DamagedVehicleCurrentPrice: this.parseFanavaranMoney(priceDrop?.carPrice) || null, DmgSections: dmgSections, }; payload.DmgCaseId = input.claimCase.dmgCaseId ?? null; return { replyKey: active?.replyKey, warnings, payload, }; } private assertFanavaranExpertisePayloadReady( payload: Record, warnings: string[], options?: { requireDmgCaseId?: boolean; requireThirdPartyLookups?: boolean }, ): void { const sections = Array.isArray(payload.DmgSections) ? payload.DmgSections : []; if (options?.requireDmgCaseId !== false && !payload.DmgCaseId) warnings.push("DmgCaseId is required."); if (options?.requireThirdPartyLookups !== false) { if (!payload.InspectionPlaceId) warnings.push("InspectionPlaceId is required."); if (!payload.DropAmountStatus) warnings.push("DropAmountStatus is required."); } if (!payload.InspectionPlaceId) warnings.push("InspectionPlaceId is required."); if (!payload.DropAmountStatus) warnings.push("DropAmountStatus is required."); if (!sections.length) warnings.push("At least one DmgSections row is required."); for (const [index, section] of sections.entries()) { const row = section as Record; if (!row.DmgSectionId) { warnings.push(`DmgSections[${index}].DmgSectionId is required.`); } if (!row.AccidentLevel) { warnings.push(`DmgSections[${index}].AccidentLevel is required.`); } } if (warnings.length) { throw new BadRequestException({ message: "Fanavaran expertise payload is not ready.", warnings: Array.from(new Set(warnings)), }); } } async previewFanavaranExpertiseV2( claimCaseId: string, clientKey: FanavaranClientKey, ): Promise { const claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) throw new NotFoundException("Claim case not found"); 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, claimId: claimCase.claimId ?? null, dmgCaseId: claimCase.dmgCaseId ?? null, expertiseId: claimCase.expertiseId ?? null, replyKey, submitUrl: claimCase.claimId ? `${await this.fanavaranClaimsUrlForClaim(claimCaseId)}/${claimCase.claimId}/expertise` : null, ready: warnings.length === 0, warnings: Array.from(new Set(warnings)), payload, }; } async submitFanavaranExpertiseV2( claimCaseId: string, clientKey: FanavaranClientKey, bodyOverride?: Record, ): Promise { try { await this.ensureFanavaranDamageCase( claimCaseId, clientKey, FanavaranAuditSource.SUBMIT, ); const claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) throw new NotFoundException("Claim case not found"); const product = await this.resolveFanavaranClaimProduct(claimCaseId); const built = await this.buildFanavaranExpertisePayload({ claimCase, clientKey, }); const payload = applyFanavaranManualPayloadOverrides( built.payload, bodyOverride, FANAVARAN_EXPERTISE_MANUAL_OVERRIDE_KEYS, ); this.assertFanavaranExpertisePayloadReady(payload, built.warnings, { ...hullExpertiseAssertFields(product), }); return await this.executeFanavaranExpertiseSubmit( claimCaseId, clientKey, payload, FanavaranAuditSource.SUBMIT, ); } catch (error) { if (error instanceof HttpException) throw error; this.logger.error( `[Fanavaran ${clientKey} V2] Error submitting expertise to Fanavaran`, error, ); throw new BadGatewayException(this.extractFanavaranErrorMessage(error)); } } private async executeFanavaranExpertiseSubmit( claimCaseId: string, clientKey: FanavaranClientKey, payload: Record, auditSource: FanavaranAuditSource, ): Promise { let claimCase = await this.claimCaseDbService.findById(claimCaseId); const product = await this.resolveFanavaranClaimProduct(claimCaseId); if ( !claimCase?.claimId || (fanavaranHasDamageCaseStage(product) && claimCase.dmgCaseId == null) ) { await this.ensureFanavaranDamageCase( claimCaseId, clientKey, auditSource, ); claimCase = await this.claimCaseDbService.findById(claimCaseId); } if (!claimCase?.claimId) { throw new BadRequestException( "Fanavaran claimId is required before submitting expertise", ); } if (claimCase.expertiseId != null) { this.logger.log( `[executeFanavaranExpertiseSubmit] Already have expertiseId=${claimCase.expertiseId}; skipping Fanavaran POST`, ); // Catch-up / deduped: SMS may still be pending if expertise predated this notify. await this.notifyClaimOwnerFanavaranClaimRegistered({ claimCaseId, claimId: claimCase.claimId, claimNo: claimCase.claimNo, logPrefix: `[Fanavaran ${clientKey} V2 Expertise]`, }); return ( (claimCase as any)?.fanavaranSync?.expertise?.response ?? { Id: claimCase.expertiseId, } ); } const url = `${await this.fanavaranClaimsUrlForClaim(claimCaseId)}/${claimCase.claimId}/expertise`; const startedAt = Date.now(); const auditSession: FanavaranAuditSession = { trackingCode: this.fanavaranAuditService.generateTrackingCode(), clientKey, source: auditSource, claimCaseId, }; await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_EXPERTISE, status: FanavaranAuditStatus.STARTED, requestUrl: url, requestMethod: "POST", requestBody: payload, requestMeta: { claimId: claimCase.claimId }, }); try { const response = await this.postFanavaranJson( url, payload, clientKey, auditSession, ); const expertiseId = response.data?.Id; const exchange = this.fanavaranAuditService.captureAxiosExchange(response); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_EXPERTISE, status: FanavaranAuditStatus.SUCCESS, requestUrl: url, requestMethod: "POST", httpStatus: response.status, requestBody: payload, responseHeaders: exchange.responseHeaders, responseBody: exchange.responseBody, responseMeta: { claimId: claimCase.claimId, expertiseId }, durationMs: Date.now() - startedAt, }); await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $set: { ...(expertiseId !== undefined ? { expertiseId } : {}), "fanavaranSync.expertise": { status: "success", lastTriedAt: new Date(), claimId: claimCase.claimId, dmgCaseId: payload.DmgCaseId, expertiseId, response: response.data, }, }, }); // Best-effort: SMS after last Fanavaran stage (expertise) succeeds await this.notifyClaimOwnerFanavaranClaimRegistered({ claimCaseId, claimId: claimCase.claimId, claimNo: claimCase.claimNo, logPrefix: `[Fanavaran ${clientKey} V2 Expertise]`, }); return response.data; } catch (error) { const exchange = this.fanavaranAuditService.captureAxiosExchange(error); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_EXPERTISE, status: FanavaranAuditStatus.FAILURE, requestUrl: url, requestMethod: "POST", httpStatus: exchange.httpStatus, requestBody: payload, responseHeaders: exchange.responseHeaders, responseBody: exchange.responseBody, errorMessage: this.fanavaranAuditService.extractErrorMessage(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, }); throw error; } } async autoSubmitFanavaranExpertiseOnExpertReply( claimCaseId: string, ): Promise { const clientKey = resolveFanavaranClientKey(); const logPrefix = `[Fanavaran ${clientKey} V2 Expertise Auto] claimCaseId=${claimCaseId}`; try { let claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) { return { attempted: false, submitted: false, warning: "Claim case not found for Fanavaran expertise submit.", }; } if (claimCase.expertiseId != null) { await this.notifyClaimOwnerFanavaranClaimRegistered({ claimCaseId, claimId: claimCase.claimId, claimNo: claimCase.claimNo, logPrefix, }); return { attempted: false, submitted: false, skipped: true, skipReason: "Expertise already submitted to Fanavaran", claimId: claimCase.claimId, dmgCaseId: claimCase.dmgCaseId, expertiseId: claimCase.expertiseId, }; } if (!claimCase.claimId) { await this.autoSubmitToFanavaranV2OnClaimCreated(claimCaseId); claimCase = await this.claimCaseDbService.findById(claimCaseId); } const product = await this.resolveFanavaranClaimProduct(claimCaseId); if (fanavaranHasDamageCaseStage(product) && !claimCase?.dmgCaseId) { await this.autoSubmitFanavaranDamageCaseOnOuterPartsSelected( claimCaseId, claimCase?.damage?.selectedParts ?? [], ); claimCase = await this.claimCaseDbService.findById(claimCaseId); } const { payload, warnings } = await this.buildFanavaranExpertisePayload({ claimCase, clientKey, }); this.assertFanavaranExpertisePayloadReady(payload, warnings, { ...hullExpertiseAssertFields(product), }); const fanavaranResponse = await this.executeFanavaranExpertiseSubmit( claimCaseId, clientKey, payload, FanavaranAuditSource.AUTO_SUBMIT, ); const expertiseId = fanavaranResponse?.Id; await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $push: { history: { type: "FANAVARAN_EXPERTISE_AUTO_SUBMIT_SUCCEEDED", actor: { actorType: "system" }, timestamp: new Date(), metadata: { clientKey, claimId: claimCase?.claimId, dmgCaseId: claimCase?.dmgCaseId, expertiseId, }, }, }, }); return { attempted: true, submitted: true, claimId: claimCase?.claimId, dmgCaseId: claimCase?.dmgCaseId, expertiseId, fanavaranResponse, }; } catch (error) { const warning = this.extractFanavaranErrorMessage(error); this.logger.warn(`${logPrefix} Expertise auto-submit failed: ${warning}`); try { await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $set: { "fanavaranSync.expertise.status": "failed", "fanavaranSync.expertise.lastTriedAt": new Date(), "fanavaranSync.expertise.lastError": warning, }, $push: { history: { type: "FANAVARAN_EXPERTISE_AUTO_SUBMIT_FAILED", actor: { actorType: "system" }, timestamp: new Date(), metadata: { clientKey, error: warning }, }, }, }); } catch (historyError) { this.logger.error( `${logPrefix} Failed to record Fanavaran expertise failure history`, historyError, ); } // Schedule retry for expertise await this.scheduleFanavaranRetry( claimCaseId, "expertise", () => this.autoSubmitFanavaranExpertiseOnExpertReply(claimCaseId), logPrefix, { error, clientKey }, ); return { attempted: true, submitted: false, warning, }; } } private withFanavaranAutoSubmitMessage( baseMessage: string, fanavaran: FanavaranAutoSubmitResult, ): string { if (fanavaran.submitted) { return `${baseMessage} The initial Fanavaran case was sent successfully.`; } if (fanavaran.warning) { return `${baseMessage} ${fanavaran.warning}`; } return baseMessage; } private async getFanavaranAutoSubmitSkipReason( claimCase: any, ): Promise { if (claimCase.claimId != null || claimCase.claimNo != null) { return "Already submitted to Fanavaran"; } if (!claimCase.blameRequestId) { return "Blame case not linked to claim case"; } const blameCase = await this.blameRequestDbService.findById( claimCase.blameRequestId, ); if (!blameCase) { return "Blame case not found"; } if (!isFanavaranSubmitSupportedBlameType(blameCase.type)) { return "Fanavaran submit only applies to THIRD_PARTY and CAR_BODY claims"; } return null; } /** * Soft-ensure Fanavaran base claim exists before a later stage (damage / * attachments / expertise). Reuses claimId when present; otherwise submits * GEN.03 once. Does not invent a fake claimId on failure. */ private async ensureFanavaranBaseClaim( claimCaseId: string, clientKey: FanavaranClientKey, auditSource: FanavaranAuditSource, ): Promise<{ claimId: number; claimNo?: string; created: boolean }> { const existing = await this.claimCaseDbService.findById(claimCaseId); if (!existing) { throw new NotFoundException("Claim case not found"); } if (existing.claimId != null) { return { claimId: Number(existing.claimId), claimNo: existing.claimNo != null ? String(existing.claimNo) : undefined, created: false, }; } this.logger.log( `[Fanavaran ${clientKey}] Soft-ensuring base claim before next stage claimCaseId=${claimCaseId}`, ); await this.executeFanavaranV2Submit( claimCaseId, clientKey, undefined, auditSource, ); const refreshed = await this.claimCaseDbService.findById(claimCaseId); if (refreshed?.claimId == null) { throw new BadRequestException( "Fanavaran base claim is missing and could not be created before the next stage. Fix base claim first.", ); } return { claimId: Number(refreshed.claimId), claimNo: refreshed.claimNo != null ? String(refreshed.claimNo) : undefined, created: true, }; } /** * Soft-ensure damage case exists (creates base claim first if needed). */ private async ensureFanavaranDamageCase( claimCaseId: string, clientKey: FanavaranClientKey, auditSource: FanavaranAuditSource, selectedParts?: unknown[], ): Promise<{ claimId: number; dmgCaseId: number | null; created: boolean; }> { await this.ensureFanavaranBaseClaim(claimCaseId, clientKey, auditSource); const existing = await this.claimCaseDbService.findById(claimCaseId); if (existing?.dmgCaseId != null && existing.claimId != null) { return { claimId: Number(existing.claimId), dmgCaseId: Number(existing.dmgCaseId), created: false, }; } const product = await this.resolveFanavaranClaimProduct(claimCaseId); if (!fanavaranHasDamageCaseStage(product)) { if (existing?.claimId == null) { throw new BadRequestException( "Fanavaran claimId is required before the next hull stage.", ); } return { claimId: Number(existing.claimId), dmgCaseId: null, created: false, }; } this.logger.log( `[Fanavaran ${clientKey}] Soft-ensuring damage case before next stage claimCaseId=${claimCaseId}`, ); await this.executeFanavaranDamageCaseSubmit( claimCaseId, clientKey, selectedParts ?? existing?.damage?.selectedParts ?? [], auditSource, ); const refreshed = await this.claimCaseDbService.findById(claimCaseId); if (refreshed?.claimId == null || refreshed.dmgCaseId == null) { throw new BadRequestException( "Fanavaran damage case is missing and could not be created before the next stage.", ); } return { claimId: Number(refreshed.claimId), dmgCaseId: Number(refreshed.dmgCaseId), created: true, }; } private async postFanavaranJson( url: string, payload: Record, clientKey: FanavaranClientKey, auditSession?: FanavaranAuditSession, claimCaseId?: string, ) { this.fanavaranAuthService.assertNotInBackoff(clientKey); const headers = await this.getFanavaranAuthHeaders( clientKey, auditSession, claimCaseId, ); 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( url: string, content: Record, files: Array<{ path: string; fileName: string }>, clientKey: FanavaranClientKey, auditSession?: FanavaranAuditSession, claimCaseId?: string, ) { this.fanavaranAuthService.assertNotInBackoff(clientKey); const headers = await this.getFanavaranAuthHeaders( clientKey, auditSession, claimCaseId, ); const form = new FormData(); form.append("Param", JSON.stringify(content), { contentType: "application/json", }); for (const file of files) { form.append("Param1", createReadStream(resolve(file.path)), { filename: file.fileName, }); } const curlParts = [ `curl -X POST '${url}'`, `-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}'`), ]; this.logger.log(`\n====== COPY THIS CURL ======\n${curlParts.join(" \\\n ")}\n====== END CURL ======\n`); const logPrefix = `[Fanavaran Multipart]`; this.logger.log( `${logPrefix} REQUEST: url=${url} | Param=${JSON.stringify(content)} | files=[${files.map((f) => f.fileName).join(", ")}] | fileCount=${files.length}`, ); 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)}`, ); this.fanavaranAuthService.clearBackoff(clientKey); return response; } catch (error) { this.fanavaranAuthService.registerFailure(clientKey, error); throw error; } } private async executeFanavaranDamageCaseSubmit( claimCaseId: string, clientKey: FanavaranClientKey, selectedParts?: unknown[], auditSource: FanavaranAuditSource = FanavaranAuditSource.AUTO_SUBMIT, bodyOverride?: Record, ): Promise { const profile = getFanavaranClientProfile(clientKey); let claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) { throw new NotFoundException("Claim case not found"); } if (!claimCase.claimId) { await this.ensureFanavaranBaseClaim(claimCaseId, clientKey, auditSource); claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase?.claimId) { throw new BadRequestException( "Fanavaran claimId is required before submitting damage case", ); } } if (claimCase.dmgCaseId != null) { this.logger.log( `[executeFanavaranDamageCaseSubmit] Already have dmgCaseId=${claimCase.dmgCaseId}; skipping Fanavaran POST`, ); return ( (claimCase as any)?.fanavaranSync?.damageCase?.response ?? { Id: claimCase.dmgCaseId, } ); } if (!claimCase.blameRequestId) { throw new BadRequestException("Blame case not linked to claim case"); } const blameCase = await this.blameRequestDbService.findById( claimCase.blameRequestId, ); if (!blameCase) { throw new NotFoundException("Blame case not found"); } if (!isFanavaranSubmitSupportedBlameType(blameCase.type)) { throw new BadRequestException( "Fanavaran damage-case submit only applies to THIRD_PARTY and CAR_BODY claims", ); } const product = fanavaranClaimProductFromBlameType(blameCase.type); const url = fanavaranClaimStageUrl( product, "damage-case", claimCase.claimId, ); if (url == null) { this.logger.log( `[executeFanavaranDamageCaseSubmit] claimCaseId=${claimCaseId} claimId=${claimCase.claimId} skipping hull dmg-cases`, ); await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $set: { "fanavaranSync.damageCase": { status: "skipped", lastTriedAt: new Date(), lastError: FANAVARAN_HULL_NO_DAMAGE_CASE_REASON, claimId: claimCase.claimId, }, }, }); return { skipped: true, skipReason: FANAVARAN_HULL_NO_DAMAGE_CASE_REASON, claimId: claimCase.claimId, }; } const builtPayload = await this.buildFanavaranDamageCasePayload({ claimCase, blameCase, selectedParts: selectedParts ?? claimCase.damage?.selectedParts ?? [], clientKey, defaults: profile.defaults, registerMissingPerson: true, }); const payload = applyFanavaranManualPayloadOverrides( builtPayload, bodyOverride, FANAVARAN_DAMAGE_MANUAL_OVERRIDE_KEYS, ); // Guardrail: never send empty/null licence fields even if manual overrides arrive. payload.LicenceNo = this.ensureFanavaranNonEmptyLicenceNo(payload.LicenceNo); this.logger.log( `[executeFanavaranDamageCaseSubmit] claimCaseId=${claimCaseId} claimId=${claimCase.claimId} payload.DriverId=${payload.DriverId ?? "NULL"} payload keys=${Object.keys(payload).join(", ")}`, ); const startedAt = Date.now(); const auditSession: FanavaranAuditSession = { trackingCode: this.fanavaranAuditService.generateTrackingCode(), clientKey, source: auditSource, claimCaseId, }; await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE, status: FanavaranAuditStatus.STARTED, requestUrl: url, requestMethod: "POST", requestBody: payload, requestMeta: { claimId: claimCase.claimId }, }); try { const response = await this.postFanavaranJson( url, payload, clientKey, auditSession, ); const dmgCaseId = response.data?.Id; const exchange = this.fanavaranAuditService.captureAxiosExchange(response); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE, status: FanavaranAuditStatus.SUCCESS, requestUrl: url, requestMethod: "POST", httpStatus: response.status, requestBody: payload, responseHeaders: exchange.responseHeaders, responseBody: exchange.responseBody, responseMeta: { dmgCaseId, claimId: claimCase.claimId }, durationMs: Date.now() - startedAt, }); if (dmgCaseId !== undefined) { await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $set: { dmgCaseId, "fanavaranSync.damageCase": { status: "success", lastTriedAt: new Date(), claimId: claimCase.claimId, dmgCaseId, response: response.data, }, }, }); } return response.data; } catch (error) { const errDetail = isAxiosError(error) ? `status=${error.response?.status} data=${JSON.stringify(error.response?.data)}` : error instanceof Error ? error.message : String(error); this.logger.error( `[executeFanavaranDamageCaseSubmit] FAILED claimCaseId=${claimCaseId} claimId=${claimCase.claimId} payload.DriverId=${payload.DriverId ?? "NULL"} error: ${errDetail}`, ); const exchange = this.fanavaranAuditService.captureAxiosExchange(error); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE, status: FanavaranAuditStatus.FAILURE, requestUrl: url, requestMethod: "POST", httpStatus: exchange.httpStatus, requestBody: payload, responseHeaders: exchange.responseHeaders, responseBody: exchange.responseBody, errorMessage: this.fanavaranAuditService.extractErrorMessage(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, }); throw error; } } async autoSubmitFanavaranDamageCaseOnOuterPartsSelected( claimCaseId: string, selectedParts: unknown[], ): Promise { const clientKey = resolveFanavaranClientKey(); const logPrefix = `[Fanavaran ${clientKey} V2 DamageCase Auto] claimCaseId=${claimCaseId}`; try { let claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) { return { attempted: false, submitted: false, warning: "Claim case not found for Fanavaran damage-case submit.", }; } if (claimCase.dmgCaseId != null) { return { attempted: false, submitted: false, skipped: true, skipReason: "Damage case already submitted to Fanavaran", claimId: claimCase.claimId, dmgCaseId: claimCase.dmgCaseId, }; } if (!claimCase.claimId) { await this.autoSubmitToFanavaranV2OnClaimCreated(claimCaseId); claimCase = await this.claimCaseDbService.findById(claimCaseId); } if (!claimCase?.claimId) { const warning = "Fanavaran base claim is missing, so damage case was not submitted."; await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $set: { "fanavaranSync.damageCase": { status: "skipped", lastTriedAt: new Date(), lastError: warning, }, }, }); return { attempted: false, submitted: false, skipped: true, skipReason: warning, warning, }; } const fanavaranResponse = await this.executeFanavaranDamageCaseSubmit( claimCaseId, clientKey, selectedParts, ); if (fanavaranResponse?.skipped) { return { attempted: false, submitted: false, skipped: true, skipReason: fanavaranResponse.skipReason ?? FANAVARAN_HULL_NO_DAMAGE_CASE_REASON, claimId: claimCase.claimId, }; } const dmgCaseId = fanavaranResponse?.Id; await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $push: { history: { type: "FANAVARAN_DAMAGE_CASE_AUTO_SUBMIT_SUCCEEDED", actor: { actorType: "system" }, timestamp: new Date(), metadata: { clientKey, claimId: claimCase.claimId, dmgCaseId, }, }, }, }); return { attempted: true, submitted: true, claimId: claimCase.claimId, dmgCaseId, fanavaranResponse, }; } catch (error) { const warning = this.extractFanavaranErrorMessage(error); this.logger.warn( `${logPrefix} Damage-case auto-submit failed: ${warning}`, ); try { await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $set: { "fanavaranSync.damageCase.status": "failed", "fanavaranSync.damageCase.lastTriedAt": new Date(), "fanavaranSync.damageCase.lastError": warning, }, $push: { history: { type: "FANAVARAN_DAMAGE_CASE_AUTO_SUBMIT_FAILED", actor: { actorType: "system" }, timestamp: new Date(), metadata: { clientKey, error: warning }, }, }, }); } catch (historyError) { this.logger.error( `${logPrefix} Failed to record Fanavaran damage-case failure history`, historyError, ); } // Schedule retry for damage case await this.scheduleFanavaranRetry( claimCaseId, "damageCase", () => this.autoSubmitFanavaranDamageCaseOnOuterPartsSelected( claimCaseId, selectedParts, ), logPrefix, { error, clientKey }, ); return { attempted: true, submitted: false, warning, }; } } /** * Auto-submit the minimum Fanavaran claim as soon as the local * claim case exists. This preserves progress in Fanavaran even if parties stop * later in the damage-assessment flow. */ async autoSubmitToFanavaranV2OnClaimCreated( claimCaseId: string, ): Promise { 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) { return { attempted: false, submitted: false, warning: "Claim case not found for Fanavaran early submit. Retry manually when available.", }; } const skipReason = await this.getFanavaranAutoSubmitSkipReason(claimCase); if (skipReason) { return { attempted: false, submitted: false, skipped: true, skipReason, claimId: claimCase.claimId, claimNo: claimCase.claimNo, }; } const fanavaranResponse = await this.executeFanavaranV2Submit( claimCaseId, clientKey, undefined, FanavaranAuditSource.AUTO_SUBMIT, ); await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $push: { history: { type: "FANAVARAN_EARLY_AUTO_SUBMIT_SUCCEEDED", actor: { actorType: "system" }, timestamp: new Date(), metadata: { clientKey, 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} Early auto-submit failed: ${warning}`); 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", actor: { actorType: "system" }, timestamp: new Date(), metadata: { clientKey, error: warning }, }, }, }); } catch (historyError) { this.logger.error( `${logPrefix} Failed to record Fanavaran early auto-submit failure history`, historyError, ); } 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; } /** * Auto-submit to Fanavaran when a claim case reaches COMPLETED. * Uses FANAVARAN_CLIENT env (or CLIENT_ID fallback) to pick the tenant. * Never throws — failures are returned as warnings so the main flow continues. */ async autoSubmitToFanavaranV2OnClaimCompleted( claimCaseId: string, ): Promise { const clientKey = resolveFanavaranClientKey(); const logPrefix = `[Fanavaran ${clientKey} 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.", }; } // V5 flow: FileMaker approval required before fanavaran. // Cross-check the blame record — only V5 blame files carry requiresFileMakerApproval. // V4 claims may have the flag set from an old bug; we ignore it if the blame // itself does not carry the flag. if ((claimCase as any).requiresFileMakerApproval) { const blameId = (claimCase as any).blameRequestId; const blame = blameId ? await this.blameRequestDbService.findById(String(blameId)) : null; const isV5 = !!(blame as any)?.requiresFileMakerApproval; if (isV5) { await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $set: { status: ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL }, $push: { history: { type: "V5_HELD_FOR_FILE_MAKER_APPROVAL", actor: { actorType: "system" }, timestamp: new Date(), metadata: { claimCaseId }, }, }, }); return { attempted: false, submitted: false, skipped: true, skipReason: "V5 flow: FileMaker approval required before Fanavaran submission. " + "Claim is now in WAITING_FOR_FILE_MAKER_APPROVAL.", }; } // V4 claim with stale flag — clear it so this path is not hit again. await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $unset: { requiresFileMakerApproval: "" }, }); } const skipReason = await this.getFanavaranAutoSubmitSkipReason(claimCase); if (skipReason) { return { attempted: false, submitted: false, skipped: true, skipReason, claimId: claimCase.claimId, claimNo: claimCase.claimNo, }; } const fanavaranResponse = await this.executeFanavaranV2Submit( claimCaseId, clientKey, ); await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $push: { history: { type: "FANAVARAN_AUTO_SUBMIT_SUCCEEDED", actor: { actorType: "system" }, timestamp: new Date(), metadata: { clientKey, 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: { clientKey, error: warning }, }, }, }); } catch (historyError) { this.logger.error( `${logPrefix} Failed to record Fanavaran auto-submit failure history`, historyError, ); } // Schedule retry for base claim await this.scheduleFanavaranRetry( claimCaseId, "baseClaim", () => this.autoSubmitToFanavaranV2OnClaimCreated(claimCaseId), logPrefix, { error, clientKey }, ); return { attempted: true, submitted: false, warning: `${warning} Case was not sent to Fanavaran. Retry manually via POST ${fanavaranSubmitPath(clientKey, claimCaseId)}.`, }; } } /** @deprecated Use autoSubmitToFanavaranV2OnClaimCompleted */ async autoSubmitToFanavaranParsianV2OnClaimCompleted( claimCaseId: string, ): Promise { return this.autoSubmitToFanavaranV2OnClaimCompleted(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 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 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; if (retryCount >= maxRetries) { this.logger.warn( `${logPrefix} Max retries (${maxRetries}) exhausted for ${stage}.`, ); 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() + 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()} (delay=${delayMs}ms${transient ? ", transient backoff" : ""})`, ); const timer = setTimeout(async () => { this.fanavaranRetryTimers.delete(key); try { const fresh = await this.claimCaseDbService.findById(claimCaseId); const freshStage = (fresh as any)?.fanavaranSync?.[stage]; if (freshStage?.status === "pending") { await retryFn(); } } catch (retryError) { this.logger.error( `${logPrefix} Retry ${nextRetryCount}/${maxRetries} for ${stage} failed`, retryError, ); } }, 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 expertise (last stage) succeeds, SMS the claim owner with * publicId + claimId + ClaimNo. Uses SmsOrchestrationService → same provider * as login for this deployment (`SMS` / `SMS_PROVIDER`). Never throws. */ private async notifyClaimOwnerFanavaranClaimRegistered(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; const sync = (claimCase as any)?.fanavaranSync; // Dedup: expertise stage, or older base-claim notify from before the move. if (sync?.expertise?.smsNotifiedAt || sync?.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.sendFanavaranClaimRegisteredNotice({ receptor: phone, publicId, claimNo: input.claimNo ?? claimCase.claimNo, claimId: input.claimId ?? claimCase.claimId, }); if (sent) { await this.claimCaseDbService.findByIdAndUpdate(input.claimCaseId, { $set: { "fanavaranSync.expertise.smsNotifiedAt": new Date(), }, }); this.logger.log( `${input.logPrefix} Fanavaran claim SMS sent to claim owner phone=${phone} publicId=${publicId} claimNo=${input.claimNo ?? claimCase.claimNo ?? "-"} claimId=${input.claimId ?? claimCase.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 logPrefix = `[Fanavaran ${clientKey} V2]`; this.logger.log( `${logPrefix} Starting submission for claimCaseId: ${claimCaseId}`, ); const existing = await this.claimCaseDbService.findById(claimCaseId); if (existing?.claimId != null) { this.logger.log( `${logPrefix} Base claim already exists claimId=${existing.claimId}; skipping Fanavaran POST`, ); return ( (existing as any)?.fanavaranSync?.baseClaim?.response ?? { Id: existing.claimId, ClaimNo: existing.claimNo, } ); } const auditSession: FanavaranAuditSession = { trackingCode: this.fanavaranAuditService.generateTrackingCode(), clientKey, source: auditSource, claimCaseId, }; const built = await this.previewFanavaranSubmitV2(claimCaseId, clientKey, { requirePolicyId: true, auditSession, persistPayload: true, forceRefreshPolicy: true, forceRefreshUsedId: true, }); const fanavaranData = applyFanavaranManualPayloadOverrides( built, bodyOverride, FANAVARAN_BASE_MANUAL_OVERRIDE_KEYS, ); // Guardrail: never send empty/null licence fields even if manual overrides arrive. fanavaranData.CulpritLicenceNo = this.ensureFanavaranNonEmptyLicenceNo( fanavaranData.CulpritLicenceNo, ); 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}`, ); } this.fanavaranAuthService.assertNotInBackoff(clientKey); const headers = await this.getFanavaranAuthHeaders( clientKey, auditSession, claimCaseId, ); const submitUrl = await this.fanavaranClaimsUrlForClaim(claimCaseId); const requestHeaders = { ...headers, "Content-Type": "application/json", }; await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_CLAIM, status: FanavaranAuditStatus.STARTED, requestUrl: submitUrl, requestMethod: "POST", requestHeaders, requestBody: fanavaranData, requestMeta: { policyId: fanavaranData?.PolicyId ?? null }, }); const startedAt = Date.now(); try { const response = await firstValueFrom( this.httpService.post(submitUrl, fanavaranData, { headers: requestHeaders, }), ); 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); const exchange = this.fanavaranAuditService.captureAxiosExchange( response, requestHeaders, ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_CLAIM, status: FanavaranAuditStatus.SUCCESS, requestUrl: submitUrl, requestMethod: "POST", httpStatus: response.status, requestHeaders: exchange.requestHeaders, requestBody: fanavaranData, responseHeaders: exchange.responseHeaders, responseBody: exchange.responseBody, responseMeta: { claimId: response.data?.Id, claimNo: response.data?.ClaimNo, }, 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, ); } return response.data; } catch (error) { this.fanavaranAuthService.registerFailure(clientKey, error); const exchange = this.fanavaranAuditService.captureAxiosExchange( error, requestHeaders, ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_CLAIM, status: FanavaranAuditStatus.FAILURE, requestUrl: submitUrl, requestMethod: "POST", httpStatus: exchange.httpStatus, requestHeaders: exchange.requestHeaders, requestBody: fanavaranData, responseHeaders: exchange.responseHeaders, responseBody: exchange.responseBody, errorMessage: this.fanavaranAuditService.extractErrorMessage(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, }); throw error; } } /** @deprecated Use executeFanavaranV2Submit(claimCaseId, "parsian") */ private async executeFanavaranParsianV2Submit( claimCaseId: string, ): Promise { return this.executeFanavaranV2Submit(claimCaseId, "parsian"); } /** * Submit fanavaran data to external API */ async submitToFanavaran(claimRequestId: string): Promise { try { // First, get the mapped data this.logger.log( `[Fanavaran Submit] Starting submission for claimRequestId: ${claimRequestId}`, ); const fanavaranData = await this.fanavaranSubmit(claimRequestId); this.logger.log( `[Fanavaran Submit] Mapped data prepared:`, JSON.stringify(fanavaranData, null, 2), ); // Validate required fields before submission if ( fanavaranData.AccidentCauseId === undefined || fanavaranData.AccidentCauseId === null ) { this.logger.error( `[Fanavaran Submit] CRITICAL: AccidentCauseId is missing or null!`, ); this.logger.error( `[Fanavaran Submit] This field is required by the API. Request will likely fail.`, ); this.logger.error( `[Fanavaran Submit] Available fields in result:`, Object.keys(fanavaranData), ); } else { this.logger.log( `[Fanavaran Submit] AccidentCauseId validated: ${fanavaranData.AccidentCauseId}`, ); } // Step 1: Get appToken this.logger.log(`[Fanavaran Submit] Step 1: Getting appToken`); const appToken = await this.getAppToken(); this.logger.log(`[Fanavaran Submit] AppToken obtained: ${appToken}`); // Step 2: Login to get authenticationToken this.logger.log( `[Fanavaran Submit] Step 2: Logging in to get authenticationToken`, ); const authenticationToken = await this.login(appToken); this.logger.log( `[Fanavaran Submit] AuthenticationToken obtained: ${authenticationToken}`, ); // Step 3: Submit data to Fanavaran API this.logger.log( `[Fanavaran Submit] Step 3: Submitting data to Fanavaran API`, ); this.logger.log(`[Fanavaran Submit] URL: ${this.FANAVARAN_SUBMIT_URL}`); this.logger.log(`[Fanavaran Submit] Request headers:`, { authenticationToken: authenticationToken, CorpId: this.CORP_ID, ContractId: this.CONTRACT_ID, Location: this.LOCATION, "Content-Type": "application/json", }); this.logger.log( `[Fanavaran Submit] Request body:`, JSON.stringify(fanavaranData, null, 2), ); const response = await firstValueFrom( this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, { headers: { authenticationToken: authenticationToken, CorpId: this.CORP_ID, ContractId: this.CONTRACT_ID, Location: this.LOCATION, "Content-Type": "application/json", }, }), ); this.logger.log( `[Fanavaran Submit] API Response Status: ${response.status}`, ); this.logger.log( `[Fanavaran Submit] API Response Headers:`, JSON.stringify(response.headers, null, 2), ); this.logger.log( `[Fanavaran Submit] API Response Data:`, JSON.stringify(response.data, null, 2), ); this.logger.log("Successfully submitted data to Fanavaran API"); // Step 4: Save ClaimNo and Id from response to claim-request-management document if (response.data) { const claimNo = response.data.ClaimNo; const claimId = response.data.Id; this.logger.log( `[Fanavaran Submit] Extracted from response - ClaimNo: ${claimNo}, Id: ${claimId}`, ); if (claimNo !== undefined || claimId !== undefined) { const updateData: any = { $set: {}, }; if (claimNo !== undefined) { updateData.$set.claimNo = claimNo; } if (claimId !== undefined) { updateData.$set.claimId = claimId; } this.logger.log( `[Fanavaran Submit] Updating document with:`, JSON.stringify(updateData, null, 2), ); await this.claimRequestManagementDbService.findAndUpdate( claimRequestId, updateData, ); this.logger.log( `Updated claim-request-management document with claimNo: ${claimNo}, claimId: ${claimId}`, ); } else { this.logger.warn( `[Fanavaran Submit] No ClaimNo or Id found in response data`, ); } } else { this.logger.warn(`[Fanavaran Submit] No response data received`); } return response.data; } catch (error) { this.logger.error("[Fanavaran Submit] Error submitting to Fanavaran"); this.logger.error( "[Fanavaran Submit] Error type:", error?.constructor?.name, ); if (isAxiosError(error)) { this.logger.error("[Fanavaran Submit] Axios Error Details:"); this.logger.error( "[Fanavaran Submit] Error Status:", error.response?.status, ); this.logger.error( "[Fanavaran Submit] Error Status Text:", error.response?.statusText, ); this.logger.error( "[Fanavaran Submit] Error Response Headers:", JSON.stringify(error.response?.headers, null, 2), ); this.logger.error( "[Fanavaran Submit] Error Response Data:", JSON.stringify(error.response?.data, null, 2), ); this.logger.error("[Fanavaran Submit] Request URL:", error.config?.url); this.logger.error( "[Fanavaran Submit] Request Method:", error.config?.method, ); this.logger.error( "[Fanavaran Submit] Request Headers:", JSON.stringify(error.config?.headers, null, 2), ); this.logger.error( "[Fanavaran Submit] Request Data:", JSON.stringify(error.config?.data, null, 2), ); const errorMessage = error.response?.data?.message || error.response?.data?.Message || error.response?.data || error.message || "Failed to submit data to Fanavaran API"; this.logger.error( "[Fanavaran Submit] Extracted Error Message:", errorMessage, ); throw new BadGatewayException(errorMessage); } else { this.logger.error("[Fanavaran Submit] Non-Axios Error:", error); throw new BadGatewayException("Failed to submit data to Fanavaran API"); } } } /** * V2: Create claim request from completed blame case * Only damaged party can create claim after both parties accept expert decision */ async createClaimFromBlameV2( blameRequestId: string, currentUserId: string, ): Promise { try { // 1. Fetch blame request from new blameCases collection const blameRequest = await this.blameRequestDbService.findById(blameRequestId); if (!blameRequest) { throw new NotFoundException("Blame request not found"); } // 2. Validate blame status is COMPLETED // if (blameRequest.status !== BlameCaseStatus.COMPLETED) { // throw new BadRequestException( // `Blame request must be COMPLETED. Current status: ${blameRequest.status}`, // ); // } const parties = blameRequest.parties || []; const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY; if (isCarBody) { // CAR_BODY: single party, no expert; first party is the damaged one who creates the claim if (parties.length < 1) { throw new BadRequestException("Blame request has no party"); } const firstParty = parties.find((p) => p.role === "FIRST"); if ( !firstParty || String(firstParty.person?.userId) !== currentUserId ) { throw new ForbiddenException( "Only the first party (damaged) can create a claim from this CAR_BODY blame request", ); } } else { // THIRD_PARTY: require expert decision and both parties signed if (!blameRequest.expert?.decision) { throw new BadRequestException( "Cannot create claim until expert has made a decision", ); } const guiltyPartyId = String( blameRequest.expert.decision.guiltyPartyId, ); if (parties.length < 2) { throw new BadRequestException( "Both parties must be present in the blame request", ); } const allPartiesSigned = parties.every( (p) => p.confirmation !== undefined && p.confirmation !== null, ); if (!allPartiesSigned) { throw new BadRequestException( "Both parties must sign the expert decision before creating a claim", ); } const allPartiesAccepted = parties.every( (p) => p.confirmation?.accepted === true, ); if (!allPartiesAccepted) { throw new BadRequestException( "Both parties must accept the expert decision. If rejected, in-person resolution is required.", ); } const guiltyParty = parties.find((p) => { return String(p.person?.userId) === guiltyPartyId; }); if ( guiltyParty && String(guiltyParty.person?.userId) === currentUserId ) { throw new ForbiddenException( "You cannot create a claim as you are the guilty party", ); } } // 5. Validate current user is a party (and for THIRD_PARTY not the guilty one) const currentUserParty = parties.find( (p) => String(p.person?.userId) === currentUserId, ); if (!currentUserParty) { throw new ForbiddenException( "You are not a party in this blame request", ); } // 6. Validate no existing claim for this blame const existingClaim = await this.claimCaseDbService.findOne({ blameRequestId: new Types.ObjectId(blameRequestId), }); if (existingClaim) { throw new ConflictException( "A claim request for this blame case already exists", ); } // 7. Generate claim number const claimNo = await this.generateUniqueClaimNumber(); // 8. Create claim case const ownerParty = resolveClaimOwnerParty(blameRequest); if (!ownerParty?.person?.userId) { throw new BadRequestException( "Could not resolve claim owner from blame parties", ); } const damagedUserId = (() => { const oid = String(ownerParty.person.userId); const dp = (blameRequest.parties || []).find( (p: any) => p.person?.userId && String(p.person.userId) !== oid, ); return dp?.person?.userId ? String(dp.person.userId) : null; })(); const newClaim = await this.claimCaseDbService.create({ requestNo: claimNo, publicId: blameRequest.publicId, blameRequestId: new Types.ObjectId(blameRequestId), blameRequestNo: blameRequest.requestNo, status: ClaimCaseStatus.SELECTING_OUTER_PARTS, claimStatus: ClaimStatus.PENDING, inquiries: (blameRequest as any).inquiries ?? {}, workflow: { currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, completedSteps: [ClaimWorkflowStep.CLAIM_CREATED], locked: false, }, ...(damagedUserId ? { damagedPartyUserId: new Types.ObjectId(damagedUserId) } : {}), owner: (() => { const cid = ownerParty.person.clientId; return { userId: new Types.ObjectId(String(ownerParty.person.userId)), userRole: ownerParty.role as any, fullName: currentUserParty.person?.fullName, userClientKey: cid ? String(cid) : undefined, ...(cid ? { clientId: new Types.ObjectId(String(cid)) } : {}), }; })(), history: [ { type: "CLAIM_CREATED", actor: { actorId: new Types.ObjectId(currentUserId), actorName: currentUserParty.person?.fullName || "User", actorType: "user", }, timestamp: new Date(), metadata: { blameRequestId, blamePublicId: blameRequest.publicId, }, }, ], } as any); this.logger.log( `Claim created: ${newClaim._id} from blame: ${blameRequestId}`, ); const fanavaran = await this.autoSubmitToFanavaranV2OnClaimCreated( String(newClaim._id), ); const message = this.withFanavaranAutoSubmitMessage( "Claim request created successfully", fanavaran, ); return { claimRequestId: String(newClaim._id), publicId: blameRequest.publicId, status: ClaimCaseStatus.SELECTING_OUTER_PARTS, message, fanavaran, }; } catch (error) { if (error instanceof HttpException) throw error; this.logger.error( "createClaimFromBlameV2 failed", blameRequestId, error instanceof Error ? error.stack : String(error), ); throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to create claim request", ); } } /** * V2: Field expert creates a claim from an expert-initiated IN_PERSON completed blame. * Owner: damaged party `userId` (SMS / user actions), guilty party `clientId` (insurer queue). */ async createClaimFromBlameForExpertV2( blameRequestId: string, expert: { sub: string; firstName?: string; lastName?: string }, ): Promise { const blameRequest = await this.blameRequestDbService.findById(blameRequestId); if (!blameRequest) { throw new NotFoundException("Blame request not found"); } // if (blameRequest.status !== BlameCaseStatus.COMPLETED) { // throw new BadRequestException( // `Blame request must be COMPLETED. Current status: ${blameRequest.status}`, // ); // } if ( !blameRequest.expertInitiated || blameRequest.creationMethod !== "IN_PERSON" || !blameRequest.initiatedByFieldExpertId ) { throw new BadRequestException( "This endpoint is only for expert-initiated IN_PERSON blame files.", ); } if (String(blameRequest.initiatedByFieldExpertId) !== String(expert.sub)) { throw new ForbiddenException( "Only the field expert who created this blame file can create the claim.", ); } const ownerFields = resolveClaimOwnerFieldsFromBlame(blameRequest); if (!ownerFields) { throw new BadRequestException( "Could not resolve claim owner (damaged party) from blame", ); } const existingClaim = await this.claimCaseDbService.findOne({ blameRequestId: new Types.ObjectId(blameRequestId), }); if (existingClaim) { throw new ConflictException("A claim for this blame case already exists"); } const claimNo = await this.generateUniqueClaimNumber(); const newClaim = await this.claimCaseDbService.create({ requestNo: claimNo, publicId: blameRequest.publicId, blameRequestId: new Types.ObjectId(blameRequestId), blameRequestNo: blameRequest.requestNo, initiatedByFieldExpertId: new Types.ObjectId(expert.sub), status: ClaimCaseStatus.SELECTING_OUTER_PARTS, claimStatus: ClaimStatus.PENDING, inquiries: (blameRequest as any).inquiries ?? {}, workflow: { currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, completedSteps: [ClaimWorkflowStep.CLAIM_CREATED], locked: false, }, damagedPartyUserId: new Types.ObjectId(ownerFields.userId), owner: { userId: new Types.ObjectId(ownerFields.userId), userRole: ownerFields.userRole as any, ...(ownerFields.clientId ? { clientId: new Types.ObjectId(ownerFields.clientId), userClientKey: ownerFields.clientId, } : {}), }, history: [ { type: "CLAIM_CREATED", actor: { actorId: new Types.ObjectId(expert.sub), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "field_expert", }, timestamp: new Date(), metadata: { blameRequestId, blamePublicId: blameRequest.publicId, createdByExpert: true, }, }, ], } as any); this.logger.log( `Claim created by field expert: ${newClaim._id} from blame: ${blameRequestId}`, ); const fanavaran = await this.autoSubmitToFanavaranV2OnClaimCreated( String(newClaim._id), ); const message = this.withFanavaranAutoSubmitMessage( "Claim created successfully. You can now fill claim data on behalf of the damaged party.", fanavaran, ); return { claimRequestId: String(newClaim._id), publicId: blameRequest.publicId, status: ClaimCaseStatus.SELECTING_OUTER_PARTS, message, fanavaran, }; } /** * True when the actor appears as a non-owner (damaged) party on the claim's * snapshot.parties. This is the fastest, blame-free fallback: the snapshot is * written at claim-creation time and always holds the correct user IDs. * * Logic: * - Any party whose person.userId is in linkedUserIds and is NOT the claim * owner (guilty party) is considered the damaged party. * - If owner.userId is unknown we accept any matching party (CAR_BODY flows). */ private claimSnapshotPartyMatchesAsDamagedParty( claim: any, linkedUserIdStrings: string[], ): boolean { const parties: any[] = claim?.snapshot?.parties ?? []; if (!parties.length) return false; const ownerIdStr = claim?.owner?.userId ? String(claim.owner.userId) : null; for (const party of parties) { const partyUserId = party?.person?.userId; if (partyUserId == null) continue; const partyStr = String(partyUserId); // Must be this actor if (!linkedUserIdStrings.some((id) => id === partyStr)) continue; // Must NOT be the guilty-owner (unless owner is unknown — CAR_BODY) if (ownerIdStr && partyStr === ownerIdStr) continue; return true; } return false; } private async assertEffectiveUserIsDamagedPartyOnClaim( claimCase: any, effectiveUserId: string, actor?: { username?: string }, message = "Only the damaged party can perform this action on the claim", ): Promise { const linkedUserIds = await resolveLinkedUserIdStrings(this.userDbService, { sub: effectiveUserId, username: actor?.username, }); const actorCtx = { sub: effectiveUserId, username: actor?.username }; // 0. damagedPartyUserId stored directly on the claim (fastest, most reliable) if (ownerUserIdMatchesActor(claimCase?.damagedPartyUserId, linkedUserIds)) { return; } // 1. Owner (= guilty party for THIRD_PARTY) — kept for backward compat // on CAR_BODY where owner IS the damaged party. if (ownerUserIdMatchesActor(claimCase?.owner?.userId, linkedUserIds)) { return; } // 2. Blame-based resolution (primary path for THIRD_PARTY) if (claimCase?.blameRequestId) { const blame = await this.blameRequestDbService.findById( claimCase.blameRequestId.toString(), ); if ( blame && blameDamagedPartyMatchesUser(blame, actorCtx, linkedUserIds) ) { return; } } // 3. Snapshot-party fallback: claim.snapshot.parties is written at creation // and is reliable even when blame lookup fails or userId mapping differs. if ( this.claimSnapshotPartyMatchesAsDamagedParty(claimCase, linkedUserIds) ) { return; } throw new ForbiddenException(message); } private async assertActorCanViewClaimV2( claim: any, currentUserId: string, actor?: { sub: string; role?: string; username?: string }, ): Promise { const linkedUserIds = await resolveLinkedUserIdStrings(this.userDbService, { sub: currentUserId, username: actor?.username, }); if (ownerUserIdMatchesActor(claim.owner?.userId, linkedUserIds)) { return; } // damagedPartyUserId is set at claim-creation time — the most direct check. if (ownerUserIdMatchesActor(claim.damagedPartyUserId, linkedUserIds)) { return; } if (!actor?.role || actor.role === RoleEnum.USER) { const userCtx = { sub: currentUserId, username: actor?.username }; // Any party on the linked blame can VIEW the claim. // (Mutation guards use assertEffectiveUserIsDamagedPartyOnClaim which is // stricter and only allows the damaged party to act.) if (claim.blameRequestId) { const blame = await this.blameRequestDbService.findById( claim.blameRequestId.toString(), ); if (blame) { const parties: any[] = (blame as any).parties ?? []; const isParty = parties.some((p: any) => partyPersonMatchesUser(p?.person, userCtx, linkedUserIds), ); if (isParty) return; } } // Snapshot-party fallback (reliable for claims with snapshot written). if (this.claimSnapshotPartyMatchesAsDamagedParty(claim, linkedUserIds)) { return; } } if (actor?.role === RoleEnum.FIELD_EXPERT) { const blame = claim.blameRequestId ? await this.blameRequestDbService.findById( claim.blameRequestId.toString(), ) : null; if ( claimCaseInitiatedByFieldExpert(claim, { sub: currentUserId }, blame) ) { return; } } if ( (actor?.role === RoleEnum.FILE_MAKER || actor?.role === RoleEnum.FILE_REVIEWER) && claim.blameRequestId ) { const blame = await this.blameRequestDbService.findById( claim.blameRequestId.toString(), ); const isV4OrV5File = !!(blame as any)?.isMadeByFileMaker && blame?.expertInitiated && blame?.creationMethod === "IN_PERSON"; if ( actor.role === RoleEnum.FILE_MAKER && isV4OrV5File && claimCaseInitiatedByFieldExpert(claim, { sub: currentUserId }, blame) ) { return; } if ( actor.role === RoleEnum.FILE_REVIEWER && isV4OrV5File && String((blame as any)?.assignedFileReviewerId ?? "") === String(currentUserId) ) { return; } } if (actor?.role === RoleEnum.REGISTRAR && claim.blameRequestId) { const blame = await this.blameRequestDbService.findById( claim.blameRequestId.toString(), ); if ( blame?.registrarInitiated && blame.initiatedByRegistrarId && String(blame.initiatedByRegistrarId) === currentUserId ) { return; } } throw new ForbiddenException("You do not have access to this claim"); } /** * Resolve effective user id for claim operations. * For FIELD_EXPERT acting on expert-initiated IN_PERSON claim, returns the claim owner's id; otherwise returns currentUserId. */ private async resolveClaimEffectiveUserId( claimCase: any, currentUserId: string, actorRole?: string, ): Promise { const isFieldLikeRole = [ RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR, RoleEnum.FILE_MAKER, RoleEnum.FILE_REVIEWER, ].includes(actorRole as any); if (!isFieldLikeRole || !claimCase?.blameRequestId) { return currentUserId; } const blameRequest = await this.blameRequestDbService.findById( claimCase.blameRequestId.toString(), ); if (!blameRequest || blameRequest.creationMethod !== "IN_PERSON") return currentUserId; if (actorRole === RoleEnum.FIELD_EXPERT || actorRole === RoleEnum.FILE_MAKER) { if ( !blameRequest.expertInitiated || !blameRequest.initiatedByFieldExpertId || String(blameRequest.initiatedByFieldExpertId) !== currentUserId ) { return currentUserId; } } else if (actorRole === RoleEnum.REGISTRAR) { if ( !blameRequest.registrarInitiated || !blameRequest.initiatedByRegistrarId || String(blameRequest.initiatedByRegistrarId) !== currentUserId ) { return currentUserId; } } // FILE_REVIEWER: no initiator-id check — they pick up any IN_PERSON expert // file after the FileMaker seals it, so we resolve the owner unconditionally. return ( resolveDamagedPartyUserId(blameRequest) ?? (claimCase.owner?.userId ? String(claimCase.owner.userId) : currentUserId) ); } async createClaimFromBlameForRegistrarV1( blameRequestId: string, registrar: { sub: string; firstName?: string; lastName?: string }, ): Promise { const blameRequest = await this.blameRequestDbService.findById(blameRequestId); if (!blameRequest) throw new NotFoundException("Blame request not found"); if (blameRequest.status !== BlameCaseStatus.COMPLETED) { throw new BadRequestException( `Blame request must be COMPLETED. Current status: ${blameRequest.status}`, ); } if ( !blameRequest.registrarInitiated || blameRequest.creationMethod !== "IN_PERSON" || !blameRequest.initiatedByRegistrarId ) { throw new BadRequestException( "This endpoint is only for registrar-initiated IN_PERSON blame files.", ); } if (String(blameRequest.initiatedByRegistrarId) !== String(registrar.sub)) { throw new ForbiddenException( "Only the registrar who created this blame file can create the claim.", ); } const ownerFields = resolveClaimOwnerFieldsFromBlame(blameRequest); if (!ownerFields) { throw new BadRequestException( "Could not resolve claim owner (damaged party) from blame", ); } const existingClaim = await this.claimCaseDbService.findOne({ blameRequestId: new Types.ObjectId(blameRequestId), }); if (existingClaim) throw new ConflictException("A claim for this blame case already exists"); const claimNo = await this.generateUniqueClaimNumber(); const newClaim = await this.claimCaseDbService.create({ requestNo: claimNo, publicId: blameRequest.publicId, blameRequestId: new Types.ObjectId(blameRequestId), blameRequestNo: blameRequest.requestNo, status: ClaimCaseStatus.SELECTING_OUTER_PARTS, claimStatus: ClaimStatus.PENDING, inquiries: (blameRequest as any).inquiries ?? {}, workflow: { currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, completedSteps: [ClaimWorkflowStep.CLAIM_CREATED], locked: false, }, damagedPartyUserId: new Types.ObjectId(ownerFields.userId), owner: { userId: new Types.ObjectId(ownerFields.userId), userRole: ownerFields.userRole as any, ...(ownerFields.clientId ? { clientId: new Types.ObjectId(ownerFields.clientId), userClientKey: ownerFields.clientId, } : {}), }, createdByRegistrarId: new Types.ObjectId(registrar.sub), history: [ { type: "CLAIM_CREATED", actor: { actorId: new Types.ObjectId(registrar.sub), actorName: `${registrar.firstName || ""} ${registrar.lastName || ""}`.trim(), actorType: "registrar", }, timestamp: new Date(), metadata: { blameRequestId, blamePublicId: blameRequest.publicId, createdByRegistrar: true, }, }, ], } as any); const fanavaran = await this.autoSubmitToFanavaranV2OnClaimCreated( String(newClaim._id), ); const message = this.withFanavaranAutoSubmitMessage( "Claim created successfully. Registrar can now fill claim data.", fanavaran, ); return { claimRequestId: String(newClaim._id), publicId: blameRequest.publicId, status: ClaimCaseStatus.SELECTING_OUTER_PARTS, message, fanavaran, }; } /** * V2: Registrar creates a claim from a registrar-initiated IN_PERSON completed blame. * Mirrors createClaimFromBlameForRegistrarV1 but uses V2 naming conventions. */ async createClaimFromBlameForRegistrarV2( blameRequestId: string, registrar: { sub: string; firstName?: string; lastName?: string }, ): Promise { const blameRequest = await this.blameRequestDbService.findById(blameRequestId); if (!blameRequest) throw new NotFoundException("Blame request not found"); if (blameRequest.status !== BlameCaseStatus.COMPLETED) { throw new BadRequestException( `Blame request must be COMPLETED. Current status: ${blameRequest.status}`, ); } if ( !blameRequest.registrarInitiated || blameRequest.creationMethod !== "IN_PERSON" || !blameRequest.initiatedByRegistrarId ) { throw new BadRequestException( "This endpoint is only for registrar-initiated IN_PERSON blame files.", ); } if (String(blameRequest.initiatedByRegistrarId) !== String(registrar.sub)) { throw new ForbiddenException( "Only the registrar who created this blame file can create the claim.", ); } const ownerFields = resolveClaimOwnerFieldsFromBlame(blameRequest); if (!ownerFields) { throw new BadRequestException( "Could not resolve claim owner (damaged party) from blame", ); } const existingClaim = await this.claimCaseDbService.findOne({ blameRequestId: new Types.ObjectId(blameRequestId), }); if (existingClaim) throw new ConflictException("A claim for this blame case already exists"); const claimNo = await this.generateUniqueClaimNumber(); const newClaim = await this.claimCaseDbService.create({ requestNo: claimNo, publicId: blameRequest.publicId, blameRequestId: new Types.ObjectId(blameRequestId), blameRequestNo: blameRequest.requestNo, status: ClaimCaseStatus.SELECTING_OUTER_PARTS, claimStatus: ClaimStatus.PENDING, inquiries: (blameRequest as any).inquiries ?? {}, workflow: { currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, completedSteps: [ClaimWorkflowStep.CLAIM_CREATED], locked: false, }, damagedPartyUserId: new Types.ObjectId(ownerFields.userId), owner: { userId: new Types.ObjectId(ownerFields.userId), userRole: ownerFields.userRole as any, ...(ownerFields.clientId ? { clientId: new Types.ObjectId(ownerFields.clientId), userClientKey: ownerFields.clientId, } : {}), }, createdByRegistrarId: new Types.ObjectId(registrar.sub), history: [ { type: "CLAIM_CREATED", actor: { actorId: new Types.ObjectId(registrar.sub), actorName: `${registrar.firstName || ""} ${registrar.lastName || ""}`.trim(), actorType: "registrar", }, timestamp: new Date(), metadata: { blameRequestId, blamePublicId: blameRequest.publicId, createdByRegistrar: true, }, }, ], } as any); const fanavaran = await this.autoSubmitToFanavaranV2OnClaimCreated( String(newClaim._id), ); const message = this.withFanavaranAutoSubmitMessage( "Claim created successfully. Registrar can now fill claim data on behalf of the damaged party.", fanavaran, ); return { claimRequestId: String(newClaim._id), publicId: blameRequest.publicId, status: ClaimCaseStatus.SELECTING_OUTER_PARTS, message, fanavaran, }; } /** * V2 API: Select damaged outer car parts for claim (Step 2 of claim workflow) * * @param claimRequestId - The claim case ID * @param body - Selected outer parts * @param currentUserId - The current user's ID * @returns Response with updated claim information * * Validations: * - Claim case must exist * - User must be the claim owner (damaged party) * - Current workflow step must be CLAIM_CREATED (step 1) * - Selected parts array must not be empty * - Parts must not have been selected previously */ async selectOuterPartsV2( claimRequestId: string, body: SelectOuterPartsV2Dto, currentUserId: string, actor?: { sub: string; role?: string }, ): Promise { try { // 1. Validate claim exists const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException( `Claim case with ID ${claimRequestId} not found`, ); } const effectiveUserId = await this.resolveClaimEffectiveUserId( claimCase, currentUserId, actor?.role, ); await this.assertEffectiveUserIsDamagedPartyOnClaim( claimCase, effectiveUserId, actor as { username?: string }, "Only the claim owner (damaged party) can select damaged parts", ); // 3. Validate workflow step is correct (should be at SELECT_OUTER_PARTS) if ( claimCase.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS ) { throw new BadRequestException( `Invalid workflow step. Expected ${ClaimWorkflowStep.SELECT_OUTER_PARTS}, but current step is ${claimCase.workflow?.currentStep}`, ); } // 4. Validate outer parts haven't been selected yet. // For FILE_REVIEWER re-reviewing after a FileMaker rejection, the existing // selection from the prior cycle is intentionally overwritten — skip this // guard so the reviewer can update the damaged parts list. const isFileReviewerReselect = actor?.role === RoleEnum.FILE_REVIEWER; if ( !isFileReviewerReselect && claimCase.damage?.selectedParts && claimCase.damage.selectedParts.length > 0 ) { throw new ConflictException( "Outer parts have already been selected for this claim", ); } // 5. Fetch the live Fanavaran catalog and resolve selected parts by ids const selectedCarType = (body as any)?.carType as | ClaimVehicleTypeV2 | undefined; const liveCatalog = await this.getLiveFanavaranCatalogItems(); const byId = new Map( liveCatalog.map((p) => [p.id, p]), ); const selectedFromIds: OuterPartCatalogItem[] = []; if ( Array.isArray((body as any)?.selectedPartIds) && (body as any).selectedPartIds.length > 0 ) { for (const id of (body as any).selectedPartIds as number[]) { const item = byId.get(id); if (!item) { throw new BadRequestException( `Invalid outer part id: ${id}. Use GET outer-parts-catalog to see valid IDs.`, ); } selectedFromIds.push(item); } } if (selectedFromIds.length === 0) { throw new BadRequestException( "selectedPartIds is required and must contain at least one valid Fanavaran part ID", ); } const selectedItems = selectedFromIds; // DISABLED: At most two non-top sides allowed // const sideSet = new Set( // selectedItems // .map((p) => p.side) // .filter((s) => s !== "top"), // ); // if (sideSet.size > 2) { // throw new BadRequestException( // `At most two of left/right/front/back can be selected. Selected: ${[...sideSet].join(", ")}`, // ); // } const selectedPartDocs = selectedItems.map((p) => catalogItemToSelectedPart(p, liveCatalog), ); const damagedPartsInitial = selectedPartDocs.map((p) => ({ id: p.id ?? undefined, name: p.name, side: p.side, label_fa: p.label_fa, ...(p.catalogKey ? { catalogKey: p.catalogKey } : {}), })); const selectedPartIds = selectedItems.map((p) => p.id); // 6. Update claim case with selected parts and move to next step const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate( claimRequestId, { "damage.selectedParts": selectedPartDocs, "media.damagedParts": damagedPartsInitial, "vehicle.carType": selectedCarType, status: ClaimCaseStatus.SELECTING_OTHER_PARTS, "workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS, "workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES, $unset: { "damage.selectedOuterParts": "" }, $push: { "workflow.completedSteps": ClaimWorkflowStep.SELECT_OUTER_PARTS, history: { type: "STEP_COMPLETED", actor: { actorId: new Types.ObjectId(currentUserId), actorName: claimCase.owner?.fullName || "User", actorType: "user", }, timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS, selectedParts: selectedPartDocs, selectedPartIds, carType: selectedCarType, partsCount: selectedPartDocs?.length, description: `User selected ${selectedPartDocs?.length} damaged outer parts`, }, }, }, }, ); if (!updatedClaim) { throw new InternalServerErrorException("Failed to update claim case"); } this.logger.log( `Outer parts selected for claim ${claimRequestId}: ${selectedPartDocs?.length} parts`, ); const fanavaranDamageCase = await this.autoSubmitFanavaranDamageCaseOnOuterPartsSelected( claimRequestId, selectedPartDocs, ); let message = "Outer parts selected successfully. Please proceed to select other parts and provide bank information."; if (fanavaranDamageCase.submitted) { message += " The damage case was sent to Fanavaran successfully."; } else if (fanavaranDamageCase.warning) { message += ` ${fanavaranDamageCase.warning}`; } // 7. Return response return { claimRequestId: updatedClaim._id.toString(), publicId: updatedClaim.publicId, selectedParts: selectedPartDocs, selectedPartIds, currentStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, nextStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, message, fanavaranDamageCase, }; } catch (error) { if (error instanceof HttpException) throw error; this.logger.error( "selectOuterPartsV2 failed", claimRequestId, error instanceof Error ? error.stack : String(error), ); throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to select outer parts", ); } } /** * V2 API: Select other damaged parts and provide bank information (Step 3 of claim workflow) * * @param claimRequestId - The claim case ID * @param body - Other parts, Sheba number, and national code * @param currentUserId - The current user's ID * @returns Response with updated claim information * * Validations: * - Claim case must exist * - User must be the claim owner (damaged party) * - Current workflow step must be SELECT_OTHER_PARTS (step 3) * - Other parts and bank info must not have been submitted previously */ async selectOtherPartsV2( claimRequestId: string, body: SelectOtherPartsV2Dto, currentUserId: string, actor?: { sub: string; role?: string }, file?: Express.Multer.File, ): Promise { try { // 1. Validate claim exists const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException( `Claim case with ID ${claimRequestId} not found`, ); } const effectiveUserId = await this.resolveClaimEffectiveUserId( claimCase, currentUserId, actor?.role, ); // 2. Validate user is the damaged party (or field expert for IN_PERSON claim) await this.assertEffectiveUserIsDamagedPartyOnClaim( claimCase, effectiveUserId, actor as { username?: string }, "Only the claim owner (damaged party) can submit other parts and bank information", ); // 3. Validate workflow step is correct (should be at SELECT_OTHER_PARTS) if ( claimCase.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OTHER_PARTS && claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND ) { throw new BadRequestException( `Invalid workflow step. Expected ${ClaimWorkflowStep.SELECT_OTHER_PARTS}, but current step is ${claimCase.workflow?.currentStep}`, ); } // 4. Validate other parts and bank info haven't been submitted yet if ( claimCase.money?.sheba && claimCase.money?.nationalCodeOfInsurer && claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND ) { throw new ConflictException( "Bank information has already been submitted for this claim", ); } // 5. Prepare data // Backward compatibility aliases: // - shebaNumber <- sheba // - nationalCodeOfOwner <- nationalCodeOfInsurer let otherParts = Array.isArray((body as any)?.otherParts) ? (body as any).otherParts : []; // Multipart clients may send otherParts as JSON string if ( !Array.isArray((body as any)?.otherParts) && typeof (body as any)?.otherParts === "string" ) { try { const parsed = JSON.parse((body as any).otherParts); otherParts = Array.isArray(parsed) ? parsed : []; } catch { otherParts = []; } } const shebaInput = String( ((body as any).shebaNumber ?? (body as any).sheba ?? "") as string, ).replace(/\s/g, ""); const shebaDigits = shebaInput.replace(/^IR/i, ""); const shebaNumber = `IR${shebaDigits}`; const submittedNationalCode = String( ((body as any).nationalCodeOfOwner ?? (body as any).nationalCodeOfInsurer ?? "") as string, ).replace(/\s/g, ""); if (!/^[0-9]{24}$/.test(shebaDigits)) { throw new BadRequestException( "shebaNumber is required and must be valid (IR + 24 digits or only 24 digits)", ); } const blameRequest = claimCase.blameRequestId ? await this.blameRequestDbService.findById( claimCase.blameRequestId.toString(), ) : null; const storedOwnerNationalCode = this.resolveVehicleOwnerNationalCodeForClaim(blameRequest); if ( storedOwnerNationalCode && submittedNationalCode && submittedNationalCode !== storedOwnerNationalCode ) { throw new BadRequestException( "Submitted national code does not match the vehicle owner saved on the blame case.", ); } const nationalCode = storedOwnerNationalCode ?? submittedNationalCode; if (!/^[0-9]{10}$/.test(nationalCode)) { throw new BadRequestException( "Vehicle owner national code is unavailable; a 10-digit nationalCodeOfOwner is required only for legacy claims.", ); } const ownershipPlate = this.resolveOwnershipPlateForClaim( claimCase, blameRequest, ); if (!ownershipPlate) { throw new BadRequestException( "Could not resolve vehicle plate for ownership validation.", ); } let step = claimCase.workflow?.currentStep; // External inquiry checks before moving workflow: // 1) ownership check for nationalCode + plate // await this.sandHubService.getCarOwnershipInfo(ownershipPlate, nationalCode); // 2) sheba check for nationalCode + sheba await this.sandHubService.getShebaValidation(nationalCode, shebaNumber); const updatePayload: any = { "damage.otherParts": otherParts.length > 0 ? otherParts : undefined, "money.sheba": shebaNumber, "money.nationalCodeOfInsurer": nationalCode, status: ClaimCaseStatus.CAPTURING_PART_DAMAGES, "workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES, "workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, $push: { "workflow.completedSteps": ClaimWorkflowStep.SELECT_OTHER_PARTS, history: { type: "STEP_COMPLETED", actor: { actorId: new Types.ObjectId(currentUserId), actorName: claimCase.owner?.fullName || "User", actorType: "user", }, timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS, otherParts, otherPartsCount: otherParts.length, hasBankInfo: true, hasGreenCardUpload: !!file, description: `User selected ${otherParts.length} other damaged parts and provided bank information`, }, }, }, }; if (file) { const existingGreen = (claimCase.requiredDocuments as any)?.get?.( ClaimRequiredDocumentType.CAR_GREEN_CARD, ) || (claimCase.requiredDocuments as any)?.[ ClaimRequiredDocumentType.CAR_GREEN_CARD ]; if (existingGreen?.uploaded) { throw new ConflictException( "car_green_card has already been uploaded for this claim", ); } const docRef = await this.claimRequiredDocumentDbService.create({ path: file.path, fileName: file.filename, claimId: new Types.ObjectId(claimRequestId), documentType: ClaimRequiredDocumentType.CAR_GREEN_CARD, uploadedAt: new Date(), }); updatePayload[ `requiredDocuments.${ClaimRequiredDocumentType.CAR_GREEN_CARD}` ] = { fileId: docRef._id, filePath: file.path, fileName: file.filename, uploaded: true, uploadedAt: new Date(), }; } // 6. Update claim case with other parts, bank info and optional green card file const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate( claimRequestId, updatePayload, ); if (!updatedClaim) { throw new InternalServerErrorException("Failed to update claim case"); } this.logger.log( `Other parts and bank info submitted for claim ${claimRequestId}: ${otherParts.length} parts`, ); // 7. Mask sensitive data for response const maskedSheba = `IR${shebaDigits.slice(0, 4)}************${shebaDigits.slice(-4)}`; const maskedNationalCode = `${nationalCode.slice(0, 2)}******${nationalCode.slice(-2)}`; // 8. Return response return { claimRequestId: updatedClaim._id.toString(), publicId: updatedClaim.publicId, otherParts: otherParts, shebaNumber: maskedSheba, nationalCodeOfOwner: maskedNationalCode, currentStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, nextStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, message: "Other parts and bank information saved successfully. Please capture car angles and damaged parts, then upload required documents.", }; } catch (error) { if (error instanceof HttpException) throw error; this.logger.error( "selectOtherPartsV2 failed", claimRequestId, error instanceof Error ? error.stack : String(error), ); throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to select other parts", ); } } /** * V2 API: Get capture requirements for claim (documents, angles, parts) */ async getCaptureRequirementsV2( claimRequestId: string, currentUserId: string, actor?: { sub: string; role?: string }, ): Promise { try { const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException( `Claim case with ID ${claimRequestId} not found`, ); } const effectiveUserId = await this.resolveClaimEffectiveUserId( claimCase, currentUserId, actor?.role, ); await this.assertEffectiveUserIsDamagedPartyOnClaim( claimCase, effectiveUserId, actor as { username?: string }, "Only the claim owner can view capture requirements", ); // Build outer-parts lookup for resolving stored DB parts. // Uses the static snapshot (FANAVARAN_CAR_PARTS_CATALOG) which is keyed // by numeric id (key === String(id)). This is only needed for legacy // migration of parts stored before the Fanavaran catalog change. const catalogByKey = new Map(); for (const item of FANAVARAN_CAR_PARTS_CATALOG) { if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item); } // Required documents: CAR_BODY needs 7 (damaged + green card); THIRD_PARTY needs 13 (add guilty_*) const blameRequest = claimCase.blameRequestId ? await this.blameRequestDbService.findById( claimCase.blameRequestId.toString(), ) : null; const isCarBody = blameRequest?.type === BlameRequestType.CAR_BODY; const requiredDocsDefinition = [ { key: "damaged_driving_license_front", label_fa: "گواهینامه طرف آسیب‌دیده - جلو", label_en: "Damaged Party License - Front", category: "damaged_party", }, { key: "damaged_driving_license_back", label_fa: "گواهینامه طرف آسیب‌دیده - پشت", label_en: "Damaged Party License - Back", category: "damaged_party", }, { key: "damaged_chassis_number", label_fa: "شماره شاسی", label_en: "Chassis Number", category: "damaged_party", }, { key: "damaged_engine_photo", label_fa: "عکس موتور", label_en: "Engine Photo", category: "damaged_party", }, { key: "damaged_car_card_front", label_fa: "کارت خودرو آسیب‌دیده - جلو", label_en: "Damaged Car Card - Front", category: "damaged_party", }, { key: "damaged_car_card_back", label_fa: "کارت خودرو آسیب‌دیده - پشت", label_en: "Damaged Car Card - Back", category: "damaged_party", }, { key: "damaged_metal_plate", label_fa: "پلاک فلزی آسیب‌دیده", label_en: "Damaged Metal Plate", category: "damaged_party", }, { key: "car_green_card", label_fa: "کارت سبز خودرو", label_en: "Car Green Card", category: "damaged_party", }, ...(isCarBody ? [] : [ { key: "guilty_driving_license_front", label_fa: "گواهینامه طرف مقصر - جلو", label_en: "Guilty Party License - Front", category: "guilty_party", }, { key: "guilty_driving_license_back", label_fa: "گواهینامه طرف مقصر - پشت", label_en: "Guilty Party License - Back", category: "guilty_party", }, { key: "guilty_car_card_front", label_fa: "کارت خودرو مقصر - جلو", label_en: "Guilty Car Card - Front", category: "guilty_party", }, { key: "guilty_car_card_back", label_fa: "کارت خودرو مقصر - پشت", label_en: "Guilty Car Card - Back", category: "guilty_party", }, { key: "guilty_metal_plate", label_fa: "پلاک فلزی مقصر", label_en: "Guilty Metal Plate", category: "guilty_party", }, ]), ]; const requiredDocuments = requiredDocsDefinition.map((doc) => { const docData = (claimCase.requiredDocuments as any)?.get?.(doc.key); return { key: doc.key, label_fa: doc.label_fa, label_en: doc.label_en, category: doc.category, uploaded: docData?.uploaded || false, preferUploadDuringCapture: isCapturePhaseDamagedPartyDocKey(doc.key), }; }); const hasCapture = (data: any, key: string) => data && (data instanceof Map ? data.get(key) : data[key]); // Car angles const carAngles = [ { key: "front", label_fa: "جلوی ماشین", label_en: "Front of the car" }, { key: "back", label_fa: "عقب ماشین", label_en: "Back of the car" }, { key: "left", label_fa: "چپ ماشین", label_en: "Left of the car" }, { key: "right", label_fa: "راست ماشین", label_en: "Right of the car" }, ].map((angle) => ({ key: angle.key, label_fa: angle.label_fa, label_en: angle.label_en, captured: hasClaimCarAngleCapture( claimCase.media?.carAngles as any, claimCase.media?.damagedParts as any, angle.key as ClaimCarAngleKey, ), })); const carType = claimCase.vehicle?.carType as | ClaimVehicleTypeV2 | undefined; const selectedNorm = normalizeDamageSelectedParts( claimCase.damage?.selectedParts, carType, (claimCase.damage as any)?.selectedOuterParts, ); const damagedParts = selectedNorm.map((sp) => { const catalogMeta = sp.catalogKey ? catalogByKey.get(sp.catalogKey) : sp.name ? catalogByKey.get(catalogLikeKeyFromPart(sp)) : undefined; const labelEnName = sp.name .replace(/([a-z])([A-Z])/g, "$1 $2") .replace(/_/g, " ") .trim() .replace(/\b\w/g, (c) => c.toUpperCase()); const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp); return { id: sp.id ?? catalogMeta?.id, key: sp.name, name: sp.name, side: sp.side, label_fa: sp.label_fa, label_en: labelEnName, captured: hasDamagedPartCapture( claimCase.media?.damagedParts as any, ck, selectedNorm, ), }; }); // Calculate progress const documentsUploaded = requiredDocuments.filter( (d) => d.uploaded, ).length; const anglesCaptured = carAngles.filter((a) => a.captured).length; const partsCaptured = damagedParts.filter((p) => p.captured).length; const captureProgress = getClaimCaptureProgress(claimCase); const capturePhaseDocsRemaining = captureProgress.capturePhaseDocsRemaining; const partsRemaining = Math.max( 0, captureProgress.partsTotal - captureProgress.partsCaptured, ); const anglesRemaining = Math.max( 0, captureProgress.anglesTotal - captureProgress.anglesCaptured, ); const postCaptureDocsRemaining = requiredDocuments.filter( (d) => !d.preferUploadDuringCapture && !d.uploaded, ).length; const totalRemaining = partsRemaining + anglesRemaining + capturePhaseDocsRemaining; return { claimRequestId: claimCase._id.toString(), publicId: claimCase.publicId, currentStep: claimCase.workflow?.currentStep || "", captureSequencePhase: captureProgress.sequencePhase, captureSequenceHint: capturePhaseSequenceMessage( captureProgress.sequencePhase, ), requiredDocuments, carAngles, damagedParts, totalRemaining, progress: { documentsUploaded, documentsTotal: requiredDocuments.length, anglesCaptured, anglesTotal: carAngles.length, partsCaptured, partsTotal: damagedParts.length, capturePhaseDocsRemaining, postCaptureDocumentsRemaining: postCaptureDocsRemaining, }, }; } catch (error) { if (error instanceof HttpException) throw error; this.logger.error("getCaptureRequirementsV2 failed", error); throw new InternalServerErrorException( "Failed to get capture requirements", ); } } /** * When the owner has uploaded every expert-requested document/part (or acknowledged description-only resend), * move the claim back to the damage expert queue (same posture as after initial submission). */ private async tryFinalizeExpertResendAfterUserAction( claimRequestId: string, actorUserId: string, ownerDisplayName: string, ): Promise { const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim || !canFinalizeExpertResend(claim)) { return; } await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, claimStatus: ClaimStatus.PENDING, "workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, "evaluation.damageExpertResend.fulfilledAt": new Date(), }, $push: { history: { type: "EXPERT_RESEND_FULFILLED", actor: { actorId: new Types.ObjectId(actorUserId), actorName: ownerDisplayName, actorType: "user", }, timestamp: new Date(), metadata: {}, }, }, }); } /** * V2: Owner acknowledges a description-only expert resend (no extra documents or part photos requested). */ async acknowledgeExpertResendInstructionsV2( claimRequestId: string, currentUserId: string, actor?: { sub: string; role?: string }, ): Promise<{ claimRequestId: string; status: string; claimStatus: string; currentStep: string; message: string; }> { const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException( `Claim case with ID ${claimRequestId} not found`, ); } const effectiveUserId = await this.resolveClaimEffectiveUserId( claimCase, currentUserId, actor?.role, ); await this.assertEffectiveUserIsDamagedPartyOnClaim( claimCase, effectiveUserId, actor as { username?: string }, "Only the claim owner can complete this step", ); if ( claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND ) { throw new BadRequestException( `No expert resend step is active. Current step: ${claimCase.workflow?.currentStep ?? "none"}`, ); } if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) { throw new BadRequestException( `Claim is not waiting for resend action. Current status: ${claimCase.status}`, ); } const r = claimCase.evaluation?.damageExpertResend; if (!r || r.fulfilledAt) { throw new BadRequestException( "There is no active expert resend request to acknowledge.", ); } if ( normalizeResendDocumentKeys(r.resendDocuments).length > 0 || normalizeResendPartKeys( r.resendCarParts, claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined, claimCase.damage?.selectedParts, ).length > 0 ) { throw new BadRequestException( "Upload all requested documents and part photos before completing the resend step.", ); } await this.tryFinalizeExpertResendAfterUserAction( claimRequestId, effectiveUserId, claimCase.owner?.fullName || "User", ); const updated = await this.claimCaseDbService.findById(claimRequestId); return { claimRequestId, status: String(updated?.status ?? ""), claimStatus: String(updated?.claimStatus ?? ""), currentStep: String(updated?.workflow?.currentStep ?? ""), message: "Claim returned to the damage expert queue.", }; } /** * V2 API: Upload required document */ async uploadRequiredDocumentV2( claimRequestId: string, body: UploadRequiredDocumentV2Dto, file: Express.Multer.File, currentUserId: string, actor?: { sub: string; role?: string }, options?: { v3InPersonFlow?: boolean; skipMetalPlate?: boolean; requiresFileMakerApproval?: boolean; includeGuiltyDamageArea?: boolean }, ): Promise { try { const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException( `Claim case with ID ${claimRequestId} not found`, ); } const effectiveUserId = await this.resolveClaimEffectiveUserId( claimCase, currentUserId, actor?.role, ); await this.assertEffectiveUserIsDamagedPartyOnClaim( claimCase, effectiveUserId, actor as { username?: string }, "Only the claim owner can upload documents", ); const step = claimCase.workflow?.currentStep; const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND; const isCapturePhaseDocUpload = step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES && (isCapturePhaseDamagedPartyDocKey(body.documentKey) || (options?.includeGuiltyDamageArea && (body.documentKey as string) === GUILTY_DAMAGE_AREA_DOC_KEY)); if (!isResendUpload) { if ( step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES && !isCapturePhaseDocUpload ) { throw new BadRequestException( `During ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} only chassis, engine, and damaged metal plate photos may be uploaded here (${CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.join(", ")}), and only after all damaged parts and car angles are captured.`, ); } if (isCapturePhaseDocUpload) { const captureProgress = getClaimCaptureProgress(claimCase); if (!captureProgress.partsComplete) { throw new BadRequestException( "Upload photos for all selected damaged parts before uploading chassis, engine, or metal plate photos.", ); } if (!captureProgress.anglesComplete) { throw new BadRequestException( "Capture all four car angles (front, back, left, right) before uploading chassis, engine, or metal plate photos.", ); } } const allowedInitialUploadStep = step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS || isCapturePhaseDocUpload; if (!allowedInitialUploadStep) { throw new BadRequestException( `Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} (capture-phase documents only), or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`, ); } if (options?.v3InPersonFlow) { if ( step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && isCapturePhaseDamagedPartyDocKey(body.documentKey) ) { const capturePartDone = ( claimCase.workflow?.completedSteps ?? [] ).includes(ClaimWorkflowStep.CAPTURE_PART_DAMAGES); if (!capturePartDone) { throw new BadRequestException( `Complete outer/other part selection and capture-part (damaged parts + car angles) before uploading chassis, engine, and metal plate photos (${CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.join(", ")}).`, ); } throw new BadRequestException( `Chassis, engine, and metal plate photos must be uploaded during ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} after parts and angles are captured.`, ); } } } if (isResendUpload) { if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) { throw new BadRequestException( `Claim is not waiting for expert resend uploads. Status: ${claimCase.status}`, ); } if (!documentKeyAllowedForExpertResend(claimCase, body.documentKey)) { throw new BadRequestException( `Document "${body.documentKey}" was not requested by the damage expert for this resend.`, ); } } // CAR_BODY claims only allow 7 document types (no guilty_*) const blameForUpload = claimCase.blameRequestId ? await this.blameRequestDbService.findById( claimCase.blameRequestId.toString(), ) : null; const isCarBodyUpload = blameForUpload?.type === BlameRequestType.CAR_BODY; const guiltyKeys = [ "guilty_driving_license_front", "guilty_driving_license_back", "guilty_car_card_front", "guilty_car_card_back", "guilty_metal_plate", ]; if (isCarBodyUpload && guiltyKeys.includes(body.documentKey)) { throw new BadRequestException( `Document type ${body.documentKey} is not required for CAR_BODY claims`, ); } // Initial flow: each document once. Expert resend and v3 in-person: allow replacement. if (!isResendUpload && !options?.v3InPersonFlow) { const existingDoc = (claimCase.requiredDocuments as any)?.get?.(body.documentKey) ?? (claimCase.requiredDocuments as any)?.[body.documentKey]; if (existingDoc?.uploaded) { throw new ConflictException( `Document ${body.documentKey} has already been uploaded`, ); } } const fileUrl = buildFileLink(file.path); const docRef = await this.claimRequiredDocumentDbService.create({ path: file.path, fileName: file.filename, claimId: new Types.ObjectId(claimRequestId), documentType: body.documentKey, uploadedAt: new Date(), }); // Base history entry for this upload const uploadHistoryEntry = { type: "DOCUMENT_UPLOADED", actor: { actorId: new Types.ObjectId(currentUserId), actorName: claimCase.owner?.fullName || "User", actorType: "user", }, timestamp: new Date(), metadata: { documentKey: body.documentKey, fileName: file.filename, fileId: docRef._id.toString(), }, }; // Base $set — always applied const $set: Record = { [`requiredDocuments.${body.documentKey}`]: { fileId: docRef._id, filePath: file.path, fileName: file.filename, uploaded: true, uploadedAt: new Date(), }, }; // History entries to push — always at least the upload entry const historyEntries: unknown[] = [uploadHistoryEntry]; // completedSteps entries to push const completedStepsEntries: string[] = []; let allDocumentsUploaded = false; let remaining = 0; let captureStepCompletedOnThisUpload = false; if (!isResendUpload) { if (isCapturePhaseDocUpload) { const afterThis = (k: string) => k === body.documentKey || this.isRequiredDocumentUploadedOnClaim(claimCase, k); const captureKeysForCount: string[] = [ ...CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS, ...(options?.includeGuiltyDamageArea ? [GUILTY_DAMAGE_AREA_DOC_KEY] : []), ]; remaining = captureKeysForCount.filter((k) => { if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false; return !afterThis(k); }).length; if (remaining === 0) { const progressAfterDoc = getClaimCaptureProgress(claimCase, { assumeCapturePhaseDocKey: body.documentKey, skipMetalPlate: options?.skipMetalPlate, includeGuiltyDamageArea: options?.includeGuiltyDamageArea, }); if ( progressAfterDoc.partsComplete && progressAfterDoc.anglesComplete && progressAfterDoc.capturePhaseDocsComplete ) { captureStepCompletedOnThisUpload = true; // Advance to walk-around video step (UPLOADING_REQUIRED_DOCUMENTS / // UPLOAD_REQUIRED_DOCUMENTS). For V4/V5 the claim is finalised in // setVideoCaptureV3 (car-capture), which is the last FileReviewer // action before handing off to the damage expert. $set["status"] = ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS; $set["workflow.currentStep"] = ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS; $set["workflow.nextStep"] = ClaimWorkflowStep.USER_SUBMISSION_COMPLETE; completedStepsEntries.push( ClaimWorkflowStep.CAPTURE_PART_DAMAGES, ); historyEntries.push({ type: "STEP_COMPLETED", actor: { actorId: new Types.ObjectId(currentUserId), actorName: claimCase.owner?.fullName || "User", actorType: "user", }, timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, description: options?.v3InPersonFlow ? "V4/V5 capture docs complete. Upload walk-around video to finalise." : "Angles, damaged parts, and capture-phase vehicle evidence are complete.", }, }); } } } else { const v3InitialDocsComplete = !!options?.v3InPersonFlow && step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && this.allV3PreCaptureDocumentsComplete( claimCase, isCarBodyUpload, body.documentKey, options?.skipMetalPlate, ); allDocumentsUploaded = options?.v3InPersonFlow ? v3InitialDocsComplete : this.allV2OwnerDocumentsComplete( claimCase, isCarBodyUpload, body.documentKey, ); remaining = options?.v3InPersonFlow ? this.countRemainingV3PreCaptureDocuments( claimCase, isCarBodyUpload, body.documentKey, options?.skipMetalPlate, ) : this.countRemainingV2OwnerDocuments( claimCase, isCarBodyUpload, body.documentKey, ); if (allDocumentsUploaded) { if (options?.v3InPersonFlow) { // V4/V5 split flow: the status transition to WAITING_FOR_FILE_REVIEWER is // deferred to a post-write re-check (see below) to eliminate the race // condition where two concurrent uploads each see a stale snapshot and // neither fires the transition. We skip adding it to $set here. } else { $set["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; $set["claimStatus"] = ClaimStatus.PENDING; $set["workflow.currentStep"] = ClaimWorkflowStep.USER_SUBMISSION_COMPLETE; $set["workflow.nextStep"] = ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; completedStepsEntries.push( ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, ); historyEntries.push( { type: "STEP_COMPLETED", actor: { actorId: new Types.ObjectId(currentUserId), actorName: claimCase.owner?.fullName || "User", actorType: "user", }, timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, description: "All required documents uploaded", }, }, { type: "STEP_COMPLETED", actor: { actorId: new Types.ObjectId(currentUserId), actorName: claimCase.owner?.fullName || "User", actorType: "user", }, timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, description: "User submission complete. Claim ready for damage expert review.", }, }, ); } } } } // Build the final MongoDB update — all operators explicit and clean const updatePayload: Record = { $set }; const $push: Record = { history: historyEntries.length === 1 ? historyEntries[0] : { $each: historyEntries }, }; if (completedStepsEntries.length === 1) { $push["workflow.completedSteps"] = completedStepsEntries[0]; } else if (completedStepsEntries.length > 1) { $push["workflow.completedSteps"] = { $each: completedStepsEntries }; } updatePayload.$push = $push; await this.claimCaseDbService.findByIdAndUpdate( claimRequestId, updatePayload, ); // ─── V4/V5 in-person flow: post-write race-safe status transition ────── // After the write we re-read the actual DB state. If all pre-capture // documents are now present but the status has not yet advanced (i.e. a // concurrent request uploaded the last doc a split-second before us and // the stale snapshot we read earlier missed it — or vice-versa), we apply // WAITING_FOR_FILE_REVIEWER atomically using findOneAndUpdate with a status // condition, so exactly one writer wins regardless of concurrency. if (options?.v3InPersonFlow && !isResendUpload && !isCapturePhaseDocUpload) { const afterWrite = await this.claimCaseDbService.findById(claimRequestId); if (afterWrite) { const blameForRecheck = afterWrite.blameRequestId ? await this.blameRequestDbService.findById( afterWrite.blameRequestId.toString(), ) : null; const isCarBodyRecheck = blameForRecheck?.type === BlameRequestType.CAR_BODY; const skipMetalPlateRecheck = !!(blameForRecheck as any)?.isMadeByFileMaker; const allDoneNow = afterWrite.status === ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS && afterWrite.workflow?.currentStep === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && this.allV3PreCaptureDocumentsComplete(afterWrite, isCarBodyRecheck, undefined, skipMetalPlateRecheck); if (allDoneNow) { // Atomic conditional update: only succeeds when status is still // UPLOADING_REQUIRED_DOCUMENTS, preventing double-application. const advanced = await this.claimCaseDbService.findOneAndUpdate( { _id: afterWrite._id, status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, }, { $set: { status: ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER, "workflow.nextStep": ClaimWorkflowStep.SELECT_OUTER_PARTS, }, $push: { "workflow.completedSteps": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, history: { type: "STEP_COMPLETED", actor: { actorId: new Types.ObjectId(currentUserId), actorName: afterWrite.owner?.fullName || "User", actorType: "user", }, timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, description: "FileMaker documents complete. File sealed — awaiting FileReviewer.", v3InPersonFlow: true, }, }, }, }, ); if (advanced) { // This request won the race — reflect the final state in the response. allDocumentsUploaded = true; // Now that the FileMaker's document phase is complete, promote the // blame file to WAITING_FOR_FILE_REVIEWER so the frontend shows the // correct sealed state when the FileMaker returns. const blameId = (afterWrite as any).blameRequestId; if (blameId) { await this.blameRequestDbService.findByIdAndUpdate( String(blameId), { $set: { status: BlameCaseStatus.WAITING_FOR_FILE_REVIEWER } }, ); } } } else if ( afterWrite.status === ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER || (afterWrite.workflow?.completedSteps ?? []).includes( ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, ) ) { // Another concurrent request already advanced the status — treat // this upload as the completing upload for response purposes. allDocumentsUploaded = true; } } } // Auto-submit Fanavaran attachments when all documents are uploaded if (allDocumentsUploaded && !isResendUpload && !options?.v3InPersonFlow) { this.submitFanavaranAttachmentsV2(claimRequestId, resolveFanavaranClientKey()).catch( (err) => this.logger.warn(`Fanavaran attachments auto-submit failed: ${err?.message}`), ); } if (isResendUpload) { await this.tryFinalizeExpertResendAfterUserAction( claimRequestId, currentUserId, claimCase.owner?.fullName || "User", ); const refreshed = await this.claimCaseDbService.findById(claimRequestId); const expertResendComplete = !!refreshed?.evaluation?.damageExpertResend?.fulfilledAt; const fanavaranAttachment = await this.autoSubmitFanavaranAttachment( claimRequestId, { path: file.path, fileName: file.filename, source: `document:${body.documentKey}:resend`, }, ); return { claimRequestId: claimCase._id.toString(), documentKey: body.documentKey, fileUrl, allDocumentsUploaded: false, expertResendComplete, currentStep: refreshed?.workflow?.currentStep || ClaimWorkflowStep.USER_EXPERT_RESEND, message: expertResendComplete ? "Expert resend requirements are complete. Your claim is back in the damage expert queue." : "Document uploaded for expert resend.", fanavaranAttachment, }; } const message = isCapturePhaseDocUpload ? captureStepCompletedOnThisUpload ? options?.v3InPersonFlow ? "Capture-phase documents and all damage captures are complete. Upload the walk-around video (car-capture) to finalise." : "Capture-phase documents and all damage captures are complete. Please proceed to upload the remaining required documents." : remaining > 0 ? `Document saved. ${remaining} capture-phase document(s) still required (chassis, engine, metal plate).` : capturePhaseSequenceMessage( getClaimCaptureProgress(claimCase, { assumeCapturePhaseDocKey: body.documentKey, }).sequencePhase, ) : allDocumentsUploaded ? options?.v3InPersonFlow ? "All FileMaker documents uploaded. File is sealed and ready for FileReviewer pickup." : "All documents uploaded successfully. Your claim is now ready for damage expert review." : `Document uploaded successfully. ${remaining} documents remaining.`; return { claimRequestId: claimCase._id.toString(), documentKey: body.documentKey, fileUrl, allDocumentsUploaded, currentStep: isCapturePhaseDocUpload ? captureStepCompletedOnThisUpload ? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS : ClaimWorkflowStep.CAPTURE_PART_DAMAGES : allDocumentsUploaded ? options?.v3InPersonFlow ? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS : ClaimWorkflowStep.USER_SUBMISSION_COMPLETE : ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, message, fanavaranAttachment: await this.autoSubmitFanavaranAttachment( claimRequestId, { path: file.path, fileName: file.filename, source: `document:${body.documentKey}`, }, ), }; } catch (error) { if (error instanceof HttpException) throw error; this.logger.error("uploadRequiredDocumentV2 failed", error); throw new InternalServerErrorException("Failed to upload document"); } } /** * V2 API: Capture car angle or damaged part */ async capturePartV2( claimRequestId: string, body: CapturePartV2Dto, file: Express.Multer.File, currentUserId: string, actor?: { sub: string; role?: string }, ): Promise { try { const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException( `Claim case with ID ${claimRequestId} not found`, ); } const effectiveUserId = await this.resolveClaimEffectiveUserId( claimCase, currentUserId, actor?.role, ); await this.assertEffectiveUserIsDamagedPartyOnClaim( claimCase, effectiveUserId, actor as { username?: string }, "Only the claim owner can capture parts", ); const capStep = claimCase.workflow?.currentStep; const isResendCapture = capStep === ClaimWorkflowStep.USER_EXPERT_RESEND; if ( !isResendCapture && capStep !== ClaimWorkflowStep.CAPTURE_PART_DAMAGES ) { throw new BadRequestException( `Invalid workflow step. Expected ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`, ); } if (isResendCapture) { if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) { throw new BadRequestException( `Claim is not waiting for expert resend uploads. Status: ${claimCase.status}`, ); } if (body.captureType !== "part") { throw new BadRequestException( "During expert resend only damaged-part photos can be uploaded (not car angles).", ); } if (!partKeyAllowedForExpertResend(claimCase, body.captureKey)) { throw new BadRequestException( `Part "${body.captureKey}" was not requested by the damage expert for this resend.`, ); } if (canonicalizeClaimCarAngleKey(body.captureKey)) { throw new BadRequestException( "During expert resend only damaged-part photos are allowed, not car angles (front/back/left/right).", ); } } if (!isResendCapture) { const captureProgressBefore = getClaimCaptureProgress(claimCase); const willBeAngle = body.captureType === "angle" || (body.captureType === "part" && canonicalizeClaimCarAngleKey(body.captureKey) !== null); if (willBeAngle && !captureProgressBefore.partsComplete) { throw new BadRequestException( "Upload photos for all selected damaged parts before capturing car angles.", ); } } const angleCanon = canonicalizeClaimCarAngleKey(body.captureKey); if (body.captureType === "angle" && !angleCanon) { throw new BadRequestException( `Invalid angle captureKey "${body.captureKey}". Use one of: front, back, left, right.`, ); } const treatAsAngle = !isResendCapture && (body.captureType === "angle" || (body.captureType === "part" && angleCanon !== null)); const fileUrl = buildFileLink(file.path); const captureData = { path: file.path, fileName: file.filename, url: fileUrl, capturedAt: new Date(), }; const updateData: any = { $push: { history: { type: treatAsAngle ? "ANGLE_CAPTURED" : "PART_CAPTURED", actor: { actorId: new Types.ObjectId(currentUserId), actorName: claimCase.owner?.fullName || "User", actorType: "user", }, timestamp: new Date(), metadata: { captureType: body.captureType, captureKey: body.captureKey, ...(treatAsAngle && angleCanon ? { resolvedCarAngleKey: angleCanon } : {}), fileName: file.filename, }, }, }, }; const carType = claimCase.vehicle?.carType as | ClaimVehicleTypeV2 | undefined; const selectedOuterLegacy = (claimCase.damage as any)?.selectedOuterParts; const selectedBeforeNorm = normalizeDamageSelectedParts( claimCase.damage?.selectedParts, carType, selectedOuterLegacy, ); let nextSelected = [...selectedBeforeNorm]; let nextMedia = coerceDamagedPartsMediaToArray( claimCase.media?.damagedParts, nextSelected, ); if (treatAsAngle && angleCanon) { updateData[`media.carAngles.${angleCanon}`] = captureData; if (!Array.isArray(claimCase.media?.damagedParts)) { const unsetFieldKeys = legacyAngleDamagedPartFieldKeysToUnset( angleCanon, body.captureKey, claimCase.damage?.selectedParts, carType, selectedOuterLegacy, ); if (unsetFieldKeys.length) { updateData.$unset = {}; for (const k of unsetFieldKeys) { updateData.$unset[`media.damagedParts.${k}`] = ""; } } } } else { let idx = resolvePartCaptureIndex( body.captureKey, nextSelected, nextMedia, ); if (isResendCapture && body.captureType === "part" && idx < 0) { const added = normalizeDamageSelectedParts( [body.captureKey], carType, undefined, ); const row = added[0] || { id: null, name: body.captureKey, side: "", label_fa: body.captureKey, catalogKey: body.captureKey, }; nextSelected.push(row); idx = nextSelected.length - 1; nextMedia.push({}); } if (idx < 0) { throw new BadRequestException( `Unknown damaged-part captureKey "${body.captureKey}". Use catalog id, 0-based index, or catalog key.`, ); } while (nextMedia.length <= idx) { nextMedia.push({}); } const base = nextSelected[idx] || { id: null, name: String(body.captureKey), side: "", label_fa: String(body.captureKey), }; nextMedia[idx] = { ...(base.id != null ? { id: base.id } : {}), name: base.name, side: base.side, label_fa: base.label_fa, ...(base.catalogKey ? { catalogKey: base.catalogKey } : {}), ...nextMedia[idx], ...captureData, }; updateData["media.damagedParts"] = nextMedia; if ( isResendCapture && nextSelected.length !== selectedBeforeNorm.length ) { updateData["damage.selectedParts"] = nextSelected; } } const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate( claimRequestId, updateData, ); if (isResendCapture) { await this.tryFinalizeExpertResendAfterUserAction( claimRequestId, currentUserId, claimCase.owner?.fullName || "User", ); const refreshed = await this.claimCaseDbService.findById(claimRequestId); const expertResendComplete = !!refreshed?.evaluation?.damageExpertResend?.fulfilledAt; const fanavaranAttachment = await this.autoSubmitFanavaranAttachment( claimRequestId, { path: file.path, fileName: file.filename, source: `capture:${body.captureType}:${body.captureKey}:resend`, }, ); return { claimRequestId: claimCase._id.toString(), captureType: body.captureType, captureKey: body.captureKey, fileUrl, allCapturesComplete: expertResendComplete, expertResendComplete, currentStep: refreshed?.workflow?.currentStep || ClaimWorkflowStep.USER_EXPERT_RESEND, message: expertResendComplete ? "Expert resend requirements are complete. Your claim is back in the damage expert queue." : "Part photo uploaded for expert resend.", fanavaranAttachment, }; } const captureProgress = getClaimCaptureProgress(updatedClaim); const allCapturesComplete = isClaimCaptureStepComplete(updatedClaim); if (allCapturesComplete) { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, "workflow.currentStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, "workflow.nextStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, $push: { "workflow.completedSteps": ClaimWorkflowStep.CAPTURE_PART_DAMAGES, history: { type: "STEP_COMPLETED", actor: { actorId: new Types.ObjectId(currentUserId), actorName: claimCase.owner?.fullName || "User", actorType: "user", }, timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, description: "Angles, damaged parts, and capture-phase vehicle evidence (chassis, engine, metal plate) are complete. Please upload remaining required documents.", }, }, }, }); } const partsRemaining = Math.max( 0, captureProgress.partsTotal - captureProgress.partsCaptured, ); const anglesRemaining = Math.max( 0, captureProgress.anglesTotal - captureProgress.anglesCaptured, ); const remaining = partsRemaining + anglesRemaining + captureProgress.capturePhaseDocsRemaining; const message = allCapturesComplete ? "All captures complete. Please proceed to upload the remaining required documents." : `${body.captureType === "angle" ? "Angle" : "Part"} captured successfully. ${capturePhaseSequenceMessage(captureProgress.sequencePhase)} (${remaining} item(s) left in this step).`; return { claimRequestId: claimCase._id.toString(), captureType: body.captureType, captureKey: body.captureKey, fileUrl, allCapturesComplete, currentStep: allCapturesComplete ? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS : ClaimWorkflowStep.CAPTURE_PART_DAMAGES, message, fanavaranAttachment: await this.autoSubmitFanavaranAttachment( claimRequestId, { path: file.path, fileName: file.filename, source: `capture:${body.captureType}:${body.captureKey}`, }, ), }; } catch (error) { if (error instanceof HttpException) throw error; this.logger.error("capturePartV2 failed", error); throw new InternalServerErrorException("Failed to capture part"); } } /** * V2 API: Upload car walk-around video (same storage as v1: claim-video-capture + ClaimCase.media.videoCaptureId). */ async setVideoCaptureV2( claimRequestId: string, fileDetail: Express.Multer.File, currentUserId: string, actor?: { sub: string; role?: string }, ): Promise { try { const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException( `Claim case with ID ${claimRequestId} not found`, ); } const effectiveUserId = await this.resolveClaimEffectiveUserId( claimCase, currentUserId, actor?.role, ); await this.assertEffectiveUserIsDamagedPartyOnClaim( claimCase, effectiveUserId, actor as { username?: string }, "Only the claim owner can upload car capture video", ); if (!fileDetail) { throw new BadRequestException("Video file is required."); } if ( claimCase.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS ) { throw new BadRequestException( `Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, but current step is ${claimCase.workflow?.currentStep}`, ); } const captureProgress = getClaimCaptureProgress(claimCase); if (!captureProgress.partsComplete) { throw new BadRequestException( "Upload photos for all selected damaged parts before uploading the walk-around video.", ); } if (!captureProgress.anglesComplete) { throw new BadRequestException( "Capture all four car angles (front, back, left, right) before uploading the walk-around video.", ); } if (claimCase.media?.videoCaptureId) { throw new ConflictException( "A video has already been uploaded for this claim.", ); } const videoDoc = await this.createVideoCapture( fileDetail, claimRequestId, ); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { "media.videoCaptureId": videoDoc._id }, $push: { history: { type: "VIDEO_CAPTURE_UPLOADED", actor: { actorId: new Types.ObjectId(currentUserId), actorName: claimCase.owner?.fullName || "User", actorType: "user", }, timestamp: new Date(), metadata: { videoId: videoDoc._id.toString(), fileName: fileDetail.filename, }, }, }, }); return { claimRequestId: claimCase._id.toString(), videoId: videoDoc._id.toString(), message: "Video capture uploaded successfully.", }; } catch (error) { if (error instanceof HttpException) throw error; this.logger.error("setVideoCaptureV2 failed", error); throw new InternalServerErrorException("Failed to upload video capture"); } } /** * Whether an expert resend request exists (user is expected to respond). */ private claimCaseHasExpertResend(claimCase: any): boolean { const r = claimCase?.evaluation?.damageExpertResend; if (!r) return false; if ( typeof r.resendDescription === "string" && r.resendDescription.trim() !== "" ) { return true; } if (Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) return true; if (Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0) return true; return false; } /** * V2 API: User objection (ClaimCase + evaluation.objection payload). * * Priced-flow: insurer-review post-expert statuses (`INSURER_REVIEW_AWAITING_OWNER_SIGN`, `INSURER_REVIEW_MIXED_FACTORS_PENDING`, or legacy `WAITING_FOR_INSURER_APPROVAL`) @ `INSURER_REVIEW` — either NEEDS_REVISION (mixed-priced gate before factor upload) * or APPROVED (final totals after pricing / factor validation); never while uploading factors or while expert validates. * Legacy: objection during unpaid expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`). * Post: resets owner signatures when applicable and returns claim to WAITING_FOR_DAMAGE_EXPERT. */ async handleUserObjectionV2( claimRequestId: string, body: UserObjectionV2Dto, currentUserId: string, actor?: { sub: string; role?: string }, invoiceFiles?: Express.Multer.File[], ) { try { const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException( `Claim case with ID ${claimRequestId} not found`, ); } const effectiveUserId = await this.resolveClaimEffectiveUserId( claimCase, currentUserId, actor?.role, ); await this.assertEffectiveUserIsDamagedPartyOnClaim( claimCase, effectiveUserId, actor as { username?: string }, "Only the claim owner can submit an objection", ); if (claimCase.evaluation?.objection?.submittedAt) { throw new ConflictException( "An objection has already been submitted for this claim.", ); } const hasExpertResend = this.claimCaseHasExpertResend(claimCase); const pendingResendGate = hasExpertResend && claimCase.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND && claimCase.workflow?.currentStep === ClaimWorkflowStep.USER_EXPERT_RESEND; if (claimCase.evaluation?.damageExpertReplyFinal) { throw new ConflictException( "The expert already submitted the final reply after objection; submit accept/reject via the owner signer.", ); } const activeExpert = getActiveV2ExpertReply( claimCase as unknown as { evaluation?: Record }, ); const partsIn = body.objectionParts ?? []; const newPartsIn = body.newParts ?? []; if (partsIn.length === 0 && newPartsIn.length === 0) { throw new BadRequestException( "Provide at least one entry in objectionParts and/or newParts.", ); } let pricingObjectionEligible = false; if ( claimCaseStatusAllowsOwnerInsurerSignStep(claimCase.status) && claimCase.workflow?.currentStep === ClaimWorkflowStep.INSURER_REVIEW && !claimCase.evaluation?.ownerInsurerApproval?.signedAt ) { if (objectionDisallowedDueToOutstandingFactorWorkflow(claimCase)) { throw new BadRequestException( "Objections are paused while factor files are uploading or the expert is validating repair factors.", ); } const shape = classifyV2ExpertPricingParts(activeExpert?.parts ?? []); const atMixedPartialGate = claimCase.claimStatus === ClaimStatus.NEEDS_REVISION && shape.mixedFactorAndPrice && !claimCase.evaluation?.ownerPricedPartsApproval?.signedAt; const atFinalPricingGate = claimCase.claimStatus === ClaimStatus.APPROVED; pricingObjectionEligible = atMixedPartialGate || atFinalPricingGate; } if (!(pendingResendGate || pricingObjectionEligible)) { throw new BadRequestException( "No open objection window. Use this after a priced insurer-review step without a recorded final approval, " + "or while completing an unpaid expert resend.", ); } if ( !pendingResendGate && pricingObjectionEligible && !activeExpert?.parts?.length ) { throw new BadRequestException( "A priced assessment is required before you can object.", ); } const pricedCatalogIds = activeExpert?.parts?.length ? new Set( activeExpert.parts .filter((p) => p.factorNeeded !== true) .map((p) => parseCatalogPartIdInput(p.partId)) .filter((id): id is number => id != null), ) : new Set(); if (partsIn.length && pricingObjectionEligible) { for (const op of partsIn) { const catalogId = parseCatalogPartIdInput(op.partId); if (catalogId == null || !pricedCatalogIds.has(catalogId)) { throw new BadRequestException( `Only priced repair lines can be disputed (${String(op.partId)} is factor-only or unknown).`, ); } } } const objectionParts = partsIn.map((p) => ({ partId: parseCatalogPartIdInput(p.partId)!, reason: p.reason?.trim() || undefined, partPrice: p.partPrice != null && String(p.partPrice).trim() !== "" ? normalizeMoneyAmountString(String(p.partPrice)) : undefined, partSalary: p.partSalary != null && String(p.partSalary).trim() !== "" ? normalizeMoneyAmountString(String(p.partSalary)) : undefined, typeOfDamage: p.typeOfDamage != null ? String(p.typeOfDamage) : undefined, carPartDamage: p.carPartDamage?.trim() || undefined, side: p.side?.trim() || undefined, })); const newParts = newPartsIn.map((p) => ({ partId: p.partId != null && parseCatalogPartIdInput(p.partId) != null ? parseCatalogPartIdInput(p.partId)! : new Types.ObjectId().toString(), partName: p.partName.trim(), side: p.side?.trim() || undefined, })); const objectionCarType = claimCase.vehicle?.carType as | ClaimVehicleTypeV2 | undefined; const mergedNorm = normalizeDamageSelectedParts( claimCase.damage?.selectedParts, objectionCarType, (claimCase.damage as any)?.selectedOuterParts, ); const mergedKeys = new Set(mergedNorm.map((p) => partLookupKey(p))); for (const np of newParts) { const name = np.partName?.trim(); if (!name) continue; const side = String(np.side ?? "").trim(); const row = { id: null as number | null, name, side, label_fa: name, }; const k = partLookupKey(row); if (!mergedKeys.has(k)) { mergedNorm.push(row); mergedKeys.add(k); } } const invoices = (invoiceFiles ?? []).map((f) => ({ fileId: new Types.ObjectId(), path: f.path, fileName: f.filename, uploadedAt: new Date(), })); const objectionPayload = { objectionParts, newParts, submittedAt: new Date(), ...(invoices.length > 0 && { invoices }), }; await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, claimStatus: ClaimStatus.PENDING, "workflow.locked": false, "workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, "workflow.nextStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION, "evaluation.objection": objectionPayload, "damage.selectedParts": mergedNorm, ...(pendingResendGate && { "evaluation.damageExpertResend.fulfilledAt": new Date(), }), }, $unset: { "evaluation.ownerPricedPartsApproval": "", "evaluation.ownerInsurerApproval": "", }, $push: { history: { type: "USER_OBJECTION_SUBMITTED", actor: { actorId: new Types.ObjectId(effectiveUserId), actorName: claimCase.owner?.fullName || "User", actorType: "user", }, timestamp: new Date(), metadata: { objectionPartCount: objectionParts.length, newPartCount: newParts.length, invoiceCount: invoices.length, }, }, }, }); return objectionPayload; } catch (error) { if (error instanceof HttpException) throw error; this.logger.error("handleUserObjectionV2 failed", error); throw new InternalServerErrorException("Failed to submit objection"); } } /** * V2 API: User satisfaction rating for a completed claim case (same intent as v1 PUT …/request/:id/user-rating). */ async addUserRatingV2( claimRequestId: string, ratingDto: UserRatingDto, currentUserId: string, actor?: { sub: string; role?: string }, ): Promise { const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException( `Claim case with ID ${claimRequestId} not found`, ); } const effectiveUserId = await this.resolveClaimEffectiveUserId( claimCase, currentUserId, actor?.role, ); await this.assertEffectiveUserIsDamagedPartyOnClaim( claimCase, effectiveUserId, actor as { username?: string }, "Only the claim owner can submit a satisfaction rating.", ); if (claimCase.status !== ClaimCaseStatus.COMPLETED) { throw new BadRequestException( "You can only rate a claim after it has been completed.", ); } if (claimCase.userRating?.createdAt) { throw new ConflictException( "A rating has already been submitted for this claim.", ); } const score = (n: number) => Number(n); for (const [key, label] of [ [ratingDto.progressSpeed, "progressSpeed"], [ratingDto.registrationEase, "registrationEase"], [ratingDto.overallEvaluation, "overallEvaluation"], ] as const) { const v = score(key); if (Number.isNaN(v) || v < 0 || v > 5) { throw new BadRequestException( `${label} must be a number between 0 and 5.`, ); } } const userRating: UserClaimRating = { progressSpeed: ratingDto.progressSpeed, registrationEase: ratingDto.registrationEase, overallEvaluation: ratingDto.overallEvaluation, comment: ratingDto.comment, createdAt: new Date(), }; await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { userRating }, $push: { history: { type: "USER_RATING_SUBMITTED", actor: { actorId: new Types.ObjectId(effectiveUserId), actorName: claimCase.owner?.fullName || "User", actorType: "user", }, timestamp: new Date(), metadata: {}, }, }, }); return userRating; } /** * V2: Claim owner signature on expert pricing. * * Phase 1 (mixed priced + factor-needed lines): `INSURER_REVIEW_MIXED_FACTORS_PENDING` (or legacy `WAITING_FOR_INSURER_APPROVAL`), NEEDS_REVISION, INSURER_REVIEW, * owner has not yet accepted priced lines — agreeing records `evaluation.ownerPricedPartsApproval` and moves the * workflow to OWNER_UPLOAD_FACTOR_DOCUMENTS. Rejecting rejects the whole case. * * Phase 2 (final): INSURER_REVIEW_AWAITING_OWNER_SIGN (or legacy WAITING_FOR_INSURER_APPROVAL), APPROVED, INSURER_REVIEW — full accept/reject completes the case. */ async submitOwnerInsurerApprovalSignV2( claimRequestId: string, agree: boolean, signFile: Express.Multer.File, currentUserId: string, actor?: { sub: string; role?: string; fullName?: string }, ): Promise<{ message: string; claimRequestId: string; status: ClaimCaseStatus; claimStatus: ClaimStatus; currentStep?: ClaimWorkflowStep; accepted: boolean; phase?: "PRICED_PARTS_FOR_FACTORS" | "FINAL_APPROVAL"; fanavaran?: FanavaranAutoSubmitResult; fanavaranExpertise?: FanavaranExpertiseSubmitResult; }> { if (!Types.ObjectId.isValid(claimRequestId)) { throw new BadRequestException("Invalid claim request id"); } if (!signFile) { throw new BadRequestException("A signature file is required"); } const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException("Claim request not found"); } const effectiveUserId = await this.resolveClaimEffectiveUserId( claimCase, currentUserId, actor?.role, ); await this.assertEffectiveUserIsDamagedPartyOnClaim( claimCase, effectiveUserId, actor as { username?: string }, "Only the claim owner (damaged party) can sign at this step.", ); if (!claimCaseStatusAllowsOwnerInsurerSignStep(claimCase.status)) { throw new BadRequestException( `Claim is not waiting for your insurer-approval signature. Current status: ${String(claimCase.status)}`, ); } if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.INSURER_REVIEW) { throw new BadRequestException( `Signing is only allowed at workflow step ${ClaimWorkflowStep.INSURER_REVIEW}. Current: ${String(claimCase.workflow?.currentStep ?? "")}`, ); } const active = claimCase.evaluation?.damageExpertReplyFinal || claimCase.evaluation?.damageExpertReply; if (!active?.submittedAt) { throw new BadRequestException( "No expert reply is available to sign off on yet.", ); } const shape = classifyV2ExpertPricingParts(active.parts ?? []); const isMixedPartialGate = shape.mixedFactorAndPrice && claimCase.claimStatus === ClaimStatus.NEEDS_REVISION && !claimCase.evaluation?.ownerPricedPartsApproval?.signedAt; const isFinalApprovalGate = claimCase.claimStatus === ClaimStatus.APPROVED && !claimCase.evaluation?.ownerInsurerApproval?.signedAt; if (!isMixedPartialGate && !isFinalApprovalGate) { throw new BadRequestException( "No owner signature action is available at this state. " + "If repair factors are pending upload, complete the priced-line signature first (when claimStatus is NEEDS_REVISION). " + "If you already completed the final approval signature, refresh the claim.", ); } if ( isMixedPartialGate && claimCase.evaluation?.ownerInsurerApproval?.signedAt ) { throw new ConflictException( "Claim already has a final approval record; refresh the claim state.", ); } if ( isFinalApprovalGate && claimCase.evaluation?.ownerInsurerApproval?.signedAt ) { throw new ConflictException( "You have already submitted a signature for this claim.", ); } const signDoc = await this.claimSignDbService.create({ fileName: signFile.filename, userId: effectiveUserId, path: signFile.path, requestId: new Types.ObjectId(claimRequestId), } as any); const signId = new Types.ObjectId(String((signDoc as any)._id)); const signedAt = new Date(); const scopedApprovalPayload = { agree, signDetailId: signId, signedAt, }; const historyActor = { actorId: new Types.ObjectId(effectiveUserId), actorName: claimCase.owner?.fullName || "User", actorType: "user", }; /** Mixed reply: priced lines acceptance before factor uploads */ if (isMixedPartialGate) { if (!agree) { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { status: ClaimCaseStatus.REJECTED, claimStatus: ClaimStatus.REJECTED, "evaluation.ownerPricedPartsApproval": scopedApprovalPayload, "workflow.nextStep": null, }, $push: { history: { type: "OWNER_REJECTED_PRICED_PARTS_BEFORE_FACTORS", actor: historyActor, timestamp: signedAt, metadata: {}, }, }, }); return { message: "Your rejection has been recorded. This claim is marked rejected and will not proceed.", claimRequestId, status: ClaimCaseStatus.REJECTED, claimStatus: ClaimStatus.REJECTED, currentStep: claimCase.workflow?.currentStep, accepted: false, phase: "PRICED_PARTS_FOR_FACTORS", }; } await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { claimStatus: ClaimStatus.NEEDS_REVISION, "evaluation.ownerPricedPartsApproval": scopedApprovalPayload, "workflow.currentStep": ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS, "workflow.nextStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION, }, $push: { history: { type: "OWNER_SIGNED_PRICED_PARTS_PENDING_FACTOR_UPLOAD", actor: historyActor, timestamp: signedAt, metadata: {}, }, }, }); return { message: "Priced repair lines accepted. Upload repair factor files for each factor-needed part; the claim will return to the damage expert for cost validation.", claimRequestId, status: claimCase.status, claimStatus: ClaimStatus.NEEDS_REVISION, currentStep: ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS, accepted: true, phase: "PRICED_PARTS_FOR_FACTORS", }; } /** Final accept / reject (no outstanding factor gate at this step) */ if (!agree) { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { status: ClaimCaseStatus.REJECTED, claimStatus: ClaimStatus.REJECTED, "evaluation.ownerInsurerApproval": scopedApprovalPayload, "workflow.nextStep": null, }, $push: { history: { type: "OWNER_REJECTED_INSURER_APPROVAL_PRICING", actor: historyActor, timestamp: signedAt, metadata: {}, }, }, }); return { message: "Your rejection has been recorded. This claim is marked rejected and will not proceed.", claimRequestId, status: ClaimCaseStatus.REJECTED, claimStatus: ClaimStatus.REJECTED, currentStep: claimCase.workflow?.currentStep, accepted: false, phase: "FINAL_APPROVAL", }; } // Submit Fanavaran expertise when owner accepts the expert reply const fanavaranExpertise = await this.autoSubmitFanavaranExpertiseOnExpertReply(claimRequestId); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { status: ClaimCaseStatus.COMPLETED, claimStatus: ClaimStatus.APPROVED, "evaluation.ownerInsurerApproval": scopedApprovalPayload, "workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED, "workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED, }, $push: { "workflow.completedSteps": ClaimWorkflowStep.INSURER_REVIEW, history: { type: "OWNER_SIGNED_INSURER_APPROVAL", actor: historyActor, timestamp: signedAt, metadata: {}, }, }, }); const fanavaran = await this.autoSubmitToFanavaranV2OnClaimCompleted(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, claimRequestId, status: ClaimCaseStatus.COMPLETED, claimStatus: ClaimStatus.APPROVED, currentStep: ClaimWorkflowStep.CLAIM_COMPLETED, accepted: true, phase: "FINAL_APPROVAL", fanavaran, fanavaranExpertise, }; } /** * V2 API: Get list of claims for current user (or for FIELD_EXPERT: claims from their expert-initiated IN_PERSON blame files). */ async getMyClaimsV2( currentUserId: string, actor?: { sub: string; role?: string }, query: ListQueryV2Dto = {}, ): Promise { try { let claims: any[]; if (actor?.role === RoleEnum.FIELD_EXPERT) { const expertOid = new Types.ObjectId(currentUserId); const expertBlameIds = await this.blameRequestDbService .find( { expertInitiated: true, initiatedByFieldExpertId: expertOid, }, { select: "_id", lean: true }, ) .then((docs) => docs.map((d) => (d as any)._id)); const claimOr: Record[] = [ { initiatedByFieldExpertId: expertOid }, ]; if (expertBlameIds.length > 0) { claimOr.push({ blameRequestId: { $in: expertBlameIds } }); } claims = await this.claimCaseDbService.find( { $or: claimOr }, { lean: true }, ); } else if (actor?.role === RoleEnum.REGISTRAR) { const registrarBlameIds = await this.blameRequestDbService .find( { registrarInitiated: true, creationMethod: "IN_PERSON", initiatedByRegistrarId: new Types.ObjectId(currentUserId), }, { select: "_id", lean: true }, ) .then((docs) => docs.map((d) => (d as any)._id)); claims = await this.claimCaseDbService.find( { $or: [ { "owner.userId": new Types.ObjectId(currentUserId) }, ...(registrarBlameIds.length ? [{ blameRequestId: { $in: registrarBlameIds } }] : []), ], }, { lean: true }, ); } else { const linkedUserIds = await resolveLinkedUserIdStrings( this.userDbService, { sub: currentUserId, username: (actor as { username?: string })?.username, }, ); const actorCtx = { sub: currentUserId, username: (actor as { username?: string })?.username, }; const partyBlameOr = buildBlamePartyAccessOrConditions( actorCtx, linkedUserIds, ); const idVariants = collectUserIdVariants(...linkedUserIds); const ownerOr = buildOwnerUserIdAccessFilter(idVariants); // damagedPartyUserId filter — direct match without blame lookup const damagedOr = idVariants.length === 1 ? { damagedPartyUserId: idVariants[0] } : idVariants.length > 1 ? { damagedPartyUserId: { $in: idVariants } } : null; // Build snapshot-party query: claim.snapshot.parties.person.userId // is the most direct and reliable path — it's copied from the blame // at claim-creation time and always has the correct damaged-party userId. const snapshotPartyFilter = idVariants.length === 1 ? { "snapshot.parties.person.userId": idVariants[0] } : idVariants.length > 1 ? { "snapshot.parties.person.userId": { $in: idVariants } } : null; const [ ownerClaims, damagedDirectClaims, snapshotClaims, candidateBlames, ] = await Promise.all([ ownerOr ? this.claimCaseDbService.find(ownerOr as any, { lean: true }) : Promise.resolve([]), damagedOr ? this.claimCaseDbService.find(damagedOr as any, { lean: true }) : Promise.resolve([]), snapshotPartyFilter ? this.claimCaseDbService.find(snapshotPartyFilter as any, { lean: true, }) : Promise.resolve([]), partyBlameOr.length ? this.blameRequestDbService.find( partyBlameOr.length === 1 ? (partyBlameOr[0] as any) : ({ $or: partyBlameOr } as any), { lean: true, select: "type parties expert.decision blameStatus", }, ) : Promise.resolve([]), ]); // Any blame where the user is a party (not just damaged) surfaces // the linked claim. Mutation guards protect against acting as the // wrong party; viewing is intentionally open to all parties. const partyBlameIds = (candidateBlames as any[]).map((b) => b._id); const damagedClaims = partyBlameIds.length > 0 ? await this.claimCaseDbService.find( { blameRequestId: { $in: partyBlameIds } }, { lean: true }, ) : []; // Merge: owner claims + blame-resolved damaged claims + snapshot-party claims // snapshotClaims may include guilty-owner claims too — filter them out here. const snapshotDamagedClaims = (snapshotClaims as any[]).filter((c) => { if (!c?.owner?.userId) return true; // unknown owner: allow const ownerStr = String(c.owner.userId); // Exclude if the user IS the owner (already in ownerClaims) return !linkedUserIds.some((id) => id === ownerStr); }); const byId = new Map(); for (const c of [ ...(ownerClaims as any[]), ...(damagedDirectClaims as any[]), ...(damagedClaims as any[]), ...snapshotDamagedClaims, ]) { byId.set(String(c._id), c); } claims = [...byId.values()]; } const blameIdsForList = [ ...new Set( (claims as any[]) .map((c) => c.blameRequestId?.toString()) .filter((id): id is string => !!id), ), ]; const blamesForList = blameIdsForList.length > 0 ? ((await this.blameRequestDbService.find( { _id: { $in: blameIdsForList.map((id) => new Types.ObjectId(id)) } }, { lean: true, select: "type creationMethod" }, )) as any[]) : []; const blameByIdForList = new Map( blamesForList.map((b) => [String(b._id), b]), ); const list = (claims as any[]).map((c) => { const blameForItem = c.blameRequestId ? blameByIdForList.get(c.blameRequestId.toString()) : undefined; return { claimRequestId: c._id.toString(), publicId: c.publicId, requestNo: c.requestNo, status: c.status, claimStatus: c.claimStatus || "PENDING", currentStep: c.workflow?.currentStep || "", createdAt: c.createdAt, blameRequestId: c.blameRequestId?.toString(), blameType: blameForItem?.type ?? undefined, creationMethod: blameForItem?.creationMethod ?? undefined, }; }) as ClaimListItemV2Dto[]; const paged = applyListQueryV2( list, { publicId: (r) => r.publicId, createdAt: (r) => r.createdAt, requestNo: (r) => r.requestNo, status: (r) => r.status, searchExtras: (r) => [ r.claimRequestId, r.claimStatus, r.currentStep, r.blameRequestId, ].filter(Boolean) as string[], }, query, ); return { list: paged.list, total: paged.total, page: paged.page, limit: paged.limit, totalPages: paged.totalPages, }; } catch (error) { if (error instanceof HttpException) throw error; this.logger.error("getMyClaimsV2 failed", error); throw new InternalServerErrorException("Failed to get claims list"); } } /** * V2 API: Get claim details by ID (owner — or FIELD_EXPERT with enriched money/rating fields). * Owners receive `ownerGuidance` computed from workflow state. */ async getClaimDetailsV2( claimRequestId: string, currentUserId: string, actor?: { sub: string; role?: string }, ): Promise { try { const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim request not found"); } await this.assertActorCanViewClaimV2(claim, currentUserId, actor); const blameForDetail = claim.blameRequestId ? (( await this.blameRequestDbService.find( { _id: new Types.ObjectId(claim.blameRequestId.toString()) }, { lean: true, select: "type creationMethod" }, ) )[0] ?? null) : null; const hasCapture = (data: any, key: string) => data && (data instanceof Map ? data.get(key) : data[key]); const requiredDocs = claim.requiredDocuments as any; const requiredDocumentsStatus: Record< string, { uploaded: boolean; fileUrl?: string } > = {}; if (requiredDocs) { const keys = requiredDocs instanceof Map ? Array.from(requiredDocs.keys()) : Object.keys(requiredDocs); for (const k of keys) { const doc = requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k]; requiredDocumentsStatus[k] = { uploaded: !!doc?.uploaded, fileUrl: doc?.filePath ? buildFileLink(doc.filePath) : undefined, }; } } const carAnglesData = claim.media?.carAngles as any; const damagedPartsForAngles = claim.media?.damagedParts as any; const carAngles: Record = {}; for (const k of ["front", "back", "left", "right"]) { const cap = getClaimCarAngleCaptureBlob( carAnglesData, damagedPartsForAngles, k as ClaimCarAngleKey, ) as { url?: string; path?: string } | undefined; carAngles[k] = { captured: !!cap, url: resolveStoredFileUrl(cap), }; } const carTypeDetails = claim.vehicle?.carType as | ClaimVehicleTypeV2 | undefined; const selectedNormDetails = normalizeDamageSelectedParts( claim.damage?.selectedParts, carTypeDetails, (claim.damage as any)?.selectedOuterParts, ); const damagedPartsData = claim.media?.damagedParts as any; const resendPartKeys = normalizeResendPartKeys( claim.evaluation?.damageExpertResend?.resendCarParts, carTypeDetails, claim.damage?.selectedParts, ); const displayParts = [...selectedNormDetails]; const seenDetailKeys = new Set( selectedNormDetails.map((p) => partLookupKey(p)), ); for (const rk of resendPartKeys) { const extra = normalizeDamageSelectedParts( [rk], carTypeDetails, undefined, )[0] || { id: null, name: rk, side: "", label_fa: rk, catalogKey: rk, }; const k = partLookupKey(extra); if (!seenDetailKeys.has(k)) { displayParts.push(extra); seenDetailKeys.add(k); } } const damagedParts = buildEnrichedDamagedParts({ selectedParts: displayParts, damagedPartsData: damagedPartsData, evaluationBlock: (claim as any).evaluation, expertAddedParts: (claim.damage as any)?.expertAddedParts ?? [], getDamagedPartCaptureBlob, hasDamagedPartCapture, catalogLikeKeyFromPart, buildFileLink, }); const er = claim.evaluation?.damageExpertResend; const expertResend = er && (er.resendDescription || (er.resendDocuments?.length ?? 0) > 0 || (er.resendCarParts?.length ?? 0) > 0) ? { resendDescription: er.resendDescription, resendDocuments: normalizeResendDocumentKeys(er.resendDocuments), resendCarParts: mapExpertResendCarPartsForClient( er.resendCarParts, { carType: carTypeDetails, selectedParts: claim.damage?.selectedParts, damagedPartsData: claim.media?.damagedParts, buildUrl: (path) => buildFileLink(path), }, ), fulfilledAt: er.fulfilledAt, } : undefined; const maskSheba = (s?: string) => s ? s.replace(/^(.{4})(.*)(.{4})$/, "IR$1************$3") : undefined; const maskNationalCode = (s?: string) => s ? s.replace(/^(.{2})(.*)(.{2})$/, "$1******$3") : undefined; const isExpertViewer = actor?.role === RoleEnum.FIELD_EXPERT || actor?.role === RoleEnum.REGISTRAR; const ownerData = claim.owner ? { userId: claim.owner.userId?.toString(), clientId: claim.owner.clientId?.toString(), ...(claim.owner.userClientKey ? { userClientKey: claim.owner.userClientKey.toString() } : {}), fullName: claim.owner.fullName, } : undefined; const moneyForUser = claim.money ? { sheba: maskSheba(claim.money.sheba), nationalCodeOfOwner: maskNationalCode( claim.money.nationalCodeOfInsurer, ), } : undefined; const moneyForExpert = claim.money ? { sheba: claim.money.sheba, nationalCodeOfOwner: claim.money.nationalCodeOfInsurer, } : undefined; const mappedEvaluation = await this.mapEvaluationForClient( claim.evaluation, claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined, ); return { claimRequestId: claim._id.toString(), publicId: claim.publicId, requestNo: claim.requestNo, status: claim.status, claimStatus: claim.claimStatus || "PENDING", currentStep: claim.workflow?.currentStep || "", nextStep: claim.workflow?.nextStep, blameRequestId: claim.blameRequestId?.toString(), blameRequestNo: claim.blameRequestNo, blameType: (blameForDetail as any)?.type ?? undefined, creationMethod: (blameForDetail as any)?.creationMethod ?? undefined, ...(ownerData ? { owner: ownerData } : {}), vehicle: claim.vehicle, selectedParts: selectedNormDetails, otherParts: claim.damage?.otherParts, money: isExpertViewer ? moneyForExpert : moneyForUser, requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 ? requiredDocumentsStatus : undefined, carAngles, damagedParts, expertResend, fanavaran: fanavaranClaimReferences(claim), evaluation: mappedEvaluation ? { damageExpertReply: mappedEvaluation.damageExpertReply, damageExpertReplyFinal: mappedEvaluation.damageExpertReplyFinal, } : undefined, ...(!isExpertViewer ? { ownerGuidance: buildClaimDetailsV2OwnerGuidance( claim, claim._id.toString(), ), } : {}), ...(isExpertViewer ? { userRating: claim.userRating ? { progressSpeed: claim.userRating.progressSpeed, registrationEase: claim.userRating.registrationEase, overallEvaluation: claim.userRating.overallEvaluation, comment: claim.userRating.comment, createdAt: claim.userRating.createdAt, } : undefined, } : {}), createdAt: (claim as any).createdAt, updatedAt: (claim as any).updatedAt, }; } catch (error) { if (error instanceof HttpException) throw error; this.logger.error("getClaimDetailsV2 failed", error); throw new InternalServerErrorException("Failed to get claim details"); } } private async generateUniqueClaimNumber(): Promise { const prefix = "CL"; const randomPart = Math.floor(10000 + Math.random() * 90000); // 5 digits return `${prefix}${randomPart}`; } private async loadBlameForV3Claim(claimCase: any) { if (!claimCase?.blameRequestId) return null; return this.blameRequestDbService.findById( claimCase.blameRequestId.toString(), ); } private hasPartyInquiriesOnBlame( blame: any, role: "FIRST" | "SECOND", ): boolean { const step = role === "FIRST" ? WorkflowStep.FIRST_INITIAL_FORM : WorkflowStep.SECOND_INITIAL_FORM; return (blame.workflow?.completedSteps ?? []).includes(step); } private partyHasSignedOnBlame(blame: any, role: "FIRST" | "SECOND"): boolean { const party = (blame.parties ?? []).find((p: any) => p?.role === role); return !!party?.confirmation; } private assertV3ClaimDocumentsPhase( blame: any, options?: { skipAccidentFieldsCheck?: boolean }, ): void { if ( !options?.skipAccidentFieldsCheck && !(blame.expert?.decision as any)?.fields?.accidentWay ) { throw new BadRequestException( "Submit accident fields before uploading required documents.", ); } const isCarBody = blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY"; if (isCarBody) { if (!this.partyHasSignedOnBlame(blame, "FIRST")) { throw new BadRequestException( "Upload documents after the party has signed.", ); } return; } if ( !this.partyHasSignedOnBlame(blame, "FIRST") || !this.partyHasSignedOnBlame(blame, "SECOND") ) { throw new BadRequestException( "Both parties must sign before uploading required documents.", ); } } private assertV3ClaimPartsPhase(blame: any): void { if (!(blame.expert?.decision as any)?.fields?.accidentWay) { throw new BadRequestException( "Submit accident fields before part selection.", ); } const isCarBody = blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY"; if (isCarBody) { if (!this.partyHasSignedOnBlame(blame, "FIRST")) { throw new BadRequestException( "Part selection requires the party signature.", ); } return; } if ( !this.partyHasSignedOnBlame(blame, "FIRST") || !this.partyHasSignedOnBlame(blame, "SECOND") ) { throw new BadRequestException( "Both parties must sign before part selection.", ); } } private claimV3StepCompleted( claimCase: any, step: ClaimWorkflowStep, ): boolean { return (claimCase.workflow?.completedSteps ?? []).includes(step); } private assertV3ClaimWorkflowStep( claimCase: any, expected: ClaimWorkflowStep | ClaimWorkflowStep[], hint?: string, ): void { const allowed = Array.isArray(expected) ? expected : [expected]; const current = claimCase.workflow?.currentStep; if (!allowed.includes(current)) { throw new BadRequestException( hint ?? `Invalid workflow step. Expected ${allowed.join(" or ")}, but current step is ${current}`, ); } } private async advanceV3ClaimToOuterPartsIfReady( claimCase: any, blame: any, ): Promise { if ( claimCase.workflow?.currentStep === ClaimWorkflowStep.SELECT_OUTER_PARTS ) { return; } const step = claimCase.workflow?.currentStep; // UPLOAD_REQUIRED_DOCUMENTS: normal V3/V4 path after FileMaker docs. // WAITING_FOR_FILE_REVIEWER (claim status): same DB step but with the new // V4 claim status — FileReviewer is picking up the sealed file. // USER_SUBMISSION_COMPLETE: can occur when documents were uploaded via a // non-V3 path (e.g. generic v2 endpoint). Safe to advance as long as // accident fields are set (checked just below). // EXPERT_REVIEWING + EXPERT_DAMAGE_ASSESSMENT: FileReviewer re-entering // after a FileMaker rejection. The claim was locked back to // EXPERT_REVIEWING by the reviewer's lock call; the document phase was // already completed in the prior cycle so it's safe to advance directly // to part selection. const isFileReviewerReentry = claimCase.status === ClaimCaseStatus.EXPERT_REVIEWING && step === ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT && (blame as any)?.isMadeByFileMaker === true; const canAdvance = step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS || step === ClaimWorkflowStep.USER_SUBMISSION_COMPLETE || claimCase.status === ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER || isFileReviewerReentry; if (!canAdvance) { throw new BadRequestException( `Complete required documents before selecting outer parts. Current step: ${step}`, ); } if (!(blame.expert?.decision as any)?.fields?.accidentWay) { throw new BadRequestException( "Submit accident fields before selecting outer parts.", ); } await this.claimCaseDbService.findByIdAndUpdate(String(claimCase._id), { $set: { status: ClaimCaseStatus.SELECTING_OUTER_PARTS, claimStatus: ClaimStatus.PENDING, "workflow.currentStep": ClaimWorkflowStep.SELECT_OUTER_PARTS, "workflow.nextStep": ClaimWorkflowStep.SELECT_OTHER_PARTS, }, }); } private async assertV3InPersonClaim(claimCase: any) { const blame = await this.loadBlameForV3Claim(claimCase); if (!blame) { throw new BadRequestException( "V3 claim flow requires a linked blame file.", ); } if (blame.creationMethod !== CreationMethod.IN_PERSON) { throw new BadRequestException( "V3 claim flow is only for IN_PERSON blame files.", ); } if (!blame.expertInitiated) { throw new BadRequestException( "V3 claim flow requires an expert-initiated blame file.", ); } return blame; } private isV3InitialDocumentsPhase(claimCase: any): boolean { const step = claimCase.workflow?.currentStep; const capturePartDone = this.claimV3StepCompleted( claimCase, ClaimWorkflowStep.CAPTURE_PART_DAMAGES, ); return ( step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && !capturePartDone ); } private shapeCaptureRequirementsForV3( claimCase: any, blame: any, base: GetCaptureRequirementsV2ResponseDto, ): GetCaptureRequirementsV2ResponseDto { const skipMetalPlate = !!(blame as any)?.isMadeByFileMaker; // V4 = FileMaker flow without a secondary FileMaker approval step const isV4 = !!(blame as any)?.isMadeByFileMaker && !(claimCase as any)?.requiresFileMakerApproval; const isCarBody = blame?.type === BlameRequestType.CAR_BODY || (blame as any)?.type === "CAR_BODY"; // For V4/V5 files strip metal-plate keys from the base response entirely // so they never appear in any phase — the front-end should not show them. if (skipMetalPlate) { base = { ...base, requiredDocuments: base.requiredDocuments.filter( (d) => !OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(d.key as any), ), }; } const step = claimCase.workflow?.currentStep; const capturePartDone = this.claimV3StepCompleted( claimCase, ClaimWorkflowStep.CAPTURE_PART_DAMAGES, ); if (this.isV3InitialDocumentsPhase(claimCase)) { const preCaptureDocs = base.requiredDocuments.filter( (d) => !d.preferUploadDuringCapture, ); const remaining = preCaptureDocs.filter((d) => !d.uploaded).length; return { ...base, requiredDocuments: preCaptureDocs, captureSequencePhase: "parts", captureSequenceHint: "Upload initial required documents (licenses and car cards). Chassis, engine, and metal plate photos come after part selection, damaged-part photos, and car angles.", damagedParts: [], carAngles: base.carAngles.map((a) => ({ ...a, captured: false })), totalRemaining: remaining, progress: { ...base.progress, documentsTotal: preCaptureDocs.length, documentsUploaded: preCaptureDocs.filter((d) => d.uploaded).length, partsCaptured: 0, partsTotal: 0, anglesCaptured: 0, capturePhaseDocsRemaining: 0, postCaptureDocumentsRemaining: remaining, }, }; } if (step === ClaimWorkflowStep.SELECT_OUTER_PARTS) { return { ...base, requiredDocuments: base.requiredDocuments.filter( (d) => !d.preferUploadDuringCapture, ), captureSequencePhase: "parts", captureSequenceHint: "Select outer damaged parts before capture.", totalRemaining: base.damagedParts.filter((p) => !p.captured).length, }; } if (step === ClaimWorkflowStep.SELECT_OTHER_PARTS) { return { ...base, requiredDocuments: base.requiredDocuments.filter( (d) => !d.preferUploadDuringCapture, ), captureSequencePhase: "parts", captureSequenceHint: "Select other damaged parts (and car green card when required) before capture.", }; } if (step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES) { const capturePhaseDocs = base.requiredDocuments.filter( (d) => d.preferUploadDuringCapture && !(skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(d.key as any)), ); // V4 THIRD_PARTY only: guilty_damage_area is uploaded via upload-document // after all car angles are captured (last item in the documents phase). // It also appears in damagedParts so the expert sees it labelled as a // damaged-part capture. if (isV4 && !isCarBody) { const guiltyAreaUploaded = this.isRequiredDocumentUploadedOnClaim( claimCase, GUILTY_DAMAGE_AREA_DOC_KEY, ); const guiltyAreaDoc = { key: GUILTY_DAMAGE_AREA_DOC_KEY, label_fa: "تصویر نقطه آسیب‌دیده مقصر", label_en: "Guilty Car Damaged Area", category: "guilty_party", uploaded: guiltyAreaUploaded, preferUploadDuringCapture: true, }; const captureProgress = getClaimCaptureProgress(claimCase, { skipMetalPlate, includeGuiltyDamageArea: true, }); return { ...base, requiredDocuments: [...capturePhaseDocs, guiltyAreaDoc], totalRemaining: captureProgress.capturePhaseDocsRemaining, progress: { ...base.progress, capturePhaseDocsRemaining: captureProgress.capturePhaseDocsRemaining, }, }; } return { ...base, requiredDocuments: capturePhaseDocs, totalRemaining: base.progress.capturePhaseDocsRemaining, }; } if ( step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && capturePartDone ) { // car-capture not yet uploaded: next step is the walk-around video. return { ...base, requiredDocuments: [], captureSequencePhase: "complete", captureSequenceHint: "Upload the walk-around car video (car-capture), then the blame accident video.", totalRemaining: claimCase.media?.videoCaptureId ? 0 : 1, }; } // V3 FIELD_EXPERT only: car-capture is done (step advanced to USER_SUBMISSION_COMPLETE // in setVideoCaptureV3). The one remaining action is the blame accident video. if ( step === ClaimWorkflowStep.USER_SUBMISSION_COMPLETE && !(blame as any).isMadeByFileMaker ) { return { ...base, requiredDocuments: [], captureSequencePhase: "complete", captureSequenceHint: "Walk-around video uploaded. Upload the blame accident video to finalise.", totalRemaining: 0, }; } return base; } async uploadRequiredDocumentV3( claimRequestId: string, body: UploadRequiredDocumentV2Dto, file: Express.Multer.File, currentUserId: string, actor?: { sub: string; role?: string }, ): Promise { const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException( `Claim case with ID ${claimRequestId} not found`, ); } const blame = await this.assertV3InPersonClaim(claimCase); const skipMetalPlate = !!(blame as any).isMadeByFileMaker; const isV4FileMaker = !!(blame as any)?.isMadeByFileMaker && !(claimCase as any)?.requiresFileMakerApproval; const isCarBodyBlame = blame?.type === BlameRequestType.CAR_BODY || (blame as any)?.type === "CAR_BODY"; // guilty_damage_area is an extra capture-phase doc key for V4 THIRD_PARTY const isGuiltyDamageAreaDoc = (body.documentKey as string) === GUILTY_DAMAGE_AREA_DOC_KEY && isV4FileMaker && !isCarBodyBlame; const isCaptureDoc = isCapturePhaseDamagedPartyDocKey(body.documentKey) || isGuiltyDamageAreaDoc; if (isCaptureDoc) { this.assertV3ClaimPartsPhase(blame); this.assertV3ClaimWorkflowStep( claimCase, ClaimWorkflowStep.CAPTURE_PART_DAMAGES, "Upload chassis and engine photos during capture after damaged-part photos and car angles.", ); if ( !this.claimV3StepCompleted( claimCase, ClaimWorkflowStep.SELECT_OTHER_PARTS, ) ) { throw new BadRequestException( "Complete outer and other part selection before capture-phase vehicle documents.", ); } const captureProgress = getClaimCaptureProgress(claimCase, { skipMetalPlate, includeGuiltyDamageArea: isGuiltyDamageAreaDoc, }); if (!captureProgress.partsComplete) { throw new BadRequestException( "Upload photos for all selected damaged parts before chassis or engine photos.", ); } if (!captureProgress.anglesComplete) { throw new BadRequestException( "Capture all four car angles before chassis or engine photos.", ); } } else { this.assertV3ClaimDocumentsPhase(blame, { skipAccidentFieldsCheck: actor?.role === "file_maker", }); if (!this.isV3InitialDocumentsPhase(claimCase)) { throw new BadRequestException( `Upload initial required documents during ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS} before part selection. Current step: ${claimCase.workflow?.currentStep}`, ); } } // For V4 THIRD_PARTY files, guilty_damage_area must be counted in the // capture-phase doc total for ALL capture uploads (not just the guilty_damage_area // upload itself) so that the CAPTURE_PART_DAMAGES step is not marked complete // until all three standard docs AND the guilty area photo are uploaded. const includeGuiltyDamageArea = isV4FileMaker && !isCarBodyBlame; return this.uploadRequiredDocumentV2( claimRequestId, body, file, currentUserId, actor, { v3InPersonFlow: true, skipMetalPlate, requiresFileMakerApproval: !!(claimCase as any).requiresFileMakerApproval, includeGuiltyDamageArea, }, ); } async getCaptureRequirementsV3( claimRequestId: string, currentUserId: string, actor?: { sub: string; role?: string }, ): Promise { const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException( `Claim case with ID ${claimRequestId} not found`, ); } const blame = await this.assertV3InPersonClaim(claimCase); if (this.isV3InitialDocumentsPhase(claimCase)) { this.assertV3ClaimDocumentsPhase(blame, { skipAccidentFieldsCheck: actor?.role === "file_maker", }); } else if ( claimCase.workflow?.currentStep === ClaimWorkflowStep.CAPTURE_PART_DAMAGES ) { this.assertV3ClaimPartsPhase(blame); } const base = await this.getCaptureRequirementsV2( claimRequestId, currentUserId, actor, ); return this.shapeCaptureRequirementsForV3(claimCase, blame, base); } async selectOuterPartsV3( claimRequestId: string, body: SelectOuterPartsV2Dto, currentUserId: string, actor?: { sub: string; role?: string }, ): Promise { const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException( `Claim case with ID ${claimRequestId} not found`, ); } const blame = await this.assertV3InPersonClaim(claimCase); this.assertV3ClaimPartsPhase(blame); await this.advanceV3ClaimToOuterPartsIfReady(claimCase, blame); const refreshed = await this.claimCaseDbService.findById(claimRequestId); this.assertV3ClaimWorkflowStep( refreshed, ClaimWorkflowStep.SELECT_OUTER_PARTS, "Select outer parts after accident fields and required documents.", ); const result = await this.selectOuterPartsV2(claimRequestId, body, currentUserId, actor); return result; } async selectOtherPartsV3( claimRequestId: string, body: SelectOtherPartsV3Dto, currentUserId: string, actor?: { sub: string; role?: string }, ): Promise { const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException( `Claim case with ID ${claimRequestId} not found`, ); } const blame = await this.assertV3InPersonClaim(claimCase); this.assertV3ClaimPartsPhase(blame); this.assertV3ClaimWorkflowStep( claimCase, ClaimWorkflowStep.SELECT_OTHER_PARTS, "Select outer parts before other parts.", ); if ( !this.claimV3StepCompleted( claimCase, ClaimWorkflowStep.SELECT_OUTER_PARTS, ) ) { throw new BadRequestException( "Complete outer part selection before selecting other parts.", ); } let otherParts = Array.isArray((body as any)?.otherParts) ? (body as any).otherParts : []; if ( !Array.isArray((body as any)?.otherParts) && typeof (body as any)?.otherParts === "string" ) { try { const parsed = JSON.parse((body as any).otherParts); otherParts = Array.isArray(parsed) ? parsed : []; } catch { otherParts = []; } } const shebaNumber = claimCase.money?.sheba; const nationalCode = claimCase.money?.nationalCodeOfInsurer; const updatePayload: any = { "damage.otherParts": otherParts.length > 0 ? otherParts : undefined, status: ClaimCaseStatus.CAPTURING_PART_DAMAGES, "workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES, "workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, $push: { "workflow.completedSteps": ClaimWorkflowStep.SELECT_OTHER_PARTS, history: { type: "V3_STEP_COMPLETED", actor: { actorId: new Types.ObjectId(currentUserId), actorName: claimCase.owner?.fullName || "Actor", actorType: actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert", }, timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS, otherParts, v3SelectionOnly: true, }, }, }, }; const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate( claimRequestId, updatePayload, ); if (!updatedClaim) { throw new InternalServerErrorException("Failed to update claim case"); } return { claimRequestId: updatedClaim._id.toString(), publicId: updatedClaim.publicId, otherParts, ...(shebaNumber ? { shebaNumber: String(shebaNumber).replace( /^(.{4})(.*)(.{4})$/, "IR$1************$3", ), } : {}), ...(nationalCode ? { nationalCodeOfOwner: String(nationalCode).replace( /^(.{2})(.*)(.{2})$/, "$1******$3", ), } : {}), currentStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, nextStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, message: "Other parts saved. Proceed to damaged-part photos and car angles (capture-part).", }; } async setVideoCaptureV3( claimRequestId: string, fileDetail: Express.Multer.File, currentUserId: string, actor?: { sub: string; role?: string }, ): Promise { const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException( `Claim case with ID ${claimRequestId} not found`, ); } const blame = await this.assertV3InPersonClaim(claimCase); const skipMetalPlate = !!(blame as any).isMadeByFileMaker; if (!fileDetail) { throw new BadRequestException("Video file is required."); } if (claimCase.media?.videoCaptureId) { // FILE_REVIEWER re-reviewing after a FileMaker rejection: allow replacing // the previous walk-around video by clearing the stale id first so that // setVideoCaptureV2's own conflict check doesn't block the upload. if (actor?.role === RoleEnum.FILE_REVIEWER) { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $unset: { "media.videoCaptureId": "" }, }); claimCase.media = { ...(claimCase.media ?? {}), videoCaptureId: undefined } as any; } else { throw new ConflictException( "A walk-around video has already been uploaded for this claim.", ); } } if ( claimCase.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS ) { throw new BadRequestException( `Complete capture-phase documents (chassis/engine) first. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, but current step is ${claimCase.workflow?.currentStep}`, ); } const captureProgress = getClaimCaptureProgress(claimCase, { skipMetalPlate }); if (!captureProgress.partsComplete) { throw new BadRequestException( "Upload photos for all selected damaged parts before the walk-around video.", ); } if (!captureProgress.anglesComplete) { throw new BadRequestException( "Capture all four car angles before the walk-around video.", ); } if (!isClaimCaptureStepComplete(claimCase, { skipMetalPlate })) { throw new BadRequestException( skipMetalPlate ? "Upload capture-phase vehicle documents (chassis and engine) before the walk-around video." : "Upload capture-phase vehicle documents (chassis, engine, metal plate) before the walk-around video.", ); } // Store the walk-around video (shared V2 logic). const result = await this.setVideoCaptureV2( claimRequestId, fileDetail, currentUserId, actor, ); // V4/V5 only (isMadeByFileMaker): car-capture is the FINAL FileReviewer step. // For the v3 mirror (FIELD_EXPERT IN_PERSON), car-capture is penultimate — // expertUploadBlameVideoV3 finalises the blame with the accident video next. // Advance the workflow step so that if the expert exits and reopens the claim // the system correctly resumes at upload-video rather than replaying car-capture. if (!(blame as any).isMadeByFileMaker) { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { "workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, "workflow.nextStep": undefined, }, $push: { "workflow.completedSteps": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, history: { type: "STEP_COMPLETED", actor: { actorId: new Types.ObjectId(currentUserId), actorName: claimCase.owner?.fullName || "User", actorType: "field_expert", }, timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, description: "V3 walk-around video uploaded. Upload blame accident video to finalise.", v3InPersonFlow: true, }, }, }, }); } else if ((blame as any).isMadeByFileMaker) { // Both V4 and V5 land at WAITING_FOR_DAMAGE_EXPERT here. // For V5 (requiresFileMakerApproval=true), autoSubmitToFanavaranV2OnClaimCompleted // intercepts COMPLETED → WAITING_FOR_FILE_MAKER_APPROVAL after expert review. await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, claimStatus: ClaimStatus.PENDING, "workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, }, $push: { "workflow.completedSteps": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, history: { type: "STEP_COMPLETED", actor: { actorId: new Types.ObjectId(currentUserId), actorName: claimCase.owner?.fullName || "User", actorType: actor?.role === RoleEnum.FILE_REVIEWER ? "file_reviewer" : "user", }, timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, description: "V4/V5 walk-around video uploaded. Claim ready for damage expert review.", v3InPersonFlow: true, }, }, }, }); // Mark the linked blame file as COMPLETED. if (claimCase.blameRequestId) { await this.blameRequestDbService.findByIdAndUpdate( String(claimCase.blameRequestId), { $set: { status: BlameCaseStatus.COMPLETED } }, ); } } return result; } async capturePartV3( claimRequestId: string, body: CapturePartV2Dto, file: Express.Multer.File, currentUserId: string, actor?: { sub: string; role?: string }, ): Promise { const claimCase = await this.claimCaseDbService.findById(claimRequestId); if (!claimCase) { throw new NotFoundException( `Claim case with ID ${claimRequestId} not found`, ); } const blame = await this.assertV3InPersonClaim(claimCase); this.assertV3ClaimPartsPhase(blame); this.assertV3ClaimWorkflowStep( claimCase, ClaimWorkflowStep.CAPTURE_PART_DAMAGES, "Select other parts before capturing damaged parts and angles.", ); if ( !this.claimV3StepCompleted( claimCase, ClaimWorkflowStep.SELECT_OTHER_PARTS, ) ) { throw new BadRequestException( "Complete other part selection before capture.", ); } return this.capturePartV2(claimRequestId, body, file, currentUserId, actor); } }