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 { existsSync } from "node:fs"; import { AiService } from "src/ai/ai.service"; import { buildFileLink } 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 { 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 { 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 { 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 { 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, } from "src/helpers/claim-capture-phase"; import { HttpService } from "@nestjs/axios"; import { firstValueFrom } from "rxjs"; @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://insapm.ir/bimeapimanager/api/EITAuthentication/GetAppToken"; private readonly LOGIN_URL = "https://insapm.ir/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 = "100"; 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 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; } 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; }; return { ...evaluation, damageExpertReply: await mapReply(evaluation.damageExpertReply), damageExpertReplyFinal: await mapReply(evaluation.damageExpertReplyFinal), }; } // 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, ); if ( !claim.owner?.userId || claim.owner.userId.toString() !== effectiveUserId ) { throw new ForbiddenException( "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 apiKey = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2In0.eyJhdWQiOiIyMTcxOCIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2IiwiaWF0IjoxNjgwNjA4NTkxLCJuYmYiOjE2ODA2MDg1OTEsImV4cCI6MTY4MzIwMDU5MSwic3ViIjoiIiwic2NvcGVzIjpbImJhc2ljIl19.rTviLd8b5yTHUDa3ODZyva593eMnL0d3XPg3sKkZxMOf_jNIH6lFQyIfbId-wsd1EAdsOdsL3CME_Y8t332PWJbxMNgnEq4Rf2IkClkvkSx6Sb5_4bmlhBM75zw2SmccvgbFUn4xkTOw0FT4vABC2Y3-MKctjMpmO8QOrVULSKt4psrmQhr7hBu7YRDnAAEc6muZ1VpRvdB1kqNKddoSIrfDaq6aDRJ-BNbGRAaFFvP_kH4cgSCKV4dU0TknL3mRKUiVy6_TDkjtzAN8fE2wsdvNo2pGTJPzKFsR2ipgGNTvB__g3bOnVpKsgFXPBH0e_Qa7ff1tZ3VGWy3jRNh9Lg"; const response = await firstValueFrom( this.httpService.get( `https://map.ir/fast-reverse?lat=${lat}&lon=${lon}`, { headers: { accept: "application/json", "x-api-key": apiKey, }, }, ), ); 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); } /** 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; } /** * 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: 1589, CompensationReferenceId: 167, CulpritLicenceTypeId: 2, CulpritTypeId: 337, AccidentCauseId: 6, // Initialize to null, will be set if found }; // AccidentCauseId: from expertSubmitReplyFinal.fields.accidentReason.fanavaran // If fanavaran is 0, use id instead. First check if expertSubmitReplyFinal exists, if not check expertSubmitReply if (blameRequest.expertSubmitReplyFinal) { // expertSubmitReplyFinal exists, use it for AccidentCauseId const accidentReason = blameRequest.expertSubmitReplyFinal?.fields?.accidentReason; const fanavaranValue = accidentReason?.fanavaran; const idValue = accidentReason?.id; if (fanavaranValue !== undefined && fanavaranValue !== null) { // If fanavaran is 0, use id instead if (fanavaranValue === 0) { result.AccidentCauseId = idValue !== undefined && idValue !== null ? idValue : null; } else { result.AccidentCauseId = fanavaranValue; } } else { this.logger.warn( `[Fanavaran Submit] expertSubmitReplyFinal exists but fanavaran is null or undefined`, ); result.AccidentCauseId = null; } } else if (blameRequest.expertSubmitReply) { // expertSubmitReplyFinal is null, use expertSubmitReply const accidentReason = blameRequest.expertSubmitReply?.fields?.accidentReason; const fanavaranValue = accidentReason?.fanavaran; const idValue = accidentReason?.id; if (fanavaranValue !== undefined && fanavaranValue !== null) { // If fanavaran is 0, use id instead if (fanavaranValue === 0) { result.AccidentCauseId = idValue !== undefined && idValue !== null ? idValue : null; } else { result.AccidentCauseId = fanavaranValue; } } else { this.logger.warn( `[Fanavaran Submit] expertSubmitReply exists but fanavaran is null or undefined`, ); result.AccidentCauseId = null; } } else { this.logger.error( `[Fanavaran Submit] Both expertSubmitReplyFinal and expertSubmitReply are null/undefined`, ); result.AccidentCauseId = null; } // 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); } // AccidentLocationAddress: Get from reverse geocoding API if ( blameRequest.firstPartyLocation?.lat && blameRequest.firstPartyLocation?.lon ) { result.AccidentLocationAddress = await this.getAddressFromCoordinates( blameRequest.firstPartyLocation.lat, blameRequest.firstPartyLocation.lon, ); } // EstimateAmount: Sum of totalPayment from damageExpertReply parts const damageReply = claimRequest.damageExpertReplyFinal || claimRequest.damageExpertReply; if (damageReply?.parts) { result.EstimateAmount = this.calculateEstimateAmount(damageReply.parts); } else { result.EstimateAmount = 0; } // Keep other fields from template with default values result.AccidentCulpritId = null; //todo result.CouponNo = null; result.CourtArchiveNo = null; result.CulpritLicenceCityId = null; result.CulpritLicenceCountryId = null; result.CulpritLicenceForeignCityName = null; result.CulpritLicenceIssuDate = "1394/10/13"; //todo result.CulpritLicenceNo = "1124242"; result.DamagedCount = 1; result.DmgAssessorFirstCreationTime = null; result.EntryDate = null; result.IsLicenseMatchWithVehicleKind = 1; result.HasOtherCulprit = 0; result.IsAccidentOutOfBorder = 0; result.IsFatalAccident = 0; result.IsPlaqueChanged = 0; result.PlaqueCityId = null; result.PlaqueKindId = null; result.PlaqueLeftNo = null; result.PlaqueMiddleCodeId = null; result.PlaqueNo = null; result.PlaqueRightNo = null; result.PlaqueSampleId = null; result.PlaqueSerial = null; result.PoliceOfficerId = 1; result.PoliceReportDesc = null; result.PoliceReportSeri = null; result.PoliceReportSerial = null; result.PolicyId = null; result.PreviousPolicyEndDate = ""; result.TrackingCode = null; result.IsLicenseReplacement = 0; result.UnknownCulpritCauseId = null; // Get PolicyId from external API try { const policyId = await this.getPolicyIdFromExternalApi( blameRequest, claimRequestId, ); if (policyId) { result.PolicyId = policyId; } } catch (error) { this.logger.error( `[Fanavaran Submit] Failed to get PolicyId from external API:`, error, ); // Continue without PolicyId if API call fails } return result; } catch (error) { this.logger.error("Error in fanavaranSubmit", error); throw error; } } /** * Step 1: Get appToken from GetAppToken API */ private async getAppToken(): Promise { try { const requestHeaders: any = { appname: this.APP_NAME, secret: this.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", ); } this.logger.log(`Successfully obtained appToken`); return appToken; } catch (error) { 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(errorMessage); } throw new BadGatewayException("Failed to get appToken from external API"); } } /** * Step 2: Login to get authenticationToken */ private async login(appToken: string): Promise { try { const requestHeaders: any = { appToken: appToken, userName: this.USERNAME, password: this.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; }, ], }), ); // Check response headers first (authenticationToken is in headers) 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", ); } this.logger.log(`Successfully obtained authenticationToken`); return authenticationToken; } catch (error) { 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(errorMessage); } throw new BadGatewayException("Failed to login to external API"); } } /** * 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; } // Get authenticationToken const appToken = await this.getAppToken(); const authenticationToken = await this.login(appToken); // Call external API to get PolicyId const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`; this.logger.log( `[Fanavaran Submit] Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`, ); const response = await firstValueFrom( this.httpService.get(policyInquiryUrl, { headers: { authenticationToken: authenticationToken, CorpId: this.CORP_ID, ContractId: this.CONTRACT_ID, Location: this.LOCATION, "Content-Type": "application/json", }, }), ); if ( Array.isArray(response.data) && response.data.length > 0 && response.data[0].PolicyId ) { const policyId = response.data[0].PolicyId; this.logger.log( `[Fanavaran Submit] Successfully retrieved PolicyId: ${policyId}`, ); return policyId; } else { this.logger.warn( `[Fanavaran Submit] No PolicyId found in API response`, ); return null; } } catch (error) { this.logger.error( `[Fanavaran Submit] Error getting PolicyId from external API:`, error, ); if (isAxiosError(error)) { const errorMessage = error.response?.data?.Message || error.response?.data?.message || error.response?.data || error.message || "Failed to get PolicyId from external API"; this.logger.error(`[Fanavaran Submit] Error message: ${errorMessage}`); } throw error; } } /** * 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 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, workflow: { currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, completedSteps: [ClaimWorkflowStep.CLAIM_CREATED], locked: false, }, owner: { userId: new Types.ObjectId(currentUserId), userRole: currentUserParty.role as any, ...(currentUserParty.person?.clientId ? { clientId: new Types.ObjectId( currentUserParty.person.clientId as any, ), } : {}), }, 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}`, ); return { claimRequestId: String(newClaim._id), publicId: blameRequest.publicId, status: ClaimCaseStatus.SELECTING_OUTER_PARTS, message: "Claim request created successfully", }; } 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. * The claim owner is set to the damaged party (first for CAR_BODY, non-guilty for THIRD_PARTY). */ 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 parties = blameRequest.parties || []; const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY; let damagedUserId: string; if (isCarBody) { if (parties.length < 1) throw new BadRequestException("Blame request has no party"); const first = parties.find((p) => p.role === "FIRST"); if (!first?.person?.userId) throw new BadRequestException("First party has no userId"); damagedUserId = String(first.person.userId); } else { const guiltyPartyId = blameRequest.expert?.decision?.guiltyPartyId ? String(blameRequest.expert.decision.guiltyPartyId) : null; if (!guiltyPartyId) throw new BadRequestException("Blame request has no guilty party set"); const damagedParty = parties.find( (p) => p.person?.userId && String(p.person.userId) !== guiltyPartyId, ); if (!damagedParty?.person?.userId) { throw new BadRequestException("Could not determine damaged party"); } damagedUserId = String(damagedParty.person.userId); } 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 damagedParty = parties.find( (p) => p.person?.userId && String(p.person.userId) === damagedUserId, ); 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, workflow: { currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, completedSteps: [ClaimWorkflowStep.CLAIM_CREATED], locked: false, }, owner: { userId: new Types.ObjectId(damagedUserId), userRole: damagedParty?.role as any, ...(damagedParty?.person?.clientId ? { clientId: new Types.ObjectId(damagedParty.person.clientId as any), } : {}), }, 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}`, ); return { claimRequestId: String(newClaim._id), publicId: blameRequest.publicId, status: ClaimCaseStatus.SELECTING_OUTER_PARTS, message: "Claim created successfully. You can now fill claim data on behalf of the damaged party.", }; } /** * 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 { if ( ![RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR].includes(actorRole as any) || !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) { if ( !blameRequest.expertInitiated || !blameRequest.initiatedByFieldExpertId || String(blameRequest.initiatedByFieldExpertId) !== currentUserId ) { return currentUserId; } } else { if ( !blameRequest.registrarInitiated || !blameRequest.initiatedByRegistrarId || String(blameRequest.initiatedByRegistrarId) !== currentUserId ) { return currentUserId; } } return 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 parties = blameRequest.parties || []; const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY; let damagedUserId: string; if (isCarBody) { const first = parties.find((p) => p.role === "FIRST"); if (!first?.person?.userId) throw new BadRequestException("First party has no userId"); damagedUserId = String(first.person.userId); } else { const guiltyPartyId = blameRequest.expert?.decision?.guiltyPartyId ? String(blameRequest.expert.decision.guiltyPartyId) : null; if (!guiltyPartyId) throw new BadRequestException("Blame request has no guilty party set"); const damagedParty = parties.find( (p) => p.person?.userId && String(p.person.userId) !== guiltyPartyId, ); if (!damagedParty?.person?.userId) throw new BadRequestException("Could not determine damaged party"); damagedUserId = String(damagedParty.person.userId); } 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 damagedParty = parties.find( (p) => p.person?.userId && String(p.person.userId) === damagedUserId, ); 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, workflow: { currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, completedSteps: [ClaimWorkflowStep.CLAIM_CREATED], locked: false, }, owner: { userId: new Types.ObjectId(damagedUserId), userRole: damagedParty?.role as any, ...(damagedParty?.person?.clientId ? { clientId: new Types.ObjectId(damagedParty.person.clientId as any), } : {}), }, 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); return { claimRequestId: String(newClaim._id), publicId: blameRequest.publicId, status: ClaimCaseStatus.SELECTING_OUTER_PARTS, message: "Claim created successfully. Registrar can now fill claim data.", }; } /** * 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, ); // 2. Validate user is the claim owner (or field expert acting for IN_PERSON claim) if ( !claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId ) { throw new ForbiddenException( "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`, ); // 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: "Outer parts selected successfully. Please proceed to select other parts and provide bank information.", }; } 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 claim owner (or field expert for IN_PERSON claim) if ( !claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId ) { throw new ForbiddenException( "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, ...(step !== ClaimWorkflowStep.USER_EXPERT_RESEND && { "money.sheba": "", "money.nationalCodeOfInsurer": nationalCode, }), status: ClaimWorkflowStep.CAPTURE_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, ); if ( !claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId ) { throw new ForbiddenException( "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", }, ...(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, ); if ( !claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId ) { throw new ForbiddenException( "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 }, ): 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, ); if ( !claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId ) { throw new ForbiddenException( "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 (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: allow replacement. if (!isResendUpload) { 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); // Create document reference in claim-required-documents collection const docRef = await this.claimRequiredDocumentDbService.create({ path: file.path, fileName: file.filename, claimId: new Types.ObjectId(claimRequestId), documentType: body.documentKey, uploadedAt: new Date(), }); // Update claim case const updateData: any = { [`requiredDocuments.${body.documentKey}`]: { fileId: docRef._id, filePath: file.path, fileName: file.filename, uploaded: true, uploadedAt: new Date(), }, $push: { history: { 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(), }, }, }, }; let allDocumentsUploaded = false; let remaining = 0; // Will be true when this upload also completes capture-phase docs AND // every angle + every selected damaged part has already been captured. // In that case we advance the workflow exactly like `capturePartV2` does // when the user finishes the last capture — otherwise the user would be // stuck in CAPTURE_PART_DAMAGES with all captures done but no more // captures to upload to trigger the transition. 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) => !afterThis(k), ).length; allDocumentsUploaded = false; if (remaining === 0) { const progressAfterDoc = getClaimCaptureProgress(claimCase, { assumeCapturePhaseDocKey: body.documentKey, }); if ( progressAfterDoc.partsComplete && progressAfterDoc.anglesComplete && progressAfterDoc.capturePhaseDocsComplete ) { captureStepCompletedOnThisUpload = true; updateData["status"] = ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS; updateData["workflow.currentStep"] = ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS; updateData["workflow.nextStep"] = ClaimWorkflowStep.USER_SUBMISSION_COMPLETE; updateData.$push.history = [ updateData.$push.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.", }, }, ]; updateData.$push["workflow.completedSteps"] = ClaimWorkflowStep.CAPTURE_PART_DAMAGES; } } } else { allDocumentsUploaded = this.allV2OwnerDocumentsComplete( claimCase, isCarBodyUpload, body.documentKey, ); remaining = this.countRemainingV2OwnerDocuments( claimCase, isCarBodyUpload, body.documentKey, ); if (allDocumentsUploaded) { updateData["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; updateData["claimStatus"] = ClaimStatus.PENDING; updateData["workflow.currentStep"] = ClaimWorkflowStep.USER_SUBMISSION_COMPLETE; updateData["workflow.nextStep"] = ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; updateData.$push.history = [ updateData.$push.history, { 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.", }, }, ]; updateData.$push["workflow.completedSteps"] = ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS; } } } await this.claimCaseDbService.findByIdAndUpdate( claimRequestId, updateData, ); if (isResendUpload) { await this.tryFinalizeExpertResendAfterUserAction( claimRequestId, currentUserId, claimCase.owner?.fullName || "User", ); const refreshed = await this.claimCaseDbService.findById(claimRequestId); const expertResendComplete = !!refreshed?.evaluation?.damageExpertResend?.fulfilledAt; 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.", }; } const message = isCapturePhaseDocUpload ? captureStepCompletedOnThisUpload ? "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 ? "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 ? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE : ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, message, }; } 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, ); if ( !claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId ) { throw new ForbiddenException("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; 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.", }; } 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, }; } 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, ); if ( !claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId ) { throw new ForbiddenException( "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 }, ) { 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, ); if ( !claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId ) { throw new ForbiddenException( "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 objectionPayload = { objectionParts, newParts, submittedAt: new Date(), }; 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, }, $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, }, }, }, }); 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, ); if ( !claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId ) { throw new ForbiddenException( "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"; }> { 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, ); if ( !claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId ) { throw new ForbiddenException( "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", }; } 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 }, }, }, }); return { message: "Your signature has been recorded. The claim is completed.", claimRequestId, status: ClaimCaseStatus.COMPLETED, claimStatus: ClaimStatus.APPROVED, currentStep: ClaimWorkflowStep.CLAIM_COMPLETED, accepted: true, phase: "FINAL_APPROVAL", }; } /** * 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 expertBlameIds = await this.blameRequestDbService .find( { expertInitiated: true, creationMethod: "IN_PERSON", initiatedByFieldExpertId: 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) }, ...(expertBlameIds.length ? [{ blameRequestId: { $in: expertBlameIds } }] : []), ], }, { lean: true }, ); } else { claims = await this.claimCaseDbService.find( { "owner.userId": new Types.ObjectId(currentUserId) }, { lean: true }, ); } 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"); } const effectiveUserId = await this.resolveClaimEffectiveUserId( claim, currentUserId, actor?.role, ); if ( !claim.owner?.userId || claim.owner.userId.toString() !== effectiveUserId ) { throw new ForbiddenException("You do not have access to this claim"); } 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: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined), }; } 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 = displayParts.map((sp, index) => { const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp); const cap = getDamagedPartCaptureBlob( damagedPartsData, ck, selectedNormDetails, ) as { url?: string; path?: string; fileName?: string } | undefined; return { index, id: sp.id, name: sp.name, side: sp.side, label_fa: sp.label_fa, captured: !!cap, url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined), ...(cap?.path ? { path: cap.path } : {}), ...(cap?.fileName ? { fileName: cap.fileName } : {}), }; }); 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; 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}`; } }