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 { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { CreateClaimFromBlameResponseDto } from "./dto/create-claim-v2.dto"; import { 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, 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 { 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 { 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, OUTER_PARTS_BY_CAR_TYPE, 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 { CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS, capturePhaseSequenceMessage, getClaimCaptureProgress, 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, } from "src/core/config/fanavaran-client.config"; import { FanavaranAuditService } from "src/fanavaran/fanavaran-audit.service"; import { FanavaranLookupService } from "src/fanavaran/fanavaran-lookup.service"; import { FANAVARAN_REMOTE_LOOKUPS } from "src/fanavaran/fanavaran-lookup.config"; import type { FanavaranAuditSession } from "src/fanavaran/fanavaran-audit.types"; import { FanavaranAuditSource, FanavaranAuditStatus, FanavaranAuditStep, } from "src/fanavaran/schema/fanavaran-audit-log.schema"; import { selectLatestActiveFanavaranPolicy } from "./fanavaran-policy-selection"; 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; } const FANAVARAN_ACCIDENT_LOCATION_ADDRESS = "استان تهران شهر تهران"; const FANAVARAN_DEFAULT_ACCIDENT_CAUSE_ID = 6; const FANAVARAN_DEFAULT_ACCIDENT_LEVEL = 5456; const FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT = 1000; /** 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); // Authentication URLs and credentials (same as lookups service) private readonly GET_APP_TOKEN_URL = "https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken"; private readonly LOGIN_URL = "https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login"; private readonly FANAVARAN_SUBMIT_URL = "https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/third-party-car-financial-claims"; // 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 private readonly CORP_ID = "3539"; private readonly CONTRACT_ID = "263"; private readonly LOCATION = "119"; private readonly PARSIAN_FANAVARAN_CONFIG = { appName: "ParsianService", secret: "P@r30@n$erv!ce", username: "ParsianServiceUser", password: "P@r30@n123", corpId: "543", contractId: "28", location: "210050", } as const; /** Tejaratno hardcoded Fanavaran lookup defaults (legacy). */ private readonly TEJARATNO_FANAVARAN_DEFAULTS = { AccidentCityId: 701, AccidentReportTypeId: 155, AccidentVehicleUsedId: 1, ClaimExpertId: 4543092, CompensationReferenceId: 167, CulpritLicenceTypeId: 2, CulpritTypeId: 337, } as const; private readonly MAP_IR_API_KEY = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2In0.eyJhdWQiOiIyMTcxOCIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2IiwiaWF0IjoxNjgwNjA4NTkxLCJuYmYiOjE2ODA2MDg1OTEsImV4cCI6MTY4MzIwMDU5MSwic3ViIjoiIiwic2NvcGVzIjpbImJhc2ljIl19.rTviLd8b5yTHUDa3ODZyva593eMnL0d3XPg3sKkZxMOf_jNIH6lFQyIfbId-wsd1EAdsOdsL3CME_Y8t332PWJbxMNgnEq4Rf2IkClkvkSx6Sb5_4bmlhBM75zw2SmccvgbFUn4xkTOw0FT4vABC2Y3-MKctjMpmO8QOrVULSKt4psrmQhr7hBu7YRDnAAEc6muZ1VpRvdB1kqNKddoSIrfDaq6aDRJ-BNbGRAaFFvP_kH4cgSCKV4dU0TknL3mRKUiVy6_TDkjtzAN8fE2wsdvNo2pGTJPzKFsR2ipgGNTvB__g3bOnVpKsgFXPBH0e_Qa7ff1tZ3VGWy3jRNh9Lg"; constructor( private readonly blameDbService: RequestManagementDbService, private readonly claimDbService: ClaimRequestManagementDbService, 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 fanavaranLookupService: FanavaranLookupService, private readonly fileMakerDbService: FileMakerDbService, private readonly fieldExpertDbService: FieldExpertDbService, ) {} 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, ): boolean { for (const k of this.v3PreCaptureDocumentKeys(isCarBody)) { 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, ): number { let n = 0; for (const k of this.v3PreCaptureDocumentKeys(isCarBody)) { 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 = String(alphaRaw || "").trim(); 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)) && String(p.centerAlphabet || "").trim() ) { return { leftDigits: Number(p.leftDigits), centerAlphabet: String(p.centerAlphabet).trim(), 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 delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Outer-parts catalog returned to clients (user app + expert panel). * * The shape intentionally mirrors how items are persisted under * `damage.selectedParts` so the front-end can match catalog rows to the * stored selection by `id` / `catalogKey` without renaming fields: * * { id, name, side, label_fa, catalogKey, carType } * * - `name` is side-agnostic (`backWheel`, not `left_backWheel`). * - `label_fa` is disambiguated with the side in parentheses when multiple * catalog rows share the same Farsi title (e.g. `چرخ عقب (چپ)`). * - `catalogKey` keeps the original full key (`left_backWheel`) for clients * that already address parts by it. */ getOuterPartsCatalogV2( carType?: ClaimVehicleTypeV2, ): (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] { const buildForType = ( type: ClaimVehicleTypeV2, items: OuterPartCatalogItem[], ): (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] => items.map((p) => ({ ...catalogItemToSelectedPart(p, items), carType: type, })); if (carType) { const items = OUTER_PARTS_BY_CAR_TYPE[carType] || []; return buildForType(carType, items); } const out: (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] = []; for (const [type, items] of Object.entries(OUTER_PARTS_BY_CAR_TYPE)) { out.push(...buildForType(type as ClaimVehicleTypeV2, items)); } return out.sort((a, b) => { if (a.carType === b.carType) return (a.id ?? 0) - (b.id ?? 0); return String(a.carType).localeCompare(String(b.carType)); }); } /** * Internal raw catalog (kept in the original `OuterPartCatalogItem` shape so * existing internal code that indexes by `key`/`titleFa` keeps working). * Public API consumers should use `getOuterPartsCatalogV2` instead. */ private getOuterPartsRawCatalog( carType?: ClaimVehicleTypeV2, ): OuterPartCatalogItem[] { if (carType) { return (OUTER_PARTS_BY_CAR_TYPE[carType] || []).map((p) => ({ ...p, carType, })); } const out: OuterPartCatalogItem[] = []; for (const [type, items] of Object.entries(OUTER_PARTS_BY_CAR_TYPE)) { const t = type as ClaimVehicleTypeV2; for (const p of items) { out.push({ ...p, carType: t }); } } return out; } 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 (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.message}`, error.stack, ); // 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.message}`, ); // 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.message}`, error.stack, ); 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.message}`, error.stack, ); 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.message}`, error.stack, ); // 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) { this.logger.error(`[ERROR] Database update error for part ${partId}:`); this.logger.error(`[ERROR] ${dbError.message}`); this.logger.error(`[ERROR] Stack: ${dbError.stack}`); throw new HttpException( `Database update failed: ${dbError.message}`, 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.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.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 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.stack, ); 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.message, err.status); } } 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.message, err.status); } } 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, branchId: string, actorDetail: any) { const request = await this.claimRequestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Claim not found"); } const branch = await this.branchDbService.findById(branchId); if (!branch) { throw new NotFoundException(`Branch with ID ${branchId} not found.`); } if (String(request.userClientKey) !== String(branch.clientKey)) { throw new ForbiddenException( "This branch does not belong to the insurer of this claim.", ); } const updated = await this.claimRequestManagementDbService.findAndUpdate( requestId, { claimStatus: ReqClaimStatus.InPersonVisit, visitLocation: `Branch ${branch.name} at ${branch.address}`, }, ); 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; } /** Branch ids referenced on expert-priced parts (`daghi` object with `branchId`). */ private collectBranchIdsFromClaimExpertReply(reply: { parts?: unknown[]; }): Set { const ids = new Set(); const parts = reply?.parts; if (!Array.isArray(parts)) { return ids; } for (const p of parts) { const d = (p as { daghi?: unknown })?.daghi; if (d && typeof d === "object" && d !== null && "branchId" in d) { const bid = (d as { branchId?: unknown }).branchId; if (bid != null && Types.ObjectId.isValid(String(bid))) { ids.add(String(bid)); } } } return ids; } private getTejaratnoFanavaranConfig() { return { appName: this.APP_NAME, secret: this.SECRET, username: this.USERNAME, password: this.PASSWORD, corpId: this.CORP_ID, contractId: this.CONTRACT_ID, location: this.LOCATION, }; } private 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"; result.CulpritLicenceNo = "1124242"; 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; 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) { throw new BadRequestException( `${input.logPrefix} PolicyId is required for Fanavaran submit. Policy inquiry returned no valid policy; contact the administrator.`, ); } result.PolicyId = policyId; 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 asNumber = Number(raw); if (Number.isFinite(asNumber)) return asNumber; return FANAVARAN_PLATE_LETTER_CODE[raw] ?? null; } 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}`; } 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 parseJalaliBirthday( birthday: string | number | null | undefined, ): { year: number; month: number; day: number } | null { if (birthday == null) return null; const str = String(birthday).trim(); // Compact format: "13640610" → year=1364, month=06, day=10 if (/^\d{8}$/.test(str)) { const year = parseInt(str.slice(0, 4), 10); const month = parseInt(str.slice(4, 6), 10); const day = parseInt(str.slice(6, 8), 10); if (isNaN(year) || isNaN(month) || isNaN(day)) { this.logger.warn( `parseJalaliBirthday: failed to parse compact format "${str}" - year=${year}, month=${month}, day=${day}`, ); return null; } return { year, month, day }; } // Delimited formats: "1364/06/10" or "1364-06-10" const parts = str.split(/[/\-]/); if (parts.length < 3) { this.logger.warn( `parseJalaliBirthday: insufficient parts in "${str}" - got ${parts.length} parts`, ); return null; } const year = parseInt(parts[0], 10); const month = parseInt(parts[1], 10); const day = parseInt(parts[2], 10); if (isNaN(year) || isNaN(month) || isNaN(day)) { this.logger.warn( `parseJalaliBirthday: failed to parse delimited format "${str}" - year=${year}, month=${month}, day=${day}`, ); return null; } return { year, month, day }; } private async resolveDriverFanavaranId( clientKey: FanavaranClientKey, nationalCode: string | null | undefined, driverBirthday: string | 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, }, )) as Array<{ Id: number; RoleId: number; NationalCode: string; Name?: string; LastName?: string; }>; this.logger.log( `[resolveDriverFanavaranId] RESPONSE: ${JSON.stringify(response)}`, ); if (!Array.isArray(response) || response.length === 0) { this.logger.warn( `[resolveDriverFanavaranId] EMPTY response for nationalCode=${nationalCode}`, ); return null; } const match = response.find((entry) => entry.RoleId === targetRoleId); if (match) { this.logger.log( `[resolveDriverFanavaranId] MATCH: Id=${match.Id} RoleId=${match.RoleId} name=${match.Name} ${match.LastName} for nationalCode=${nationalCode}`, ); return match.Id; } this.logger.warn( `[resolveDriverFanavaranId] NO MATCH for targetRoleId=${targetRoleId} (driverIsInsurer=${driverIsInsurer}) nationalCode=${nationalCode}. ` + `Available entries: ${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; }; }): Promise> { const plate = this.resolveOwnershipPlateForClaim( input.claimCase, input.blameCase, ); const damagedParty = Array.isArray(input.blameCase?.parties) ? input.blameCase.parties.find( (party: any) => String(party?.person?.userId ?? "") === String(input.claimCase?.owner?.userId ?? ""), ) : null; const person = damagedParty?.person ?? {}; const vehicle = damagedParty?.vehicle ?? {}; const insurance = damagedParty?.insurance ?? {}; const inquiryMapped = vehicle?.inquiry?.mapped ?? {}; const inquiryRaw = vehicle?.inquiry?.raw ?? {}; this.logger.log( `[buildFanavaranDamageCasePayload] damagedParty resolved: userId=${damagedParty?.person?.userId ?? "NONE"}, ` + `nationalCodeOfDriver=${person.nationalCodeOfDriver ?? "MISSING"}, ` + `driverBirthday=${person.driverBirthday ?? "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"}`, ); } const guiltyUserId = input.blameCase?.expertSubmitReplyFinal?.guiltyUserId ?? input.blameCase?.expertSubmitReply?.guiltyUserId; const blamedParty = guiltyUserId && Array.isArray(input.blameCase?.parties) ? input.blameCase.parties.find( (party: any) => String(party?.person?.userId ?? "") === String(guiltyUserId), ) : null; // Get PolicyCINumber from damaged party's inquiry (mapped or raw), not blamed party const policyCINumber = inquiryMapped.ThirdPolicyCode ?? inquiryRaw.ThirdPolicyCode ?? null; const carType = input.claimCase?.vehicle?.carType as string | undefined; const vehicleKindId = await this.resolveVehicleKindId( input.clientKey, carType, ); // Check cache first — person.fanavaranDriverId may already be persisted from a prior lookup let driverFanavaranId = person.fanavaranDriverId ?? null; if (driverFanavaranId) { this.logger.log( `[buildFanavaranDamageCasePayload] Using CACHED DriverId=${driverFanavaranId} from person.fanavaranDriverId`, ); } else { driverFanavaranId = await this.resolveDriverFanavaranId( input.clientKey, person.nationalCodeOfDriver, person.driverBirthday, person.driverIsInsurer, ); // Persist the resolved ID back to the blame case party for future use if (driverFanavaranId && input.blameCase?._id && damagedParty) { const partyIndex = input.blameCase.parties.findIndex( (p: any) => String(p?.person?.userId ?? "") === String(input.claimCase?.owner?.userId ?? ""), ); if (partyIndex >= 0) { await this.blameRequestDbService.findByIdAndUpdate( String(input.blameCase._id), { $set: { [`parties.${partyIndex}.person.fanavaranDriverId`]: driverFanavaranId } }, ); this.logger.log( `[buildFanavaranDamageCasePayload] CACHED DriverId=${driverFanavaranId} on blameCase=${input.blameCase._id} party[${partyIndex}]`, ); } } } this.logger.log( `[buildFanavaranDamageCasePayload] Final DriverId=${driverFanavaranId ?? "NULL"} for nationalCodeOfDriver=${person.nationalCodeOfDriver ?? "MISSING"}`, ); return { BeginDate: insurance.startDate ?? null, BuiltYear: Number(inquiryMapped.ModelField ?? inquiryMapped.ModelCii) || null, ChassisNo: inquiryMapped.ChassisNumberField ?? inquiryMapped.ShsNum ?? inquiryRaw.ChassisNumberField ?? inquiryRaw.ShsNum ?? null, DmgHistoryStatus: input.defaults.DmgHistoryStatus, Desc: this.formatFanavaranSelectedPartsDesc(input.selectedParts), DmgCaseTypeId: input.defaults.DmgCaseTypeId, DriverId: driverFanavaranId ?? null, EndDate: insurance.endDate ?? null, EstimateAmount: FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT, FaultPercent: input.defaults.FaultPercent, InsuranceCorpId: await this.fanavaranLookupService.resolveInsuranceCorpId(input.clientKey), DriverIsOwner: person.driverIsInsurer ? 1 : input.defaults.DriverIsOwner, LicenceCityId: null, LicenceCountryId: null, LicenceForeignCityName: null, LicenceIssuDate: person.driverBirthday ?? "1394/10/13", LicenceNo: person.driverLicense ?? "1124242", LicenceTypeId: input.defaults.CulpritLicenceTypeId, MotorNo: inquiryMapped.MtrNum ?? inquiryMapped.EngineNumberField ?? inquiryRaw.MtrNum ?? inquiryRaw.EngineNumberField ?? null, OwnerId: null, PlaqueCityId: null, PlaqueKindId: plate ? input.defaults.PlaqueKindId : null, PlaqueLeftNo: plate ? String(plate.leftDigits) : null, PlaqueMiddleCodeId: this.getFanavaranPlateMiddleCode( plate?.centerAlphabet, ), PlaqueNo: this.formatFanavaranPlateNo(plate), PlaqueRightNo: plate ? String(plate.centerDigits) : null, PlaqueSampleId: plate ? input.defaults.PlaqueSampleId : null, PlaqueSerial: plate ? String(plate.ir) : null, PolicyNo: insurance.policyNumber ?? null, PreviousPolicyEndDate: insurance.endDate ?? "", VehicleKindId: vehicleKindId, VIN: inquiryMapped.VIN ?? inquiryRaw.VIN ?? null, AccidentVehicleUsedId: input.defaults.AccidentVehicleUsedId, PolicyCINumber: policyCINumber, }; } /** * 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; } } /** * Step 1: Get appToken from GetAppToken API */ private async getAppToken( config: { appName: string; secret: string; } = this.getTejaratnoFanavaranConfig(), auditSession?: FanavaranAuditSession, ): Promise { const startedAt = Date.now(); if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.GET_APP_TOKEN, status: FanavaranAuditStatus.STARTED, requestUrl: this.GET_APP_TOKEN_URL, requestMeta: { appName: config.appName }, }); } try { const requestHeaders: any = { appname: config.appName, secret: config.secret, "Content-Length": "0", }; delete requestHeaders["Content-Type"]; const response = await firstValueFrom( this.httpService.post(this.GET_APP_TOKEN_URL, "", { headers: requestHeaders, transformRequest: [ (data, headers) => { if (headers) { delete headers["Content-Type"]; delete headers["content-type"]; } return data; }, ], }), ); const appToken = response.headers.apptoken || response.headers.appToken || response.headers["apptoken"] || response.headers["appToken"]; if (!appToken) { this.logger.error("Failed to get appToken from response headers"); throw new BadGatewayException( "Failed to get appToken from response headers", ); } if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.GET_APP_TOKEN, status: FanavaranAuditStatus.SUCCESS, requestUrl: this.GET_APP_TOKEN_URL, httpStatus: response.status, responseMeta: { hasAppToken: true }, durationMs: Date.now() - startedAt, }); } this.logger.log(`Successfully obtained appToken`); return appToken; } catch (error) { if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.GET_APP_TOKEN, status: FanavaranAuditStatus.FAILURE, requestUrl: this.GET_APP_TOKEN_URL, httpStatus: isAxiosError(error) ? error.response?.status : undefined, errorMessage: this.fanavaranAuditService.extractErrorMessage(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, }); } this.logger.error("Failed to get appToken", error); if (isAxiosError(error)) { const errorMessage = error.response?.data?.Message || error.response?.data?.message || error.response?.data || error.message || "Failed to get appToken from external API"; throw new BadGatewayException( this.fanavaranAuditService.formatErrorWithTrackingCode( String(errorMessage), auditSession?.trackingCode, ), ); } throw new BadGatewayException( this.fanavaranAuditService.formatErrorWithTrackingCode( "Failed to get appToken from external API", auditSession?.trackingCode, ), ); } } /** * Step 2: Login to get authenticationToken */ private async login( appToken: string, config: { username: string; password: string; } = this.getTejaratnoFanavaranConfig(), auditSession?: FanavaranAuditSession, ): Promise { const startedAt = Date.now(); if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.LOGIN, status: FanavaranAuditStatus.STARTED, requestUrl: this.LOGIN_URL, requestMeta: { userName: config.username }, }); } try { const requestHeaders: any = { appToken: appToken, userName: config.username, password: config.password, "Content-Length": "0", }; delete requestHeaders["Content-Type"]; const response = await firstValueFrom( this.httpService.post(this.LOGIN_URL, "", { headers: requestHeaders, transformRequest: [ (data, headers) => { if (headers) { delete headers["Content-Type"]; delete headers["content-type"]; } return data; }, ], }), ); const authenticationToken = response.headers.authenticationtoken || response.headers.authenticationToken || response.headers["authenticationtoken"] || response.headers["authenticationToken"] || response.data?.authenticationtoken || response.data?.authenticationToken || response.data?.authentication_token; if (!authenticationToken) { this.logger.error("Failed to get authenticationToken from response"); throw new BadGatewayException( "Failed to get authenticationToken from response", ); } if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.LOGIN, status: FanavaranAuditStatus.SUCCESS, requestUrl: this.LOGIN_URL, httpStatus: response.status, responseMeta: { hasAuthenticationToken: true }, durationMs: Date.now() - startedAt, }); } this.logger.log(`Successfully obtained authenticationToken`); return authenticationToken; } catch (error) { if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.LOGIN, status: FanavaranAuditStatus.FAILURE, requestUrl: this.LOGIN_URL, httpStatus: isAxiosError(error) ? error.response?.status : undefined, errorMessage: this.fanavaranAuditService.extractErrorMessage(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, }); } this.logger.error("Failed to login", error); if (isAxiosError(error)) { const errorMessage = error.response?.data?.Message || error.response?.data?.message || error.response?.data || error.message || "Failed to login to external API"; throw new BadGatewayException( this.fanavaranAuditService.formatErrorWithTrackingCode( String(errorMessage), auditSession?.trackingCode, ), ); } throw new BadGatewayException( this.fanavaranAuditService.formatErrorWithTrackingCode( "Failed to login to external API", auditSession?.trackingCode, ), ); } } private async getPolicyIdFromNationalCode( nationalCodeOfInsurer: string, config: { appName: string; secret: string; username: string; password: string; corpId: string; contractId: string; location: string; }, logPrefix: string, auditSession?: FanavaranAuditSession, ): Promise { const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`; const startedAt = Date.now(); if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.POLICY_INQUIRY, status: FanavaranAuditStatus.STARTED, requestUrl: policyInquiryUrl, requestMeta: { corpId: config.corpId, contractId: config.contractId, location: config.location, nationalCode: this.fanavaranAuditService.maskNationalCode( nationalCodeOfInsurer, ), }, }); } try { const appToken = await this.getAppToken(config, auditSession); const authenticationToken = await this.login( appToken, config, auditSession, ); this.logger.log( `${logPrefix} Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`, ); const response = await firstValueFrom( this.httpService.get(policyInquiryUrl, { headers: { authenticationToken: authenticationToken, CorpId: config.corpId, ContractId: config.contractId, Location: config.location, "Content-Type": "application/json", }, timeout: 15000, }), ); const policyCount = Array.isArray(response.data) ? response.data.length : 0; this.logger.log( `${logPrefix} Policy inquiry response status=${response.status} count=${policyCount}`, ); this.logger.log( `${logPrefix} Policy inquiry raw response: ${JSON.stringify( response.data, null, 2, )}`, ); const selectedPolicy = selectLatestActiveFanavaranPolicy(response.data); this.logger.log( `${logPrefix} Selected latest active policy PolicyId=${selectedPolicy.policyId} EndDate=${selectedPolicy.endDate}`, ); if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.POLICY_INQUIRY, status: FanavaranAuditStatus.SUCCESS, requestUrl: policyInquiryUrl, httpStatus: response.status, responseMeta: { policyId: selectedPolicy.policyId, policyEndDate: selectedPolicy.endDate, policyEndDateGregorian: selectedPolicy.endDateGregorian, policyCount, }, durationMs: Date.now() - startedAt, }); } return selectedPolicy.policyId; } catch (error) { const errorMessage = error instanceof Error ? error.message : "unknown policy inquiry error"; if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.POLICY_INQUIRY, status: FanavaranAuditStatus.FAILURE, requestUrl: policyInquiryUrl, httpStatus: isAxiosError(error) ? error.response?.status : undefined, 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 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; } /** * Preview Fanavaran submit body (V2 claimCases + blameCases) for a specific client. */ async previewFanavaranSubmitV2( claimCaseId: string, clientKey: FanavaranClientKey, options?: { debug?: boolean; auditSession?: FanavaranAuditSession; }, ): Promise { const profile = getFanavaranClientProfile(clientKey); const logPrefix = `[Fanavaran ${clientKey} V2] claimCaseId=${claimCaseId}`; const auditSession = options?.auditSession ?? ({ trackingCode: this.fanavaranAuditService.generateTrackingCode(), clientKey, source: FanavaranAuditSource.PREVIEW, claimCaseId, } satisfies FanavaranAuditSession); const buildStartedAt = Date.now(); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.BUILD_PAYLOAD, status: FanavaranAuditStatus.STARTED, }); try { const debug = { clientKey, claimCaseId, steps: { claimCaseFound: false, blameRequestLinked: false, blameCaseFound: false, expertDecisionFound: false, accidentReasonFound: false, firstPartyFound: false, firstPartyLocationFound: false, damageReplyFound: false, guiltyPartyIdFound: false, nationalCodeOfInsurerFound: false, policyInquiryAttempted: false, policyInquirySucceeded: false, accidentReasonFallbackFromSnapshot: false, guiltyPartyFallbackUsed: false, }, values: { blameRequestId: null as string | null, guiltyPartyId: null as string | null, nationalCodeOfInsurer: null as string | null, policyId: null as number | null, estimateAmount: null as number | null, }, failureReason: null as string | null, }; const claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) { throw new NotFoundException("Claim case not found"); } debug.steps.claimCaseFound = true; if (!claimCase.blameRequestId) { debug.failureReason = "claimCase.blameRequestId is missing"; throw new BadRequestException("Blame case not linked to claim case"); } debug.steps.blameRequestLinked = true; debug.values.blameRequestId = claimCase.blameRequestId.toString(); const blameCase = await this.blameRequestDbService.findById( claimCase.blameRequestId, ); if (!blameCase) { throw new NotFoundException("Blame case not found"); } debug.steps.blameCaseFound = true; const expertDecision = blameCase.expert?.decision; debug.steps.expertDecisionFound = !!expertDecision; const fallbackAccidentReason = claimCase.snapshot?.accident?.classification?.accidentReason; if (!expertDecision?.fields?.accidentReason && fallbackAccidentReason) { debug.steps.accidentReasonFallbackFromSnapshot = true; } const selectedAccidentReason = expertDecision?.fields?.accidentReason ?? fallbackAccidentReason; debug.steps.accidentReasonFound = !!selectedAccidentReason; const firstParty = blameCase.parties?.find( (party) => party.role === PartyRole.FIRST, ); debug.steps.firstPartyFound = !!firstParty; debug.steps.firstPartyLocationFound = !!firstParty?.location; const activeExpertReply = getActiveV2ExpertReply( claimCase as unknown as { evaluation?: Record }, ); const damageParts = activeExpertReply?.parts; debug.steps.damageReplyFound = !!damageParts?.length; if (options?.debug) { debug.values.estimateAmount = damageParts ? this.calculateEstimateAmount(damageParts) : 0; } const payload = await this.buildFanavaranSubmitPayload({ accidentReason: selectedAccidentReason, createdAt: (blameCase as { createdAt?: Date }).createdAt, location: firstParty?.location, damageParts, defaults: profile.defaults, logPrefix, resolvePolicyId: async () => { const guiltyPartyId = this.resolveGuiltyPartyIdV2( blameCase.parties ?? [], expertDecision?.guiltyPartyId, ); if (!expertDecision?.guiltyPartyId && guiltyPartyId) { debug.steps.guiltyPartyFallbackUsed = true; } debug.steps.guiltyPartyIdFound = !!guiltyPartyId; debug.values.guiltyPartyId = guiltyPartyId ? guiltyPartyId.toString() : null; const nationalCodeOfInsurer = this.getNationalCodeOfInsurerForGuiltyPartyV2( blameCase.parties ?? [], guiltyPartyId, ); debug.steps.nationalCodeOfInsurerFound = !!nationalCodeOfInsurer; debug.values.nationalCodeOfInsurer = nationalCodeOfInsurer; if (!guiltyPartyId) { this.logger.warn( `${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, ); debug.values.policyId = policyId; debug.steps.policyInquirySucceeded = policyId !== null; if (policyId === null) { debug.failureReason = debug.failureReason ?? "policy inquiry returned no PolicyId (timeout, network error, or empty response)"; } return policyId; }, }); if (options?.debug) { return { 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); } 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 (blameCase.type !== BlameRequestType.THIRD_PARTY) { throw new BadRequestException( "Fanavaran damage-case submit only applies to THIRD_PARTY claims", ); } const payload = await this.buildFanavaranDamageCasePayload({ claimCase, blameCase, selectedParts: claimCase.damage?.selectedParts ?? [], clientKey, defaults: profile.defaults, }); return { clientKey, claimCaseId, claimId: claimCase.claimId ?? null, dmgCaseId: claimCase.dmgCaseId ?? null, submitUrl: claimCase.claimId ? `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/dmg-cases` : null, warning: claimCase.claimId ? undefined : "Fanavaran base claimId is required before submit.", 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); return { clientKey, claimCaseId, claimId: claimCase.claimId ?? null, claimNo: claimCase.claimNo ?? null, dmgCaseId: claimCase.dmgCaseId ?? null, submitUrl: claimCase.claimId ? `${this.FANAVARAN_SUBMIT_URL}/${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); const claimCase = await this.claimCaseDbService.findById(claimCaseId); 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 = `${this.FANAVARAN_SUBMIT_URL}/${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, ); 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, }); } 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 = `${this.FANAVARAN_SUBMIT_URL}/${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, requestMeta: { claimId: claimCase.claimId, claimNo: claimCase.claimNo, dmgCaseId: claimCase.dmgCaseId, fileName, source: file.source, content, }, }); const response = await this.postFanavaranMultipart( url, content, [{ path: filePath, fileName }], clientKey, ); this.logger.log(`${logPrefix} Fanavaran response: ${JSON.stringify(response.data)}`); const fileId = response.data?.Id; await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_ATTACHMENT, status: FanavaranAuditStatus.SUCCESS, requestUrl: url, httpStatus: response.status, 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, ); 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); 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(); return { replyKey: active?.replyKey, warnings, payload: { ClaimExpertId: claimExpertId, ComponentReplacementCost: componentReplacementCost, DmgAssessmentDate: this.convertToPersianDate(submittedAt), DmgCaseId: input.claimCase.dmgCaseId ?? null, 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, }, }; } private assertFanavaranExpertisePayloadReady( payload: Record, warnings: string[], ): void { const sections = Array.isArray(payload.DmgSections) ? payload.DmgSections : []; if (!payload.DmgCaseId) warnings.push("DmgCaseId 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 }); return { clientKey, claimCaseId, claimId: claimCase.claimId ?? null, dmgCaseId: claimCase.dmgCaseId ?? null, expertiseId: claimCase.expertiseId ?? null, replyKey, submitUrl: claimCase.claimId ? `${this.FANAVARAN_SUBMIT_URL}/${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 { const claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) throw new NotFoundException("Claim case not found"); let payload: Record; if (bodyOverride) { payload = bodyOverride; } else { const built = await this.buildFanavaranExpertisePayload({ claimCase, clientKey, }); payload = built.payload; this.assertFanavaranExpertisePayloadReady(payload, built.warnings); } 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 { const claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase?.claimId) { throw new BadRequestException( "Fanavaran claimId is required before submitting expertise", ); } const url = `${this.FANAVARAN_SUBMIT_URL}/${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, requestMeta: { claimId: claimCase.claimId, payload }, }); try { const response = await this.postFanavaranJson(url, payload, clientKey); const expertiseId = response.data?.Id; await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_EXPERTISE, status: FanavaranAuditStatus.SUCCESS, requestUrl: url, httpStatus: response.status, 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, }, }, }); return response.data; } catch (error) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_EXPERTISE, status: FanavaranAuditStatus.FAILURE, requestUrl: url, httpStatus: isAxiosError(error) ? error.response?.status : undefined, 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) { 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); } if (!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); 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, ); return { attempted: true, submitted: false, warning, }; } } private withFanavaranAutoSubmitMessage( baseMessage: string, fanavaran: FanavaranAutoSubmitResult, ): string { if (fanavaran.submitted) { return `${baseMessage} The initial third-party case was sent to Fanavaran 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 (blameCase.type !== BlameRequestType.THIRD_PARTY) { return "Fanavaran third-party financial submit only applies to THIRD_PARTY claims"; } return null; } private async postFanavaranJson( url: string, payload: Record, clientKey: FanavaranClientKey, ) { const profile = getFanavaranClientProfile(clientKey); const appToken = await this.getAppToken(profile.auth); const authenticationToken = await this.login(appToken, profile.auth); return await firstValueFrom( this.httpService.post(url, payload, { headers: { authenticationToken, CorpId: profile.auth.corpId, ContractId: profile.auth.contractId, Location: profile.auth.location, "Content-Type": "application/json", }, }), ); } private async postFanavaranMultipart( url: string, content: Record, files: Array<{ path: string; fileName: string }>, clientKey: FanavaranClientKey, ) { const profile = getFanavaranClientProfile(clientKey); const appToken = await this.getAppToken(profile.auth); const authenticationToken = await this.login(appToken, profile.auth); 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: ${authenticationToken}'`, `-H 'CorpId: ${profile.auth.corpId}'`, `-H 'ContractId: ${profile.auth.contractId}'`, `-H 'Location: ${profile.auth.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}`, ); const response = await firstValueFrom( this.httpService.post(url, form, { headers: { ...form.getHeaders(), authenticationToken, CorpId: profile.auth.corpId, ContractId: profile.auth.contractId, Location: profile.auth.location, }, maxBodyLength: Infinity, maxContentLength: Infinity, }), ); this.logger.log( `${logPrefix} RESPONSE: status=${response.status} | body=${JSON.stringify(response.data)}`, ); return response; } private async executeFanavaranDamageCaseSubmit( claimCaseId: string, clientKey: FanavaranClientKey, selectedParts?: unknown[], auditSource: FanavaranAuditSource = FanavaranAuditSource.AUTO_SUBMIT, bodyOverride?: Record, ): Promise { const profile = getFanavaranClientProfile(clientKey); const claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) { throw new NotFoundException("Claim case not found"); } if (!claimCase.claimId) { throw new BadRequestException( "Fanavaran claimId is required before submitting damage case", ); } 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 (blameCase.type !== BlameRequestType.THIRD_PARTY) { throw new BadRequestException( "Fanavaran damage-case submit only applies to THIRD_PARTY claims", ); } const payload = bodyOverride ?? await this.buildFanavaranDamageCasePayload({ claimCase, blameCase, selectedParts: selectedParts ?? claimCase.damage?.selectedParts ?? [], clientKey, defaults: profile.defaults, }); const url = `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/dmg-cases`; 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, requestMeta: { claimId: claimCase.claimId, payload }, }); try { const response = await this.postFanavaranJson(url, payload, clientKey); const dmgCaseId = response.data?.Id; await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE, status: FanavaranAuditStatus.SUCCESS, requestUrl: url, httpStatus: response.status, 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}`, ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE, status: FanavaranAuditStatus.FAILURE, requestUrl: url, httpStatus: isAxiosError(error) ? error.response?.status : undefined, 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, ); 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", lastTriedAt: new Date(), 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, ); return { attempted: true, submitted: false, warning, }; } } /** * Auto-submit the minimum third-party 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}`; 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, ); 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, { $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, ); } return { attempted: true, submitted: false, warning: `${warning} Initial case was not sent to Fanavaran. Retry manually via POST ${fanavaranSubmitPath(clientKey, claimCaseId)}.`, }; } } /** * 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, ); 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_MAX_RETRIES = 2; /** * After a Fanavaran stage failure, schedule a retry if retries remain. * Increments retryCount, sets nextRetryAt, and after the delay calls the * provided retryFn. Returns true if a retry was scheduled. */ private async scheduleFanavaranRetry( claimCaseId: string, stage: "baseClaim" | "damageCase" | "attachments" | "expertise", retryFn: () => Promise, logPrefix: string, ): Promise { 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; } const nextRetryCount = retryCount + 1; const nextRetryAt = new Date(Date.now() + ClaimRequestManagementService.FANAVARAN_RETRY_DELAY_MS); await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { $set: { [`fanavaranSync.${stage}.retryCount`]: nextRetryCount, [`fanavaranSync.${stage}.nextRetryAt`]: nextRetryAt, [`fanavaranSync.${stage}.status`]: "pending", }, }); this.logger.log( `${logPrefix} Scheduling retry ${nextRetryCount}/${maxRetries} for ${stage} at ${nextRetryAt.toISOString()}`, ); setTimeout(async () => { try { const fresh = await this.claimCaseDbService.findById(claimCaseId); const freshStage = (fresh as any)?.fanavaranSync?.[stage]; // Only retry if still in pending state (not manually resolved) if (freshStage?.status === "pending") { await retryFn(); } } catch (retryError) { this.logger.error( `${logPrefix} Retry ${nextRetryCount}/${maxRetries} for ${stage} failed`, retryError, ); } }, ClaimRequestManagementService.FANAVARAN_RETRY_DELAY_MS); return true; } private async executeFanavaranV2Submit( claimCaseId: string, clientKey: FanavaranClientKey, bodyOverride?: Record, ): Promise { const profile = getFanavaranClientProfile(clientKey); const logPrefix = `[Fanavaran ${clientKey} V2]`; this.logger.log( `${logPrefix} Starting submission for claimCaseId: ${claimCaseId}`, ); const fanavaranData = bodyOverride ?? await this.previewFanavaranSubmitV2( claimCaseId, clientKey, ); this.logger.log( `${logPrefix} Mapped data prepared:`, JSON.stringify(fanavaranData, null, 2), ); if ( fanavaranData.AccidentCauseId === undefined || fanavaranData.AccidentCauseId === null ) { this.logger.error( `${logPrefix} CRITICAL: AccidentCauseId is missing or null!`, ); } else { this.logger.log( `${logPrefix} AccidentCauseId validated: ${fanavaranData.AccidentCauseId}`, ); } const appToken = await this.getAppToken(profile.auth); const authenticationToken = await this.login(appToken, profile.auth); const response = await firstValueFrom( this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, { headers: { authenticationToken: authenticationToken, CorpId: profile.auth.corpId, ContractId: profile.auth.contractId, Location: profile.auth.location, "Content-Type": "application/json", }, }), ); this.logger.log(`${logPrefix} API Response Status: ${response.status}`); this.logger.log( `${logPrefix} API Response Data:`, JSON.stringify(response.data, null, 2), ); if (response.data) { const claimNo = response.data.ClaimNo; const claimId = response.data.Id; const updateData: any = { $set: {} as Record }; if (claimNo !== undefined) { updateData.$set.claimNo = claimNo; } if (claimId !== undefined) { updateData.$set.claimId = claimId; } updateData.$set["fanavaranSync.baseClaim"] = { status: "success", lastTriedAt: new Date(), claimId, claimNo, response: response.data, }; await this.claimCaseDbService.findByIdAndUpdate( claimCaseId, updateData, ); } return response.data; } /** @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.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 if ( claimCase.damage?.selectedParts && claimCase.damage.selectedParts.length > 0 ) { throw new ConflictException( "Outer parts have already been selected for this claim", ); } // 5. Validate by selected car type and resolve selected parts by ids/keys const selectedCarType = (body as any)?.carType as | ClaimVehicleTypeV2 | undefined; if (!selectedCarType || !OUTER_PARTS_BY_CAR_TYPE[selectedCarType]) { throw new BadRequestException( "Vehicle type is required before selecting outer parts.", ); } const catalog = OUTER_PARTS_BY_CAR_TYPE[selectedCarType]; const byId = new Map( catalog.map((p) => [p.id, p]), ); const byKey = new Map( catalog.map((p) => [p.key, 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 for ${selectedCarType}: ${id}`, ); } selectedFromIds.push(item); } } // Backward compatibility with selectedParts + legacy carPartDamage let selectedParts = Array.isArray((body as any)?.selectedParts) ? ((body as any).selectedParts as string[]) : []; if (selectedParts.length === 0 && (body as any)?.carPartDamage) { this.assertCarPartDamageAtMostTwoOfFourSides( (body as any).carPartDamage as CarDamagePartDto, ); selectedParts = this.carDamagePartDtoToOuterPartSlugs( (body as any).carPartDamage as CarDamagePartDto, ); } const selectedFromKeys: OuterPartCatalogItem[] = []; for (const key of selectedParts) { const item = byKey.get(key); if (!item) { throw new BadRequestException( `Invalid outer part key for ${selectedCarType}: ${key}`, ); } selectedFromKeys.push(item); } const selectedItems = selectedFromIds.length > 0 ? selectedFromIds : selectedFromKeys; if (selectedItems.length === 0) { throw new BadRequestException( "selectedPartIds or selectedParts is required and must contain at least one item", ); } // 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, catalog), ); 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 nationalCode = 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)", ); } if (!/^[0-9]{10}$/.test(nationalCode)) { throw new BadRequestException( "nationalCodeOfOwner is required and must be exactly 10 digits", ); } const blameRequest = claimCase.blameRequestId ? await this.blameRequestDbService.findById( claimCase.blameRequestId.toString(), ) : null; 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 car-type aware outer-parts lookup (complete source: static catalog). // Uses the raw catalog shape (`{id, key, titleFa, side, ...}`) because the // map below is keyed on the full `key` (e.g. `left_backfender`). const selectedCarType = claimCase.vehicle?.carType as | ClaimVehicleTypeV2 | undefined; const catalogForType = selectedCarType && OUTER_PARTS_BY_CAR_TYPE[selectedCarType] ? OUTER_PARTS_BY_CAR_TYPE[selectedCarType] : this.getOuterPartsRawCatalog(); const catalogByKey = new Map(); for (const item of catalogForType) { 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 }, ): 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); 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); remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.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, }); 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, ); allDocumentsUploaded = options?.v3InPersonFlow ? v3InitialDocsComplete : this.allV2OwnerDocumentsComplete( claimCase, isCarBodyUpload, body.documentKey, ); remaining = options?.v3InPersonFlow ? this.countRemainingV3PreCaptureDocuments( claimCase, isCarBodyUpload, body.documentKey, ) : 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 allDoneNow = afterWrite.status === ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS && afterWrite.workflow?.currentStep === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && this.allV3PreCaptureDocumentsComplete(afterWrite, isCarBodyRecheck); 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; } } 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, branchId: string, 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; }> { 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 pricedBranchReply = { parts: (active.parts ?? []).filter( (p: { factorNeeded?: boolean }) => !p.factorNeeded, ), }; 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 rawBranchId = (branchId ?? "").trim(); if (!rawBranchId || !Types.ObjectId.isValid(rawBranchId)) { throw new BadRequestException( "branchId is required and must be a valid MongoDB ObjectId.", ); } const branchDoc = await this.branchDbService.findById(rawBranchId); if (!branchDoc) { throw new NotFoundException(`Branch with ID ${rawBranchId} not found.`); } const ownerClientId = claimCase.owner?.clientId; if ( !ownerClientId || String(branchDoc.clientKey) !== String(ownerClientId) ) { throw new ForbiddenException( "This branch does not belong to the insurer for this claim.", ); } const branchIdsOnPricing = isMixedPartialGate ? this.collectBranchIdsFromClaimExpertReply(pricedBranchReply) : this.collectBranchIdsFromClaimExpertReply( active as { parts?: unknown[] }, ); if ( branchIdsOnPricing.size > 0 && !branchIdsOnPricing.has(String(rawBranchId)) ) { throw new BadRequestException( "branchId must match a branch used in the expert pricing 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, branchId: new Types.ObjectId(rawBranchId), 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: { branchId: rawBranchId }, }, }, }); 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: { branchId: rawBranchId }, }, }, }); 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: { branchId: rawBranchId }, }, }, }); 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: { branchId: rawBranchId }, }, }, }); 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, }; } /** * 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 list = (claims as any[]).map((c) => ({ 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(), })) 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 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, ...(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, 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). const canAdvance = step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS || step === ClaimWorkflowStep.USER_SUBMISSION_COMPLETE || claimCase.status === ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER; 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; // 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)), ); return { ...base, requiredDocuments: capturePhaseDocs, totalRemaining: base.progress.capturePhaseDocsRemaining, }; } if ( step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && capturePartDone ) { 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, }; } 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 isCaptureDoc = isCapturePhaseDamagedPartyDocKey(body.documentKey); 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 }); 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}`, ); } } return this.uploadRequiredDocumentV2( claimRequestId, body, file, currentUserId, actor, { v3InPersonFlow: true, skipMetalPlate, requiresFileMakerApproval: !!(claimCase as any).requiresFileMakerApproval }, ); } 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.", ); return this.selectOuterPartsV2(claimRequestId, body, currentUserId, actor); } 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; if (!shebaNumber || !nationalCode) { throw new BadRequestException( "Bank information is missing on the claim. Complete run-inquiries for both parties first.", ); } 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: String(shebaNumber).replace( /^(.{4})(.*)(.{4})$/, "IR$1************$3", ), 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) { 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. 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); } }