import { BadGatewayException, BadRequestException, ConflictException, ForbiddenException, HttpException, HttpStatus, Injectable, InternalServerErrorException, Logger, NotFoundException, } from "@nestjs/common"; import { ExternalExceptionFilter } from "@nestjs/core/exceptions/external-exception-filter"; import { Types } from "mongoose"; import ShortUniqueId from "short-unique-id"; import { ClientService } from "src/client/client.service"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { CarBodyFormDto, CarBodySecondForm, InitialFormDto, } from "src/request-management/dto/create-request-management.dto"; import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service"; import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; import { SandHubService } from "src/sand-hub/sand-hub.service"; import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum"; import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { AutoCloseRequestService } from "src/utils/cron/cron.service"; import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service"; import { DescriptionDto, LocationDto, RequestManagementDtoRs, } from "./dto/create-request-management.dto"; import { DetailsReplyDtoRs, UserReplyDto } from "./dto/user-reply.dto"; import { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service"; import { UserSignDbService } from "./entities/db-service/sign.db.service"; import { RequestManagementModel } from "./entities/schema/request-management.schema"; import { UploadContext } from "./entities/schema/blame-voice.schema"; import { BlameDocumentType } from "./entities/schema/blame-document.schema"; import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service"; import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service"; import { CreationMethod, FilledBy, FileHistoryEvent, ExpertLinkInfo, } from "./entities/schema/request-management.schema"; import { CreateExpertInitiatedFileDto, } from "./dto/expert-initiated.dto"; import { RoleEnum } from "src/Types&Enums/role.enum"; @Injectable() export class RequestManagementService { private readonly logger = new Logger(RequestManagementService.name); constructor( private readonly requestManagementDbService: RequestManagementDbService, private readonly blameDocumentDbService: BlameDocumentDbService, private readonly blameVideoDbService: BlameVideoDbService, private readonly userDbService: UserDbService, private readonly sandHubService: SandHubService, private readonly sandHubDbService: SandHubDbService, private readonly clientService: ClientService, private readonly blameVoiceDbService: BlameVoiceDbService, private readonly userSign: UserSignDbService, private readonly smsManagerService: SmsManagerService, private readonly expertDbService: ExpertDbService, private readonly autoCloseRequestService: AutoCloseRequestService, private readonly claimRequestManagementDbService: ClaimRequestManagementDbService, ) {} async createRequest(user, type) { const reqNumber = new ShortUniqueId({ counter: 1 }); const createRequest = await this.requestManagementDbService.create({ firstPartyDetails: { firstPartyId: user.sub, firstPartyPhoneNumber: user.username, firstPartyCertificate: null, }, currentStep: StepsEnum.createRequest, nextStep: type === "THIRD_PARTY" ? StepsEnum.F_InitialForm : StepsEnum.CarBodyForm, requestNumber: reqNumber.rnd(), blameStatus: ReqBlameStatus.PendingForFirstParty, lockFile: false, type, }); return { requestId: createRequest["_id"] }; } async initialFormStep( reqBody: InitialFormDto, user, requestId, ): Promise { const foundRequest = await this.requestManagementDbService.findOne(requestId); if (!foundRequest) { throw new NotFoundException("Request not found"); } // For LINK-based expert-initiated files, verify user is the correct party if (foundRequest.expertInitiated && foundRequest.creationMethod === CreationMethod.LINK) { // User should already be set in firstPartyDetails or secondPartyDetails const isFirstParty = foundRequest.firstPartyDetails?.firstPartyId?.toString() === user.sub || foundRequest.firstPartyDetails?.firstPartyPhoneNumber === user.username; const isSecondParty = foundRequest.secondPartyDetails?.secondPartyId?.toString() === user.sub || foundRequest.secondPartyDetails?.secondPartyPhoneNumber === user.username; if (!isFirstParty && !isSecondParty) { throw new ForbiddenException( "You are not authorized to complete this file. This file was created by an expert for specific parties.", ); } } if ( foundRequest?.secondPartyDetails?.secondPartyPhoneNumber == user.username ) { await this.requestManagementDbService.findAndUpdate( { _id: requestId }, { "secondPartyDetails.secondPartyId": user.sub, blameStatus: ReqBlameStatus.PendingForSecondParty, "secondPartyDetails.secondPartyInitialForm": reqBody, currentStep: StepsEnum.S_InitialForm, nextStep: StepsEnum.S_addPlate, $push: { steps: StepsEnum.S_InitialForm }, }, ); // Add history for expert-initiated files if (foundRequest.expertInitiated) { await this.addHistoryEvent( requestId, "SECOND_PARTY_FORM_COMPLETED", new Types.ObjectId(user.sub), undefined, foundRequest.filledBy === FilledBy.EXPERT ? "expert" : "customer", ); } return new RequestManagementDtoRs( foundRequest.firstPartyDetails.firstPartyId, user.sub, StepsEnum.S_InitialForm, StepsEnum.S_addPlate, StepsEnum.S_addPlate, requestId, ); } else if ( foundRequest?.firstPartyDetails?.firstPartyPhoneNumber == user.username || (foundRequest.expertInitiated && foundRequest.creationMethod === CreationMethod.LINK && foundRequest.firstPartyDetails?.firstPartyId?.toString() === user.sub) ) { // Normal flow or link-based - set full firstPartyDetails const updatePayload: any = { blameStatus: ReqBlameStatus.PendingForFirstParty, firstPartyDetails: { firstPartyId: user.sub, firstPartyPhoneNumber: user.username, firstPartyInitialForm: reqBody, }, currentStep: StepsEnum.F_InitialForm, nextStep: StepsEnum.F_videoUpload, $push: { steps: StepsEnum.F_InitialForm }, }; // For link-based expert-initiated files, ensure filledBy is set if (foundRequest.expertInitiated && foundRequest.creationMethod === CreationMethod.LINK) { updatePayload.$set = { filledBy: FilledBy.CUSTOMER }; } const createRequestFile = await this.requestManagementDbService.findAndUpdate( { _id: requestId }, updatePayload, ); // Add history for expert-initiated link-based files if (foundRequest.expertInitiated && foundRequest.creationMethod === CreationMethod.LINK) { await this.addHistoryEvent( requestId, "FIRST_PARTY_FORM_COMPLETED", new Types.ObjectId(user.sub), undefined, "customer", ); } return new RequestManagementDtoRs( createRequestFile.firstPartyDetails.firstPartyId, null, StepsEnum.F_InitialForm, StepsEnum.F_videoUpload, "Add video", createRequestFile._id, ); } else { // If none of the conditions match, throw an error throw new ForbiddenException( "You are not authorized to complete this form. Please ensure you are the correct party or using a valid link.", ); } } async videoUploadStep( file, user, requestId, ): Promise { const request = await this.requestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Request not found"); } // Check phone number match for normal flow if (request?.firstPartyDetails?.firstPartyPhoneNumber !== user.username) { throw new ForbiddenException("the request doesnt belong you"); } try { if (request.steps.includes(StepsEnum.F_videoUpload) || request.steps.includes(StepsEnum.CarBodyVideo)) throw new BadRequestException("video steps has exist"); const videoDocument = await this.blameVideoDbService.create({ fileName: file.filename, path: file.path, requestId, }); // Determine the step to push and next step based on request type const stepToPush = request.type === "THIRD_PARTY" ? StepsEnum.F_videoUpload : StepsEnum.CarBodyVideo; const currentStepValue = request.type === "THIRD_PARTY" ? StepsEnum.F_videoUpload : StepsEnum.CarBodyVideo; const nextStepValue = StepsEnum.F_addPlate; const updated = await this.requestManagementDbService.findAndUpdate( { _id: requestId }, { $push: { steps: stepToPush }, currentStep: currentStepValue, "firstPartyDetails.firstPartyFile.firstPartyVideoId": videoDocument["_doc"]._id, nextStep: nextStepValue, }, ); if (updated == undefined) return new BadGatewayException("update failed"); return new RequestManagementDtoRs( user.sub, null, currentStepValue, nextStepValue, "please add plate", requestId, ); } catch (err) { return new HttpException(err.message, err.status); } } async addPlate( sandHubReport: any, request: any, user: any, body: AddPlateDto, partyType: "firstParty" | "secondParty", ) { const clientName = sandHubReport?.CompanyName; const companyCode = sandHubReport?.CompanyCode; const client = await this.clientService.findClientWithCompanyCode(+companyCode); if (!client) { throw new HttpException("Client not found", HttpStatus.CONFLICT); } const partyDetails = partyType === "firstParty" ? "firstPartyDetails" : "secondPartyDetails"; const stepsEnum = partyType === "firstParty" ? StepsEnum.F_addPlate : StepsEnum.S_addPlate; const currentStep = partyType === "firstParty" ? StepsEnum.F_addPlate : StepsEnum.S_addPlate; // Determine next step based on request type let nextStep: StepsEnum; if (request.type === "CAR_BODY") { // For CAR_BODY, after plate always go to location // (Form and video were already completed before plate) nextStep = StepsEnum.F_addLocation; } else { // THIRD_PARTY or default flow nextStep = partyType === "firstParty" ? StepsEnum.F_addLocation : StepsEnum.S_addLocation; } try { await this.userDbService.findOneAndUpdate( { _id: user.sub }, { clientKey: client["_doc"]._id }, ); const sandHubDocData = { ...sandHubReport, plate: body.plate, nationalCode: body.nationalCodeOfInsurer, }; const sandHubDoc = await this.sandHubService.sandHubDocumentCreator( user.sub, request["_doc"]._id, sandHubDocData, ); if (partyType === "firstParty") { await this.requestManagementDbService.findAndUpdate( { _id: request._id }, { sanHubId: sandHubDoc["_doc"]._id, }, ); } // Build $set object with all field updates const setFields: any = { currentStep: currentStep, nextStep: nextStep, [`${partyDetails}.nationalCodeOfInsurer`]: body.nationalCodeOfInsurer, [`${partyDetails}.${partyType}Plate`]: body.plate, [`${partyDetails}.${partyType}CarDetail.carName`]: sandHubReport.MapTypNam, [`${partyDetails}.${partyType}CarDetail.insurerBirthday`]: body.insurerBirthday, [`${partyDetails}.${partyType}CarDetail.driverBirthday`]: body.driverBirthday, [`${partyDetails}.${partyType}CarDetail.carType`]: `${sandHubReport.UsageField} / ${sandHubReport.MapUsageName || "-"}`, [`${partyDetails}.${partyType}CarDetail.isNewCar`]: body.isNewCar, [`${partyDetails}.${partyType}Client.clientId`]: client["_doc"]._id, [`${partyDetails}.${partyType}Client.clientName`]: clientName, [`${partyDetails}.${partyType}Certificate`]: body.userNoCertificate, [`${partyDetails}.${partyType}InsuranceDetail.insuranceId`]: sandHubReport.LastCompanyDocumentNumber, [`${partyDetails}.${partyType}InsuranceDetail.insuranceCompany`]: clientName, [`${partyDetails}.${partyType}InsuranceDetail.financialCommitmentCeiling`]: sandHubReport.FinancialCvrCptl, [`${partyDetails}.${partyType}InsuranceDetail.startDate`]: sandHubReport.IssueDate, [`${partyDetails}.${partyType}InsuranceDetail.endDate`]: sandHubReport.EndDate, }; // For CAR_BODY type, also fetch and persist mocked car body insurance info if (request.type === "CAR_BODY" && partyType === "firstParty") { const carBodyInfo = await this.mockCarBodyInsuranceInquiry( request._id, body.plate, body.nationalCodeOfInsurer, ); this.logger.log( `[CAR_BODY] Saving car body insurance data to blame file ${request._id}:`, JSON.stringify(carBodyInfo, null, 2), ); // Add car body insurance fields to $set setFields["firstPartyDetails.firstPartyCarBodyInsuranceDetail.policyNumber"] = carBodyInfo.policyNumber; setFields["firstPartyDetails.firstPartyCarBodyInsuranceDetail.startDate"] = carBodyInfo.startDate; setFields["firstPartyDetails.firstPartyCarBodyInsuranceDetail.endDate"] = carBodyInfo.endDate; setFields["firstPartyDetails.firstPartyCarBodyInsuranceDetail.insurerCompany"] = carBodyInfo.insurerCompany; setFields["firstPartyDetails.firstPartyCarBodyInsuranceDetail.coverages"] = carBodyInfo.coverages; } // Build final update payload with proper MongoDB operators const updatePayload: any = { $push: { steps: stepsEnum }, $set: setFields, }; await this.requestManagementDbService.findAndUpdate( { _id: request._id }, updatePayload, ); } catch (er) { this.logger.error(er); throw new InternalServerErrorException( "Failed to update request with plate details.", ); } const nextStepMessage = `Please add location - clientName is ${clientName}`; return new RequestManagementDtoRs( request?.firstPartyDetails?.firstPartyId, request?.secondPartyDetails?.secondPartyId || null, stepsEnum, nextStep, nextStepMessage, request._id, ); } async addPlateToRequest(user: any, body: AddPlateDto, requestId: string) { const request = await this.requestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Request not found"); } if ( (body.nationalCodeOfInsurer === body.nationalCodeOfDriver || body.insurerLicense === body.driverLicense) && body.driverIsInsurer === false ) { throw new BadRequestException( "Insurer and Driver should be two different persons in this mode.", ); } // Determine if the current user is the first party // Check if nextStep is firstParty-addPlate OR if user is the first party phone number const isFirstParty = (request.nextStep === StepsEnum.F_addPlate || request.currentStep === StepsEnum.F_addPlate) && request.firstPartyDetails.firstPartyPhoneNumber === user.username; if (!isFirstParty) { const firstPartyDetails = request.firstPartyDetails as any; if ( firstPartyDetails?.firstPartyPlate || firstPartyDetails?.nationalCodeOfInsurer ) { const isSameNationalCode = firstPartyDetails.nationalCodeOfInsurer === body.nationalCodeOfInsurer; const isSamePlate = JSON.stringify(firstPartyDetails.firstPartyPlate) === JSON.stringify(body.plate); if (isSameNationalCode || isSamePlate) { throw new ConflictException( "The plate and national code for the second party cannot be the same as the first party.", ); } } } // --- Proceed with existing logic if the check passes --- // 1) Main third-party/block inquiry (existing behavior) const sanHubResponse = await this.sandHubService.getSandHubResponse({ plate: body.plate, nationalCodeOfInsurer: body.nationalCodeOfInsurer, }); if (sanHubResponse.Error) { throw new HttpException( sanHubResponse.Error.Message, HttpStatus.BAD_REQUEST, ); } // 2) Personal inquiry (new shared SandHub API) // This is a best-effort call; if it fails we log and continue. try { const personalInquiry = await this.sandHubService.getPersonalInquiry( body.nationalCodeOfInsurer, body.insurerBirthday, ); this.logger.log( `[SANDHUB] Personal inquiry succeeded for nationalCode=${body.nationalCodeOfInsurer}: ${JSON.stringify( personalInquiry, )}`, ); // NOTE: We are not persisting this data yet; once the exact fields // are finalized, we can map and store them on the request document. } catch (err) { this.logger.error( `[SANDHUB] Personal inquiry failed for nationalCode=${body.nationalCodeOfInsurer}: ${err?.message || err}`, ); } return await this.addPlate( sanHubResponse, request, user, body, isFirstParty ? "firstParty" : "secondParty", ); } async carBodyFormStep( reqBody: CarBodyFormDto, user, requestId, ): Promise { const foundRequest = await this.requestManagementDbService.findOne(requestId); if (!foundRequest) { throw new NotFoundException("Request not found"); } // Validate that this is a CAR_BODY type request if (foundRequest?.type !== "CAR_BODY") { throw new BadRequestException( "This endpoint is only for CAR_BODY type requests", ); } // Validate that the user is the first party (or expert filling their own file) if ( !foundRequest.expertInitiated && foundRequest?.firstPartyDetails?.firstPartyPhoneNumber !== user.username ) { throw new ForbiddenException( "Only the first party can complete this step", ); } // For CAR_BODY flow: after form, always go to video upload // We'll assume guilty by default for car accidents (no second form needed) const nextStep = StepsEnum.CarBodyVideo; const createRequestFile = await this.requestManagementDbService.findAndUpdate( { _id: requestId }, { blameStatus: ReqBlameStatus.PendingForFirstParty, carBodyForm: { firstPartyId: user.sub, firstPartyPhoneNumber: user.username, carBodyForm: reqBody, // Assume guilty by default for car accidents isGuilty: reqBody.car ? true : false, }, currentStep: StepsEnum.CarBodyForm, nextStep: nextStep, $push: { steps: StepsEnum.CarBodyForm }, }, ); return new RequestManagementDtoRs( createRequestFile.firstPartyDetails.firstPartyId, null, StepsEnum.CarBodyForm, nextStep, "Please upload accident video", createRequestFile._id, ); } async carBodySecondForm( reqBody: CarBodySecondForm, user, requestId, ): Promise { // This endpoint is deprecated - second form is no longer needed // We now assume guilty by default in the first form throw new BadRequestException( "This endpoint is deprecated. The second form is no longer required. Users are assumed guilty by default for car accidents.", ); } async addDetailToRequestLocation( userId: string, body: LocationDto, requestId, user?: any, // Optional user object for expert verification ) { const findRequest = await this.requestManagementDbService.findOne(requestId); if (!findRequest) throw new NotFoundException("requestId not found"); // Allow first party only if (findRequest.firstPartyDetails.firstPartyId == new Types.ObjectId(userId)) { await this.requestManagementDbService.findAndUpdate( { _id: requestId }, { firstPartyLocation: body, currentStep: StepsEnum.F_addLocation, nextStep: StepsEnum.F_addVoice, $push: { steps: StepsEnum.F_addLocation }, // Ensure step is saved }, ); return new RequestManagementDtoRs( findRequest.firstPartyDetails.firstPartyId, findRequest?.secondPartyDetails?.secondPartyId || null, StepsEnum.F_addLocation, StepsEnum.F_addVoice, "please add detail", requestId, ); } if ( findRequest?.secondPartyDetails?.secondPartyId == new Types.ObjectId(userId) ) { await this.requestManagementDbService.findAndUpdate( { _id: requestId }, { secondPartyLocation: body, currentStep: StepsEnum.S_addLocation, nextStep: StepsEnum.S_addVoice, $push: { steps: StepsEnum.S_addLocation }, // Ensure step is saved }, ); return new RequestManagementDtoRs( findRequest.firstPartyDetails.firstPartyId, findRequest?.secondPartyDetails?.secondPartyId || null, StepsEnum.S_addLocation, StepsEnum.S_addVoice, "please add detail", requestId, ); } else { throw new BadGatewayException("parties not allowed"); } } async voiceUploadStep(userId: any, voice: Express.Multer.File, requestId) { const request = await this.requestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("request is not found"); } const voiceDoc = await this.blameVoiceDbService.create({ fileName: voice.filename, path: voice.path, requestId, }); // Allow first party if (request.firstPartyDetails.firstPartyId === userId.sub) { try { const updated = await this.requestManagementDbService.findAndUpdate( { _id: requestId }, { currentStep: StepsEnum.F_addVoice, nextStep: StepsEnum.F_addDescription, $push: { "firstPartyDetails.firstPartyFile.voices": voiceDoc["_id"], steps: StepsEnum.F_addVoice, }, }, ); if (updated == undefined) { return new BadGatewayException("update failed"); } return new RequestManagementDtoRs( request.firstPartyDetails.firstPartyId, request?.secondPartyDetails?.secondPartyId || null, StepsEnum.F_addVoice, StepsEnum.F_addDescription, "please add description", requestId, ); } catch (err) { console.log(err); throw new ExternalExceptionFilter(); } } if (request?.secondPartyDetails?.secondPartyId === userId.sub) { try { const updated = await this.requestManagementDbService.findAndUpdate( { _id: requestId }, { currentStep: StepsEnum.S_addVoice, nextStep: StepsEnum.S_addDescription, $push: { "secondPartyDetails.secondPartyFiles.voices": voiceDoc["_id"], steps: StepsEnum.S_addVoice, }, }, ); if (updated == undefined) { return new BadGatewayException("update failed"); } return new RequestManagementDtoRs( request.firstPartyDetails.firstPartyId, request?.secondPartyDetails?.secondPartyId || null, StepsEnum.S_addVoice, StepsEnum.S_addDescription, "add Description for SecondParty", requestId, ); } catch (err) { console.log(err); throw new ExternalExceptionFilter(); } } } async addDescriptionToRequest( user: any, body: DescriptionDto, requestId: string, ) { const request = await this.requestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Request not found"); } // --- First Party Flow --- if (request.firstPartyDetails.firstPartyId == user.sub) { // Build base update payload with proper MongoDB operators const nextStepValue = request.type === "THIRD_PARTY" ? StepsEnum.F_addSecondPartyPhoneNumber : StepsEnum.AddSignatures; const blameStatusValue = request.type === "THIRD_PARTY" ? ReqBlameStatus.PendingForFirstParty : ReqBlameStatus.WaitingForSignatures; // Ensure firstPartyFile exists and initialize descriptions array if needed const updatePayload: any = { $set: { currentStep: StepsEnum.F_addDescription, nextStep: nextStepValue, blameStatus: blameStatusValue, }, $push: { steps: StepsEnum.F_addDescription, }, }; // Initialize firstPartyFile.descriptions if it doesn't exist, then push description if (!request.firstPartyDetails.firstPartyFile || !request.firstPartyDetails.firstPartyFile.descriptions) { // Initialize the descriptions array if it doesn't exist if (!request.firstPartyDetails.firstPartyFile) { updatePayload.$set["firstPartyDetails.firstPartyFile"] = {}; } updatePayload.$set["firstPartyDetails.firstPartyFile.descriptions"] = [body.desc]; } else { // If descriptions array exists, push to it updatePayload.$push["firstPartyDetails.firstPartyFile.descriptions"] = body.desc; } // Add CAR_BODY specific fields if type is CAR_BODY if (request.type === "CAR_BODY") { if (body.accidentDate) { updatePayload.$set["firstPartyDetails.firstPartyFile.accidentDate"] = body.accidentDate; } if (body.accidentTime) { updatePayload.$set["firstPartyDetails.firstPartyFile.accidentTime"] = body.accidentTime; } if (body.weatherCondition) { updatePayload.$set["firstPartyDetails.firstPartyFile.weatherCondition"] = body.weatherCondition; } if (body.roadCondition) { updatePayload.$set["firstPartyDetails.firstPartyFile.roadCondition"] = body.roadCondition; } if (body.lightCondition) { updatePayload.$set["firstPartyDetails.firstPartyFile.lightCondition"] = body.lightCondition; } } await this.requestManagementDbService.findAndUpdate( { _id: requestId }, updatePayload, ); // For expert-initiated files, ensure auto-assignment and add history if (request.expertInitiated) { await this.ensureExpertAssignment(requestId); await this.addHistoryEvent( requestId, "FIRST_PARTY_DESCRIPTION_COMPLETED", new Types.ObjectId(user.sub), undefined, request.filledBy === FilledBy.EXPERT ? "expert" : "customer", ); } const nextStepMessage = request.type === "CAR_BODY" ? "Please sign the document to complete your request" : "add secondParty to request"; return new RequestManagementDtoRs( request.firstPartyDetails.firstPartyId, request?.secondPartyDetails?.secondPartyId || null, StepsEnum.F_addDescription, nextStepValue, nextStepMessage, requestId, ); } // --- Second Party Flow --- if (request?.secondPartyDetails?.secondPartyId == user.sub) { const firstPartyForm = request.firstPartyDetails.firstPartyInitialForm; const secondPartyForm = request.secondPartyDetails.secondPartyInitialForm; const shouldAutoResolve = (firstPartyForm?.imDamaged && secondPartyForm?.imGuilty) || (secondPartyForm?.imDamaged && firstPartyForm?.imGuilty); let updatePayload: any; if (shouldAutoResolve) { // Build the payload for the auto-resolve (agreement) flow const guiltyPartyId = firstPartyForm.imGuilty ? request.firstPartyDetails.firstPartyId : request.secondPartyDetails.secondPartyId; updatePayload = { $set: { blameStatus: ReqBlameStatus.WaitingForSignatures, currentStep: StepsEnum.S_addDescription, nextStep: StepsEnum.AddSignatures, expertSubmitReply: { description: "منتظر امضای طرفین برای تایید توافق.", guiltyUserId: guiltyPartyId, submitTime: new Date(), fields: { /* TODO: The accidentReason field is mocked and is default option for when the shouldAutoResolve is true */ accidentReason: { /* it should be removed and replaced with a proper mechanism to identify the accident reason */ id: 2, label: "تغيير مسير ناگهانی", fanavaran: 4, } } }, }, $push: { steps: StepsEnum.S_addDescription, }, }; // Initialize secondPartyFiles.descriptions if it doesn't exist, then push description if (!request.secondPartyDetails?.secondPartyFiles || !request.secondPartyDetails.secondPartyFiles.descriptions) { // Initialize the descriptions array if it doesn't exist if (!request.secondPartyDetails.secondPartyFiles) { updatePayload.$set["secondPartyDetails.secondPartyFiles"] = {}; } updatePayload.$set["secondPartyDetails.secondPartyFiles.descriptions"] = [body.desc]; } else { // If descriptions array exists, push to it updatePayload.$push["secondPartyDetails.secondPartyFiles.descriptions"] = body.desc; } await this.requestManagementDbService.findAndUpdate( { _id: requestId }, updatePayload, ); return new RequestManagementDtoRs( request.firstPartyDetails.firstPartyId, user.sub, StepsEnum.S_addDescription, ReqBlameStatus.WaitingForSignatures, "Agreement detected. Awaiting signatures from both parties.", requestId, ); } else { // Build the payload for the standard (disagreement) flow updatePayload = { $set: { blameStatus: ReqBlameStatus.UnChecked, currentStep: StepsEnum.S_addDescription, nextStep: StepsEnum.S_completed, }, $push: { steps: StepsEnum.S_addDescription, }, }; // Initialize secondPartyFiles.descriptions if it doesn't exist, then push description if (!request.secondPartyDetails?.secondPartyFiles || !request.secondPartyDetails.secondPartyFiles.descriptions) { // Initialize the descriptions array if it doesn't exist if (!request.secondPartyDetails.secondPartyFiles) { updatePayload.$set["secondPartyDetails.secondPartyFiles"] = {}; } updatePayload.$set["secondPartyDetails.secondPartyFiles.descriptions"] = [body.desc]; } else { // If descriptions array exists, push to it updatePayload.$push["secondPartyDetails.secondPartyFiles.descriptions"] = body.desc; } await this.requestManagementDbService.findAndUpdate( { _id: requestId }, updatePayload, ); // For expert-initiated files, ensure auto-assignment and add history if (request.expertInitiated) { await this.ensureExpertAssignment(requestId); await this.addHistoryEvent( requestId, "SECOND_PARTY_DESCRIPTION_COMPLETED", new Types.ObjectId(user.sub), undefined, request.filledBy === FilledBy.EXPERT ? "expert" : "customer", ); await this.addHistoryEvent( requestId, "READY_FOR_EXPERT_EVALUATION", request.initiatedBy, undefined, "system", ); } return new RequestManagementDtoRs( request.firstPartyDetails.firstPartyId, user.sub, StepsEnum.S_addSecondPartyPhoneNumber, "expertOpinion", "please wait... for expert reply", requestId, ); } } throw new ForbiddenException("User is not a party to this request."); } async addSecondPartyDetailsToRequest( phoneNumber, requestId, firstPartyUser, frontendRoutes, req, ) { const request = await this.requestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Request not found"); } if (request?.secondPartyDetails?.secondPartyId) { throw new BadGatewayException("Second party has already been added"); } if (request.firstPartyDetails.firstPartyPhoneNumber === phoneNumber) { throw new BadRequestException( "Second party phone number cannot be the same as the first party", ); } // Only perform the update if the second party details don't exist yet. if (!request.secondPartyDetails) { const updatePayload = { blameStatus: ReqBlameStatus.PendingForSecondParty, "secondPartyDetails.secondPartyPhoneNumber": phoneNumber, currentStep: StepsEnum.F_completed, nextStep: StepsEnum.S_InitialForm, $push: { steps: StepsEnum.F_addSecondPartyPhoneNumber, }, }; await this.requestManagementDbService.findAndUpdate( { _id: requestId }, updatePayload, ); } // Send the SMS after the database update is complete. const URL = `${process.env.URL}/${frontendRoutes}?token=${requestId}`; try { const smsRes = await this.smsManagerService.verifyLookUp({ token: URL, template: "yara724-invite-link", receptor: phoneNumber, }); this.logger.log(smsRes); } catch (er) { this.logger.error("SMS sending failed:", er); } return { url: URL }; } async myRequests(phoneNumber) { const res = { listOfFirstParty: [], listOfSecondParty: [] }; const blameStatuses = [ ReqBlameStatus.CheckedRequest, ReqBlameStatus.UnChecked, ReqBlameStatus.PendingForFirstParty, ReqBlameStatus.PendingForSecondParty, ReqBlameStatus.ReviewRequest, ReqBlameStatus.CheckAgain, ReqBlameStatus.UserPending, ReqBlameStatus.CloseRequest, ReqBlameStatus.WaitForUserAccept, ReqBlameStatus.WaitingForSignatures, ReqBlameStatus.InPersonVisit, ]; const parties = await this.requestManagementDbService.findAll({ $or: [ { $or: [ { "firstPartyDetails.firstPartyPhoneNumber": phoneNumber }, { "secondPartyDetails.secondPartyPhoneNumber": phoneNumber }, ], blameStatus: { $in: blameStatuses }, }, ], }); // TODO create a DTO for Response parties.forEach((p) => { if (p?.secondPartyDetails?.secondPartyPhoneNumber == phoneNumber) { res.listOfSecondParty.push({ id: p.id, blameStatus: p.blameStatus, requestNumber: p.requestNumber, createdAt: new Date(p.createdAt) .toLocaleString("fa-IR") .split("،")[0], timeCreatedAt: new Date(p.createdAt) .toLocaleTimeString("fa-IR") .split(",")[0], type: p.type || "THIRD_PARTY", // Include type field }); } if (p.firstPartyDetails.firstPartyPhoneNumber == phoneNumber) { console.log( new Date(p.createdAt).toLocaleTimeString("fa-IR").split(","), ); res.listOfFirstParty.push({ id: p.id, blameStatus: p.blameStatus, requestNumber: p.requestNumber, createdAt: new Date(p.createdAt) .toLocaleString("fa-IR") .split("،")[0], timeCreatedAt: new Date(p.createdAt) .toLocaleTimeString("fa-IR") .split(",")[0], type: p.type || "THIRD_PARTY", // Include type field }); } }); return res; } async getCreatableClaims(user: any) { const userId = user.sub; const creatableBlameFiles = await this.requestManagementDbService.findAll({ blameStatus: ReqBlameStatus.CloseRequest, $or: [ { "firstPartyDetails.firstPartyId": userId }, { "secondPartyDetails.secondPartyId": userId }, ], "expertSubmitReply.guiltyUserId": { $ne: userId }, }); if (!creatableBlameFiles || creatableBlameFiles.length === 0) { return []; } // Fetch existing claim files for all blame files in parallel const claimFilesPromises = creatableBlameFiles.map((blameFile) => this.claimRequestManagementDbService.findOne( undefined, { "blameFile._id": new Types.ObjectId(blameFile._id) }, ), ); const existingClaimFiles = await Promise.all(claimFilesPromises); // Create a map of blame file ID to claim file ID for quick lookup const blameToClaimMap = new Map(); creatableBlameFiles.forEach((blameFile, index) => { const claimFile = existingClaimFiles[index]; if (claimFile) { blameToClaimMap.set(blameFile._id.toString(), claimFile._id.toString()); } }); const formattedResponse = creatableBlameFiles.map((p) => { const claimId = blameToClaimMap.get(p._id.toString()); return { id: p.id, blameStatus: p.blameStatus, requestNumber: p.requestNumber, createdAt: new Date(p.createdAt).toLocaleString("fa-IR").split("،")[0], timeCreatedAt: new Date(p.createdAt) .toLocaleTimeString("fa-IR") .split(",")[0], ...(claimId && { claimId }), }; }); return formattedResponse; } async requestDetailsWithoutStatus( request: RequestManagementModel, user: any, ) { const parties = this.determineUserPartyRole(request, user); request.actorsChecker = request.actorsChecker[request.actorsChecker.length - 1]; return { theParties: parties, requestDetail: request }; } async requestDetailsCheckedStatus( request: any, user: any, ): Promise { const finalReply = request.expertSubmitReplyFinal || request.expertSubmitReply; if (!finalReply) { throw new NotFoundException("Expert reply not found for this request."); } const { guiltyUserId } = finalReply; const isGuilty = guiltyUserId == user.sub; let actorDetail; for (const a of request.actorsChecker) { if (Object.keys(a)[0].toString() === ReqBlameStatus.CheckedRequest) { actorDetail = a; } } let steps: ReqBlameStatus; if (!finalReply.firstPartyComment) { steps = ReqBlameStatus.PendingForSecondParty; } else if (!finalReply.secondPartyComment) { steps = ReqBlameStatus.PendingForFirstParty; } else { steps = ReqBlameStatus.WaitForUserAccept; } await this.requestManagementDbService.findByIdAndUpdate(request._id, { currentStep: steps, $push: { steps: steps }, }); const theParties = this.determineUserPartyRole(request, user); return new DetailsReplyDtoRs(request, { actorDetail, isGuilty, steps, theParties, }); } async requestDetailCloseStatus(request: RequestManagementModel) { const finalReply = request.expertSubmitReplyFinal || request.expertSubmitReply; if (!finalReply) { return { message: "اطلاعات کارشناسی برای این پرونده یافت نشد." }; } const authoritativeReply = finalReply as any; const bothPartiesAccepted = authoritativeReply.firstPartyComment?.isAccept && authoritativeReply.secondPartyComment?.isAccept; const wasAutoClosed = request.autoCloseTriggerTime && new Date(request.autoCloseTriggerTime) < new Date(); if (bothPartiesAccepted || wasAutoClosed) { const guiltyUserId = authoritativeReply.guiltyUserId; if (!guiltyUserId) { return { message: "اطلاعات طرف مقصر در پرونده یافت نشد." }; } const { firstPartyDetails, secondPartyDetails } = request; const isFirstPartyGuilty = firstPartyDetails.firstPartyId.toString() === guiltyUserId.toString(); const guiltyUserDetail = isFirstPartyGuilty ? [firstPartyDetails] : [secondPartyDetails]; const damageUserDetail = isFirstPartyGuilty ? [secondPartyDetails] : [firstPartyDetails]; return { requestId: request["_id"], blameStatus: request.blameStatus, damageUserDetail, guiltyUserDetail, expertResendReply: request.expertResendReply, expertSubmitReply: request.expertSubmitReply, expertSubmitReplyFinal: request.expertSubmitReplyFinal, }; } else { return { message: "یکی از طرفین پرونده را تایید نکرده است." }; } } async requestDetailsUserPendingStatus( request: RequestManagementModel, user: any, ) { const { expertResendReply } = request; const processPartyDetails = (partyKey: "firstParty" | "secondParty") => { const partyReply = expertResendReply?.[partyKey]; if (!partyReply || user.sub !== partyReply[`${partyKey}Id`]) { return null; } const descriptionString = partyReply[`${partyKey}Description`] || ""; const allParts = descriptionString .split(",") .map((p) => p.trim()) .filter(Boolean); const validDocTypes = Object.values(BlameDocumentType); const requiredDocuments: BlameDocumentType[] = []; const descriptionTextParts: string[] = []; for (const part of allParts) { const matchingDocType = validDocTypes.find( (docType) => docType.replace(/_/g, "").toUpperCase() === part.toUpperCase(), ); if (matchingDocType) { requiredDocuments.push(matchingDocType); } else { descriptionTextParts.push(part); } } const description = descriptionTextParts.join(", "); const otherPartyKey = partyKey === "firstParty" ? "secondParty" : "firstParty"; const otherPartyHasReplied = !!expertResendReply?.[otherPartyKey]?.userReply; const nextStep = otherPartyHasReplied ? "Wait For Expert Comment" : partyKey === "firstParty" ? "Wait For Second Party Reply" : "Wait For First Party Reply"; return { userId: partyReply[`${partyKey}Id`], requiredDocuments, description, hasReplied: partyReply.userReply != null, hasVoice: partyReply.voice != null, nextStep: nextStep, }; }; const details = processPartyDetails("firstParty") || processPartyDetails("secondParty"); return details; } // TODO fix requestDetailsCheckAginStatus again async requestDetailsCheckAgainStatus(request, user) { const { expertResendReply } = request; const detail = []; const addDetail = ( party, partyId, partyDescription, userReply, voice, step1, step2, ) => { if (user.sub === expertResendReply?.[party]?.[`${party}Id`]) { detail.push(expertResendReply?.[party][`${party}Id`], { description: expertResendReply?.[party][`${party}Description`], reply: expertResendReply[party]?.userReply !== null, voice: !!expertResendReply[party]?.voice, }); if ( expertResendReply?.[party][`${party}Id`] && !expertResendReply?.[ party === "firstParty" ? "secondParty" : "firstParty" ]?.userReply ) { detail.push({ step: step1 }); } else { detail.push({ step: step2 }); } } }; addDetail( "firstParty", "firstPartyId", "firstPartyDescription", "userReply", "voice", "Wait For Second Party Reply", "Wait For Expert Comment", ); addDetail( "secondParty", "secondPartyId", "secondPartyDescription", "userReply", "voice", "Wait For First Party Reply", "Wait For Expert Comment", ); return detail; } // TODO CREATE DTO FOR RESPONSE ONLY async requestDetails(user, requestId) { const request = await this.requestManagementDbService.findOne(requestId); if (!request) throw new NotFoundException("request not found"); const formattedRequest = { ...request, createdAt: request.createdAt, updatedAt: request.updatedAt, }; const formattingOptions: Intl.DateTimeFormatOptions = { timeZone: "Asia/Tehran", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }; // Check if createdAt exists and then format it on our new object. if (request.createdAt) { formattedRequest.createdAt = new Date(request.createdAt).toLocaleString( "fa-IR", formattingOptions, ); } // Do the same for updatedAt. if (request.updatedAt) { formattedRequest.updatedAt = new Date(request.updatedAt).toLocaleString( "fa-IR", formattingOptions, ) as unknown as Date; } switch (request.blameStatus) { case ReqBlameStatus.CheckedRequest: return await this.requestDetailsCheckedStatus(request, user); case ReqBlameStatus.UserPending: return await this.requestDetailsUserPendingStatus(request, user); case ReqBlameStatus.CheckAgain: return await this.requestDetailsCheckAgainStatus(request, user); case ReqBlameStatus.CloseRequest: return await this.requestDetailCloseStatus(request); default: // Pass the full user object, not just the username return await this.requestDetailsWithoutStatus(request, user); } } private determineUserPartyRole( request: RequestManagementModel, user: any, ): "firstParty" | "secondParty" | null { const { firstPartyDetails, secondPartyDetails } = request; const userId = user.sub; // The user's unique ID const username = user.username; // The user's phone number // Check against both ID and phone number for robustness if ( userId === firstPartyDetails?.firstPartyId?.toString() || username === firstPartyDetails?.firstPartyPhoneNumber ) { return "firstParty"; } if ( userId === secondPartyDetails?.secondPartyId?.toString() || username === secondPartyDetails?.secondPartyPhoneNumber ) { return "secondParty"; } return null; } private async updateDamageExpertStats( expertId: string, requestId: string, type: "checked" | "handled", ) { if (!expertId || !requestId || !["checked", "handled"].includes(type)) { console.warn("Invalid expertId, requestId, or type"); return; } // Validate that both expertId and requestId are valid ObjectId strings if (!Types.ObjectId.isValid(expertId)) { console.warn("Invalid ObjectId format for expertId:", expertId); return; } if (!Types.ObjectId.isValid(requestId)) { console.warn("Invalid ObjectId format for requestId:", requestId); return; } const expert = await this.expertDbService.findOne({ _id: new Types.ObjectId(expertId), }); if (!expert) { console.warn("Expert not found:", expertId); return; } const requestIdStr = new Types.ObjectId(requestId).toString(); const countedRequestIds = expert.countedRequests?.map((id) => id.toString()) || []; if (type === "checked" && countedRequestIds.includes(requestIdStr)) { console.log( `Request ${requestIdStr} already checked for expert ${expertId}`, ); return; } const update: any = { $inc: {}, $push: {} }; if (type === "checked") { update.$inc["requestStats.totalChecked"] = 1; update.$push["countedRequests"] = requestIdStr; } else if (type === "handled") { update.$inc["requestStats.totalHandled"] = 1; if (countedRequestIds.includes(requestIdStr)) { update.$inc["requestStats.totalChecked"] = -1; } if (!countedRequestIds.includes(requestIdStr)) { update.$push["countedRequests"] = requestIdStr; } else { delete update.$push; } } const updateResult = await this.expertDbService.findOneAndUpdate( { _id: new Types.ObjectId(expertId) }, update, ); if (!updateResult) { console.warn("Failed to update expert stats for:", expertId); } else { console.log(`Expert stats updated (${type}) for expert:`, expertId); } } private async handleDisagreement( request: any, originalReq: any, isFirstPartyTheDisagreer: boolean, ): Promise { await this.requestManagementDbService.findByIdAndUpdate(request._id, { blameStatus: ReqBlameStatus.PartiesDisagree, $unset: { autoCloseTriggerTime: "" }, }); this.logger.log( `Blame request ${request._id} closed with status 'PartiesDisagree'.`, ); const firstPartyComment = request.expertSubmitReply?.firstPartyComment; const secondPartyComment = request.expertSubmitReply?.secondPartyComment; let phoneNumberToNotify: string | null = null; if (isFirstPartyTheDisagreer && secondPartyComment?.isAccept === true) { phoneNumberToNotify = originalReq.secondPartyDetails?.secondPartyPhoneNumber; } else if ( !isFirstPartyTheDisagreer && firstPartyComment?.isAccept === true ) { phoneNumberToNotify = originalReq.firstPartyDetails?.firstPartyPhoneNumber; } // TODO SMS Sending if (phoneNumberToNotify) { const message = `متاسفانه طرف مقابل با نظر کارشناس مخالفت کرد. فرآیند آنلاین این پرونده بسته شده است و جهت پیگیری حضوری اقدام نمایید.`; await this.smsManagerService.sendMessage({ receptor: phoneNumberToNotify, message, }); } } private async handlePostReplyStateChanges( updatedReq: any, originalReq: any, ): Promise { const isCarBodyType = updatedReq.type === "CAR_BODY"; const finalReply = updatedReq.expertResendReply ? updatedReq.expertSubmitReplyFinal : updatedReq.expertSubmitReply; const firstAccept = finalReply?.firstPartyComment?.isAccept; const secondAccept = finalReply?.secondPartyComment?.isAccept; // 🟥 Step 1: Handle rejections if (!isCarBodyType && (firstAccept === false || secondAccept === false)) { await this.handleDisagreement( updatedReq, originalReq, firstAccept === false, ); return; } // 🟦 Step 2: CAR_BODY logic (one side only) if (isCarBodyType) { if (firstAccept) { await this.handleFinalStateChange(updatedReq); } return; } // 🟩 Step 3: Two-party logic if (updatedReq.blameStatus === ReqBlameStatus.WaitingForSignatures) { if (firstAccept && secondAccept) { await this.finalizeAutoResolvedRequest(updatedReq); } else if (firstAccept || secondAccept) { const message = `طرف مقابل توافق را امضا کرده است. لطفا جهت نهایی کردن پرونده، امضای خود را ثبت نمایید.`; await this.handleOnePartyReplied( updatedReq, originalReq, firstAccept, message, ); } return; } if (firstAccept && secondAccept) { await this.handleFinalStateChange(updatedReq); } else if (firstAccept || secondAccept) { const message = `طرف مقابل نظر کارشناس را قبول و امضا کرده است. در طی 24 ساعت آینده فرصت دارید تا پرونده را تکمیل کرده یا پرونده بسته خواهد شد.`; await this.handleOnePartyReplied( updatedReq, originalReq, firstAccept, message, ); } } private async finalizeAutoResolvedRequest(request: any): Promise { await this.requestManagementDbService.findByIdAndUpdate(request._id, { blameStatus: ReqBlameStatus.CloseRequest, $unset: { autoCloseTriggerTime: "" }, }); this.logger.log( `Blame request ${request._id} auto-resolved and closed successfully.`, ); } private buildReplyUpdate( request: any, userId: string, isAccept: boolean, signDetail: any, ): any { const isObjection = !!request.expertResendReply; const replyField = isObjection ? "expertSubmitReplyFinal" : "expertSubmitReply"; // Handle cases where firstPartyId might be undefined (e.g., IN_PERSON expert-filled files) const firstPartyId = request.firstPartyDetails?.firstPartyId; if (!firstPartyId) { throw new BadRequestException( "First party ID is missing. Please ensure the file has been properly initialized.", ); } const isFirstParty = userId === firstPartyId.toString(); const currentPartyKey = isFirstParty ? "firstPartyComment" : "secondPartyComment"; const otherPartyKey = isFirstParty ? "secondPartyComment" : "firstPartyComment"; const newReplyObject = JSON.parse( JSON.stringify(request[replyField] ?? {}), ); const currentUserComment = { isAccept: isAccept, signDetail: { fileId: signDetail._id, fileName: signDetail.fileName, }, }; newReplyObject[currentPartyKey] = currentUserComment; if (!newReplyObject[otherPartyKey]) { newReplyObject[otherPartyKey] = null; } const update = { $set: { [replyField]: newReplyObject, }, }; return update; } private async handleFinalStateChange(request: any): Promise { const alreadyClosed = request.blameStatus === ReqBlameStatus.CloseRequest; const alreadyHandled = request.isHandledStatsUpdated === true; if (!alreadyClosed) { await this.requestManagementDbService.findByIdAndUpdate(request._id, { blameStatus: ReqBlameStatus.CloseRequest, $unset: { autoCloseTriggerTime: "" }, }); } if (!alreadyHandled) { // Only update expert stats if the request was locked by an expert // (CAR_BODY requests might not have actorLocked set) if (request.actorLocked?.actorId) { // Convert ObjectIds to strings safely const actorIdStr = request.actorLocked.actorId.toString ? request.actorLocked.actorId.toString() : String(request.actorLocked.actorId); const requestIdStr = request._id.toString ? request._id.toString() : String(request._id); await this.updateDamageExpertStats( actorIdStr, requestIdStr, "handled", ); } await this.requestManagementDbService.findByIdAndUpdate(request._id, { $set: { isHandledStatsUpdated: true }, }); } } private async handleOnePartyReplied( updatedReq: any, originalReq: any, isFirstPartyAccepted: boolean, message: string, ): Promise { if (updatedReq.autoCloseTriggerTime) { return; } const phoneNumber = isFirstPartyAccepted ? originalReq.secondPartyDetails?.secondPartyPhoneNumber : originalReq.firstPartyDetails?.firstPartyPhoneNumber; if (phoneNumber) { // TODO FIX SMS SENDING FOR PARTIES await this.smsManagerService.sendMessage({ receptor: phoneNumber, message, }); } await this.autoCloseRequestService.scheduleAutoClose({ requestId: updatedReq._id.toString(), runAt: new Date(Date.now() + 24 * 60 * 60 * 1000), }); } async userReply( props, isAccept: boolean, file: Express.Multer.File, ): Promise { const { requestId, user } = props; if (!file) throw new BadRequestException("A signature file is required."); const request = await this.requestManagementDbService.findOne(requestId); if (!request) throw new NotFoundException("Request not found."); const signDetail = await this.userSign.create({ fileName: file.filename, userId: user.sub, path: file.path, requestId, }); const updatePayload = this.buildReplyUpdate( request, user.sub, isAccept, signDetail, ); await this.requestManagementDbService.findAndUpdate( { _id: requestId }, updatePayload, ); const updatedReq = await this.requestManagementDbService.findOne(requestId); await this.handlePostReplyStateChanges(updatedReq, request); return new UserReplyDto(requestId); } async userResend( files: { [key: string]: Express.Multer.File[] }, user: any, requestId: string, description: string, ) { const request = await this.requestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Request not found"); } const partyKey = this.determinePartyAndValidate(request, user.sub); const { voiceId, uploadedDocumentIds } = await this.processResendUploads( files, requestId, ); const updatePayload: any = { $set: { [`expertResendReply.${partyKey}.userReply`]: description, }, }; if (voiceId) { updatePayload.$set[`expertResendReply.${partyKey}.voice`] = voiceId; } for (const docType in uploadedDocumentIds) { updatePayload.$set[`expertResendReply.${partyKey}.documents.${docType}`] = uploadedDocumentIds[docType]; } await this.requestManagementDbService.findByIdAndUpdate( requestId, updatePayload, ); await this.updateRequestStatusIfNeeded(requestId); return { message: "Update saved successfully." }; } // Mocked CAR_BODY insurance inquiry – replace with real external API later private async mockCarBodyInsuranceInquiry( requestId: string, plate: any, nationalCodeOfInsurer: string, ): Promise<{ policyNumber: string; startDate: string; endDate: string; insurerCompany: string; coverages: string[]; }> { this.logger.log( `[CAR_BODY] Mocking car body insurance inquiry for request ${requestId} (plate=${JSON.stringify( plate, )}, nationalCodeOfInsurer=${nationalCodeOfInsurer})`, ); const today = new Date(); const oneYearLater = new Date( today.getFullYear() + 1, today.getMonth(), today.getDate(), ); return { policyNumber: "CB-MOCK-123456", startDate: today.toISOString().slice(0, 10), endDate: oneYearLater.toISOString().slice(0, 10), insurerCompany: "Mock Car Body Insurance Co.", coverages: ["آتش‌سوزی", "سرقت", "بدنه کامل"], }; } private async processResendUploads( files: { [key: string]: Express.Multer.File[] }, requestId: string, ) { const uploadedDocumentIds: { [key in BlameDocumentType]?: Types.ObjectId } = {}; let voiceId: Types.ObjectId | null = null; for (const fieldName in files) { const fileArray = files[fieldName]; if (fileArray && fileArray.length > 0) { const file = fileArray[0]; if (fieldName === "voice") { voiceId = await this.createVoiceDocument( file, requestId, UploadContext.EXPERT_RESEND, ); } else { const documentType = fieldName as BlameDocumentType; const docId = await this.createBlameDocument( file, requestId, documentType, ); uploadedDocumentIds[documentType] = docId; } } } return { voiceId, uploadedDocumentIds }; } private async createBlameDocument( file: Express.Multer.File, requestId: string, documentType: BlameDocumentType, ): Promise { const doc = await this.blameDocumentDbService.create({ path: file.path, requestId: new Types.ObjectId(requestId), fileName: file.filename, documentType: documentType, }); return (doc as any)._id; } private determinePartyAndValidate( request: any, userId: string, ): "firstParty" | "secondParty" { const firstPartyId = request?.firstPartyDetails?.firstPartyId?.toString(); const secondPartyId = request?.secondPartyDetails?.secondPartyId?.toString(); if (userId === firstPartyId) { if (request.expertResendReply?.firstParty?.userReply) { throw new BadRequestException( "You have already submitted a reply for this resend request.", ); } return "firstParty"; } if (userId === secondPartyId) { if (request.expertResendReply?.secondParty?.userReply) { throw new BadRequestException( "You have already submitted a reply for this resend request.", ); } return "secondParty"; } throw new ForbiddenException("User is not a party to this request."); } private async createVoiceDocument( file: Express.Multer.File, requestId: string, context: UploadContext, ): Promise { const voiceDoc = await this.blameVoiceDbService.create({ path: file.path, requestId: new Types.ObjectId(requestId), fileName: file.filename, context: context, }); return (voiceDoc as any)._id; } private async updateRequestStatusIfNeeded(requestId: string): Promise { const updatedRequest = await this.requestManagementDbService.findOne(requestId); const reply = updatedRequest.expertResendReply; if (!reply) { return; // No resend initiated, nothing to do. } // Helper function to check if a description contains a request for documents. const doesDescriptionRequireDocuments = ( description: string | null | undefined, ): boolean => { if (!description) return false; const validDocTypes = Object.values(BlameDocumentType); const descriptionParts = description.split(",").map((p) => p.trim()); // Return true if any part of the description matches a known document type. return descriptionParts.some((part) => validDocTypes.some( (docType) => docType.replace(/_/g, "").toUpperCase() === part.toUpperCase(), ), ); }; // 1. Determine which parties were actually asked to resubmit DOCUMENTS. const requiredPartiesToReply: ("firstParty" | "secondParty")[] = []; if ( reply.firstParty && doesDescriptionRequireDocuments(reply.firstParty.firstPartyDescription) ) { requiredPartiesToReply.push("firstParty"); } if ( reply.secondParty && doesDescriptionRequireDocuments(reply.secondParty.secondPartyDescription) ) { requiredPartiesToReply.push("secondParty"); } if (requiredPartiesToReply.length === 0) { return; } const allRequiredRepliesAreIn = requiredPartiesToReply.every( (party) => !!reply[party]?.userReply, ); if ( allRequiredRepliesAreIn && updatedRequest.blameStatus !== ReqBlameStatus.CheckAgain ) { await this.requestManagementDbService.findByIdAndUpdate(requestId, { blameStatus: ReqBlameStatus.CheckAgain, }); this.logger.log( `Request ${requestId} status updated to CheckAgain as all required parties have resubmitted.`, ); } } private async addedClientKey(data: { clientName: any; companyCode: number }) { if (process.env.ADDED_AUTO_CLIENT_KEY) { let clientExist = await this.clientService.findOne({ clientName: data.clientName, }); if (!clientExist) { try { await this.clientService.addClient({ clientCode: data.companyCode, clientName: { persian: data.clientName, english: null, }, property: { smsApiKey: null, }, useExpertMode: "genuine", }); } catch (err) { console.log(err); } } } } /** * Verify that expert can access this file (only if they initiated it) */ private async verifyExpertAccess( request: RequestManagementModel, user: any, ): Promise { // If user is not an expert type, no verification needed (normal flow) const expertRoles = [ RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT, RoleEnum.FIELD_EXPERT, ]; if (!expertRoles.includes(user.role)) { return; } // If file is not expert-initiated, experts cannot access it if (!request.expertInitiated || !request.initiatedBy) { throw new ForbiddenException( "Experts can only access files they have initiated", ); } // Verify the expert is the one who initiated this file if (String(request.initiatedBy) !== user.sub) { throw new ForbiddenException( "You can only access files that you have initiated", ); } } /** * Helper method to add history event to a request */ private async addHistoryEvent( requestId: string, eventType: string, actorId?: Types.ObjectId, actorName?: string, actorType?: string, metadata?: any, ): Promise { const historyEvent: FileHistoryEvent = { eventType, actorId, actorName, actorType, timestamp: new Date(), metadata, }; await this.requestManagementDbService.findAndUpdate( { _id: requestId }, { $push: { history: historyEvent }, }, ); } /** * Expert creates an initial blame file (incomplete, waiting for completion) */ async createExpertInitiatedFile( expert: any, dto: CreateExpertInitiatedFileDto, ): Promise<{ requestId: string }> { const reqNumber = new ShortUniqueId({ counter: 1 }); const expertId = new Types.ObjectId(expert.sub); // For LINK method, validate phone numbers are provided if (dto.creationMethod === CreationMethod.LINK) { if (!dto.firstPartyPhoneNumber) { throw new BadRequestException( "First party phone number is required for LINK method", ); } if (dto.type === "THIRD_PARTY" && !dto.secondPartyPhoneNumber) { throw new BadRequestException( "Second party phone number is required for LINK method with THIRD_PARTY type", ); } if ( dto.type === "THIRD_PARTY" && dto.firstPartyPhoneNumber === dto.secondPartyPhoneNumber ) { throw new BadRequestException( "First and second party phone numbers must be different", ); } } // For LINK method, create/get users and set their IDs let firstPartyUserId: Types.ObjectId | undefined; let secondPartyUserId: Types.ObjectId | undefined; let firstPartyDetails: any = {}; let secondPartyDetails: any = {}; if (dto.creationMethod === CreationMethod.LINK) { // Get or create first party user firstPartyUserId = await this.getOrCreateUserByPhoneNumber( dto.firstPartyPhoneNumber, ); firstPartyDetails = { firstPartyId: firstPartyUserId, firstPartyPhoneNumber: dto.firstPartyPhoneNumber, }; // Get or create second party user for THIRD_PARTY if (dto.type === "THIRD_PARTY" && dto.secondPartyPhoneNumber) { secondPartyUserId = await this.getOrCreateUserByPhoneNumber( dto.secondPartyPhoneNumber, ); secondPartyDetails = { secondPartyId: secondPartyUserId, secondPartyPhoneNumber: dto.secondPartyPhoneNumber, }; } } const createRequest = await this.requestManagementDbService.create({ requestNumber: reqNumber.rnd(), type: dto.type, expertInitiated: true, initiatedBy: expertId, creationMethod: dto.creationMethod, filledBy: dto.creationMethod === CreationMethod.IN_PERSON ? FilledBy.EXPERT : FilledBy.CUSTOMER, currentStep: StepsEnum.createRequest, nextStep: dto.type === "THIRD_PARTY" ? StepsEnum.F_InitialForm : StepsEnum.CarBodyForm, blameStatus: ReqBlameStatus.PendingForFirstParty, lockFile: false, history: [], assignedExpertId: expertId, // Auto-assign to initiating expert ...(dto.creationMethod === CreationMethod.LINK && { firstPartyDetails, ...(dto.type === "THIRD_PARTY" && { secondPartyDetails }), }), }); const requestId = (createRequest as any)._id.toString(); // Add history event await this.addHistoryEvent( requestId, "FILE_CREATED_BY_EXPERT", expertId, `${expert.firstName} ${expert.lastName}`, "expert", { creationMethod: dto.creationMethod, type: dto.type, }, ); return { requestId, }; } /** * List all expert-initiated blame files for the current field expert (their panel). * Only files where initiatedBy === current user are returned. */ async getMyExpertInitiatedFiles(expert: any): Promise { const expertId = new Types.ObjectId(expert.sub); const files = await this.requestManagementDbService.findAll({ expertInitiated: true, initiatedBy: expertId, }); return (files || []).map((f) => ({ _id: f._id, requestNumber: f.requestNumber, type: f.type, creationMethod: f.creationMethod, blameStatus: f.blameStatus, currentStep: f.currentStep, nextStep: f.nextStep, createdAt: f.createdAt, steps: f.steps, })); } /** * Single endpoint handler: complete all blame-needed data. * Routes to THIRD_PARTY or CAR_BODY completion based on request type. */ async completeBlameData( expert: any, requestId: string, formData: any, ): Promise { const request = await this.requestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Request not found"); } if (!request.expertInitiated) { throw new BadRequestException( "This endpoint is only for expert-initiated files", ); } await this.verifyExpertAccess(request, expert); if (request.type === "THIRD_PARTY") { return this.expertCompleteThirdPartyForm(expert, requestId, formData); } if (request.type === "CAR_BODY") { return this.expertCompleteCarBodyForm(expert, requestId, formData); } throw new BadRequestException( "Unknown file type. Must be THIRD_PARTY or CAR_BODY.", ); } /** * Expert completes all information for IN_PERSON THIRD_PARTY file at once * This replaces the step-by-step flow for experts filling files on-site */ async expertCompleteThirdPartyForm( expert: any, requestId: string, formData: any, // ExpertCompleteThirdPartyFormDto ): Promise { const request = await this.requestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Request not found"); } // Verify this is an expert-initiated IN_PERSON file if (!request.expertInitiated) { throw new BadRequestException( "This endpoint is only for expert-initiated files", ); } if (request.creationMethod !== CreationMethod.IN_PERSON) { throw new BadRequestException( "This endpoint is only for IN_PERSON files. LINK-based files should be completed by users step-by-step.", ); } // Verify expert access await this.verifyExpertAccess(request, expert); // Validate second party is provided for THIRD_PARTY type if (request.type === "THIRD_PARTY" && !formData.secondParty) { throw new BadRequestException( "Second party information is required for THIRD_PARTY type files", ); } // Validate guilty party is provided for THIRD_PARTY type if (request.type === "THIRD_PARTY" && !formData.guiltyPartyPhoneNumber) { throw new BadRequestException( "Guilty party phone number is required for THIRD_PARTY type files", ); } // Validate carBodyForm is provided for CAR_BODY type if (request.type === "CAR_BODY" && !formData.carBodyForm) { throw new BadRequestException( "CAR_BODY form is required for CAR_BODY type files", ); } // Validate phone numbers are different if ( request.type === "THIRD_PARTY" && formData.firstPartyPhoneNumber === formData.secondParty.phoneNumber ) { throw new BadRequestException( "First and second party phone numbers must be different", ); } // Validate guilty party phone number matches one of the parties if (request.type === "THIRD_PARTY") { const guiltyMatchesFirst = formData.guiltyPartyPhoneNumber === formData.firstPartyPhoneNumber; const guiltyMatchesSecond = formData.guiltyPartyPhoneNumber === formData.secondParty.phoneNumber; if (!guiltyMatchesFirst && !guiltyMatchesSecond) { throw new BadRequestException( "Guilty party phone number must match either first or second party phone number", ); } } try { // Get or create user for first party phone number const firstPartyUserId = await this.getOrCreateUserByPhoneNumber( formData.firstPartyPhoneNumber, ); // Collect all steps to be added const stepsToAdd: StepsEnum[] = [StepsEnum.F_InitialForm]; // Initialize firstPartyDetails structure completely const firstPartyDetails: any = { firstPartyId: firstPartyUserId, firstPartyPhoneNumber: formData.firstPartyPhoneNumber, firstPartyInitialForm: { expertOpinion: formData.firstPartyInitialForm.expertOpinion, imDamaged: formData.firstPartyInitialForm.imDamaged, imGuilty: formData.firstPartyInitialForm.imGuilty, }, firstPartyFile: { descriptions: [formData.firstPartyDescription.desc], }, }; // Get or create user for second party phone number const secondPartyUserId = await this.getOrCreateUserByPhoneNumber( formData.secondParty.phoneNumber, ); const secondPartyDetails: any = { secondPartyId: secondPartyUserId, secondPartyPhoneNumber: formData.secondParty.phoneNumber, secondPartyInitialForm: { expertOpinion: formData.secondParty.initialForm.expertOpinion, imDamaged: formData.secondParty.initialForm.imDamaged, imGuilty: formData.secondParty.initialForm.imGuilty, }, secondPartyFiles: { descriptions: [formData.secondParty.description.desc], }, }; // Build comprehensive update payload const updatePayload: any = { $set: { filledBy: FilledBy.EXPERT, firstPartyDetails: firstPartyDetails, firstPartyLocation: formData.firstPartyLocation, secondPartyDetails: secondPartyDetails, secondPartyLocation: formData.secondParty.location, }, }; // Add steps for location and description stepsToAdd.push(StepsEnum.F_addLocation); stepsToAdd.push(StepsEnum.F_addDescription); stepsToAdd.push(StepsEnum.S_InitialForm); stepsToAdd.push(StepsEnum.S_addLocation); stepsToAdd.push(StepsEnum.S_addDescription); // Process first party plate with SandHub lookup const firstPartyPlate = formData.firstPartyPlate; try { const sandHubResponse = await this.sandHubService.getSandHubResponse({ plate: firstPartyPlate.plate, nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, }); const sandHubReport = sandHubResponse["_doc"] || sandHubResponse; const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; const companyCode = sandHubReport?.CompanyCode; // Try to find client by company code first (more reliable) const client = companyCode ? await this.clientService.findClientWithCompanyCode(+companyCode) : await this.clientService.findOne({ clientName: clientName }); if (!client) { throw new NotFoundException( `Client not found for company: ${clientName}`, ); } // Add first party plate and related data to the existing firstPartyDetails object firstPartyDetails.nationalCodeOfInsurer = firstPartyPlate.nationalCodeOfInsurer; firstPartyDetails.firstPartyPlate = firstPartyPlate.plate; firstPartyDetails.firstPartyCarDetail = { carName: sandHubReport?.MapTypNam || sandHubReport?.CarName, insurerBirthday: firstPartyPlate.insurerBirthday, driverBirthday: firstPartyPlate.driverBirthday, carType: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`, isNewCar: firstPartyPlate.isNewCar, }; firstPartyDetails.firstPartyClient = { clientId: (client as any)["_doc"]?._id || (client as any)._id, clientName: clientName, }; firstPartyDetails.firstPartyCertificate = firstPartyPlate.userNoCertificate; firstPartyDetails.firstPartyInsuranceDetail = { insuranceId: sandHubReport?.LastCompanyDocumentNumber || sandHubReport?.DocumentNumber, insuranceCompany: clientName, financialCommitmentCeiling: sandHubReport?.FinancialCvrCptl || sandHubReport?.FinancialCommitment, startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate, endDate: sandHubReport?.EndDate, }; // Update the firstPartyDetails in the payload with all plate data updatePayload.$set.firstPartyDetails = firstPartyDetails; stepsToAdd.push(StepsEnum.F_addPlate); } catch (plateError) { this.logger.error("Error processing first party plate:", plateError); throw new InternalServerErrorException( "Failed to process first party plate information", ); } // Process second party plate try { const secondPartyPlate = formData.secondParty.plate; const sandHubResponse = await this.sandHubService.getSandHubResponse({ plate: secondPartyPlate.plate, nationalCodeOfInsurer: secondPartyPlate.nationalCodeOfInsurer, }); const sandHubReport = sandHubResponse["_doc"] || sandHubResponse; const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; const companyCode = sandHubReport?.CompanyCode; // Try to find client by company code first (more reliable) const client = companyCode ? await this.clientService.findClientWithCompanyCode(+companyCode) : await this.clientService.findOne({ clientName: clientName }); if (!client) { throw new NotFoundException( `Client not found for company: ${clientName}${companyCode ? ` (code: ${companyCode})` : ""}`, ); } // Add second party plate and related data to the existing secondPartyDetails object secondPartyDetails.nationalCodeOfInsurer = secondPartyPlate.nationalCodeOfInsurer; secondPartyDetails.secondPartyPlate = secondPartyPlate.plate; secondPartyDetails.secondPartyCarDetail = { carName: sandHubReport?.MapTypNam || sandHubReport?.CarName, insurerBirthday: secondPartyPlate.insurerBirthday, driverBirthday: secondPartyPlate.driverBirthday, carType: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`, isNewCar: secondPartyPlate.isNewCar, }; secondPartyDetails.secondPartyClient = { clientId: (client as any)["_doc"]?._id || (client as any)._id, clientName: clientName, }; secondPartyDetails.secondPartyCertificate = secondPartyPlate.userNoCertificate; secondPartyDetails.secondPartyInsuranceDetail = { insuranceId: sandHubReport?.LastCompanyDocumentNumber || sandHubReport?.DocumentNumber, insuranceCompany: clientName, financialCommitmentCeiling: sandHubReport?.FinancialCvrCptl || sandHubReport?.FinancialCommitment, startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate, endDate: sandHubReport?.EndDate, }; // Update the secondPartyDetails in the payload updatePayload.$set.secondPartyDetails = secondPartyDetails; stepsToAdd.push(StepsEnum.S_addPlate); } catch (plateError) { this.logger.error("Error processing second party plate:", plateError); throw new InternalServerErrorException( "Failed to process second party plate information", ); } // Determine which party is guilty based on phone number const guiltyMatchesFirst = formData.guiltyPartyPhoneNumber === formData.firstPartyPhoneNumber; const guiltyUserId = guiltyMatchesFirst ? firstPartyUserId : secondPartyDetails.secondPartyId; // Create initial expertSubmitReply with guiltyUserId // Accident fields can be added separately via add-accident-fields endpoint // This allows the file to proceed directly to signatures without expert review updatePayload.$set.expertSubmitReply = { description: "Expert completed all information on-site. Awaiting signatures.", guiltyUserId: guiltyUserId, submitTime: new Date(), fields: { accidentWay: { id: "", label: "To be determined", }, accidentReason: { id: "", label: "To be determined", fanavaran: 0, }, accidentType: { id: "", label: "To be determined", }, }, firstPartyComment: null, secondPartyComment: null, }; updatePayload.$set.currentStep = StepsEnum.S_addDescription; updatePayload.$set.nextStep = StepsEnum.AddSignatures; updatePayload.$set.blameStatus = ReqBlameStatus.WaitingForSignatures; // Add all steps at once using $each to push multiple values if (stepsToAdd.length > 0) { updatePayload.$push = { steps: { $each: stepsToAdd }, }; } // Execute the update await this.requestManagementDbService.findAndUpdate( { _id: requestId }, updatePayload, ); // Ensure expert assignment await this.ensureExpertAssignment(requestId); // Add history events await this.addHistoryEvent( requestId, "EXPERT_COMPLETED_ALL_INFORMATION", new Types.ObjectId(expert.sub), undefined, "expert", { type: "THIRD_PARTY", hasSecondParty: true, }, ); await this.addHistoryEvent( requestId, "READY_FOR_SIGNATURES", new Types.ObjectId(expert.sub), undefined, "system", ); // Read updated request const updatedRequest = await this.requestManagementDbService.findOne( requestId, ); return new RequestManagementDtoRs( updatedRequest.firstPartyDetails?.firstPartyId, updatedRequest?.secondPartyDetails?.secondPartyId || null, StepsEnum.S_addDescription, StepsEnum.AddSignatures, "All information completed. Waiting for user signatures.", updatedRequest._id.toString(), ); } catch (error) { this.logger.error("Error in expertCompleteForm:", error); if (error instanceof HttpException) { throw error; } throw new InternalServerErrorException( "Failed to complete form. Please try again.", ); } } /** * Expert completes all information for IN_PERSON CAR_BODY file at once * This replaces the step-by-step flow for experts filling files on-site */ async expertCompleteCarBodyForm( expert: any, requestId: string, formData: any, // ExpertCompleteCarBodyFormDto ): Promise { const request = await this.requestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Request not found"); } // Verify this is an expert-initiated IN_PERSON CAR_BODY file if (!request.expertInitiated) { throw new BadRequestException( "This endpoint is only for expert-initiated files", ); } if (request.creationMethod !== CreationMethod.IN_PERSON) { throw new BadRequestException( "This endpoint is only for IN_PERSON files. LINK-based files should be completed by users step-by-step.", ); } if (request.type !== "CAR_BODY") { throw new BadRequestException( "This endpoint is only for CAR_BODY type files. Use complete-form-third-party for THIRD_PARTY files.", ); } // Verify expert access await this.verifyExpertAccess(request, expert); // Validate carBodyForm is provided if (!formData.carBodyForm) { throw new BadRequestException( "CAR_BODY form is required for CAR_BODY type files", ); } try { // Get or create user for first party phone number const firstPartyUserId = await this.getOrCreateUserByPhoneNumber( formData.firstPartyPhoneNumber, ); // Collect all steps to be added const stepsToAdd: StepsEnum[] = [StepsEnum.CarBodyForm]; // Initialize firstPartyDetails structure completely const firstPartyDetails: any = { firstPartyId: firstPartyUserId, firstPartyPhoneNumber: formData.firstPartyPhoneNumber, firstPartyInitialForm: { expertOpinion: formData.firstPartyInitialForm.expertOpinion, imDamaged: formData.firstPartyInitialForm.imDamaged, imGuilty: formData.firstPartyInitialForm.imGuilty, }, firstPartyFile: { descriptions: [formData.firstPartyDescription.desc], }, carBodyForm: { firstPartyId: firstPartyUserId, firstPartyPhoneNumber: formData.firstPartyPhoneNumber, carBodyForm: formData.carBodyForm, // Assume guilty by default if accident was with another car isGuilty: formData.carBodyForm.car ? true : false, }, }; // CAR_BODY specific fields if (formData.firstPartyDescription.accidentDate) { firstPartyDetails.firstPartyFile.accidentDate = formData.firstPartyDescription.accidentDate; } if (formData.firstPartyDescription.accidentTime) { firstPartyDetails.firstPartyFile.accidentTime = formData.firstPartyDescription.accidentTime; } if (formData.firstPartyDescription.weatherCondition) { firstPartyDetails.firstPartyFile.weatherCondition = formData.firstPartyDescription.weatherCondition; } if (formData.firstPartyDescription.roadCondition) { firstPartyDetails.firstPartyFile.roadCondition = formData.firstPartyDescription.roadCondition; } if (formData.firstPartyDescription.lightCondition) { firstPartyDetails.firstPartyFile.lightCondition = formData.firstPartyDescription.lightCondition; } // Build comprehensive update payload const updatePayload: any = { $set: { filledBy: FilledBy.EXPERT, firstPartyDetails: firstPartyDetails, firstPartyLocation: formData.firstPartyLocation, }, }; // Add steps for location and description stepsToAdd.push(StepsEnum.F_addLocation); stepsToAdd.push(StepsEnum.F_addDescription); // Process first party plate with SandHub lookup const firstPartyPlate = formData.firstPartyPlate; try { const sandHubResponse = await this.sandHubService.getSandHubResponse({ plate: firstPartyPlate.plate, nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, }); const sandHubReport = sandHubResponse["_doc"] || sandHubResponse; const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; const companyCode = sandHubReport?.CompanyCode; // Try to find client by company code first (more reliable) const client = companyCode ? await this.clientService.findClientWithCompanyCode(+companyCode) : await this.clientService.findOne({ clientName: clientName }); if (!client) { throw new NotFoundException( `Client not found for company: ${clientName}`, ); } // Add first party plate and related data to the existing firstPartyDetails object firstPartyDetails.nationalCodeOfInsurer = firstPartyPlate.nationalCodeOfInsurer; firstPartyDetails.firstPartyPlate = firstPartyPlate.plate; firstPartyDetails.firstPartyCarDetail = { carName: sandHubReport?.MapTypNam || sandHubReport?.CarName, insurerBirthday: firstPartyPlate.insurerBirthday, driverBirthday: firstPartyPlate.driverBirthday, carType: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`, isNewCar: firstPartyPlate.isNewCar, }; firstPartyDetails.firstPartyClient = { clientId: (client as any)["_doc"]?._id || (client as any)._id, clientName: clientName, }; firstPartyDetails.firstPartyCertificate = firstPartyPlate.userNoCertificate; firstPartyDetails.firstPartyInsuranceDetail = { insuranceId: sandHubReport?.LastCompanyDocumentNumber || sandHubReport?.DocumentNumber, insuranceCompany: clientName, financialCommitmentCeiling: sandHubReport?.FinancialCvrCptl || sandHubReport?.FinancialCommitment, startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate, endDate: sandHubReport?.EndDate, }; // CAR_BODY specific insurance info const carBodyInfo = await this.mockCarBodyInsuranceInquiry( request._id.toString(), firstPartyPlate.plate, firstPartyPlate.nationalCodeOfInsurer, ); firstPartyDetails.firstPartyCarBodyInsuranceDetail = { policyNumber: carBodyInfo.policyNumber, startDate: carBodyInfo.startDate, endDate: carBodyInfo.endDate, insurerCompany: carBodyInfo.insurerCompany, coverages: carBodyInfo.coverages, }; // Update the firstPartyDetails in the payload with all plate data updatePayload.$set.firstPartyDetails = firstPartyDetails; stepsToAdd.push(StepsEnum.F_addPlate); } catch (plateError) { this.logger.error("Error processing first party plate:", plateError); throw new InternalServerErrorException( "Failed to process first party plate information", ); } // For CAR_BODY: Create expertSubmitReply // First party is guilty by default if accident was with another car const guiltyUserId = firstPartyUserId; // Accident fields can be added separately via add-accident-fields endpoint updatePayload.$set.expertSubmitReply = { description: "Expert completed all information on-site. Awaiting signature.", guiltyUserId: guiltyUserId, submitTime: new Date(), fields: { accidentWay: { id: "", label: "To be determined", }, accidentReason: { id: "", label: "To be determined", fanavaran: 0, }, accidentType: { id: "", label: "To be determined", }, }, firstPartyComment: null, secondPartyComment: null, }; updatePayload.$set.currentStep = StepsEnum.F_addDescription; updatePayload.$set.nextStep = StepsEnum.AddSignatures; updatePayload.$set.blameStatus = ReqBlameStatus.WaitingForSignatures; // Add all steps at once using $each to push multiple values if (stepsToAdd.length > 0) { updatePayload.$push = { steps: { $each: stepsToAdd }, }; } // Execute the update await this.requestManagementDbService.findAndUpdate( { _id: requestId }, updatePayload, ); // Ensure expert assignment await this.ensureExpertAssignment(requestId); // Add history events await this.addHistoryEvent( requestId, "EXPERT_COMPLETED_ALL_INFORMATION", new Types.ObjectId(expert.sub), undefined, "expert", { type: "CAR_BODY", hasSecondParty: false, }, ); await this.addHistoryEvent( requestId, "READY_FOR_SIGNATURES", new Types.ObjectId(expert.sub), undefined, "system", ); // Read updated request const updatedRequest = await this.requestManagementDbService.findOne( requestId, ); return new RequestManagementDtoRs( updatedRequest.firstPartyDetails?.firstPartyId, null, StepsEnum.F_addDescription, StepsEnum.AddSignatures, "All information completed. Waiting for user signature.", updatedRequest._id.toString(), ); } catch (error) { this.logger.error("Error in expertCompleteCarBodyForm:", error); if (error instanceof HttpException) { throw error; } throw new InternalServerErrorException( "Failed to complete form. Please try again.", ); } } /** * Expert uploads video for expert-initiated file * Only one video is needed per file (not per party) */ async expertUploadVideo( expert: any, requestId: string, file: Express.Multer.File, ): Promise { if (!file) { throw new BadRequestException("Video file is required"); } const request = await this.requestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Request not found"); } // Verify this is an expert-initiated file if (!request.expertInitiated) { throw new BadRequestException( "This endpoint is only for expert-initiated files", ); } // Verify expert access await this.verifyExpertAccess(request, expert); // Check if video already exists const stepToCheck = request.type === "THIRD_PARTY" ? StepsEnum.F_videoUpload : StepsEnum.CarBodyVideo; if (request.steps?.includes(stepToCheck)) { throw new BadRequestException("Video has already been uploaded for this file"); } try { // Create video document const videoDocument = await this.blameVideoDbService.create({ fileName: file.filename, path: file.path, requestId: new Types.ObjectId(requestId), }); // Determine the step based on request type const stepToPush = request.type === "THIRD_PARTY" ? StepsEnum.F_videoUpload : StepsEnum.CarBodyVideo; const currentStepValue = stepToPush; const nextStepValue = StepsEnum.F_addPlate; // Update request with video await this.requestManagementDbService.findAndUpdate( { _id: requestId }, { $push: { steps: stepToPush }, $set: { currentStep: currentStepValue, "firstPartyDetails.firstPartyFile.firstPartyVideoId": videoDocument["_doc"]._id, nextStep: nextStepValue, }, }, ); // Add history event await this.addHistoryEvent( requestId, "EXPERT_UPLOADED_VIDEO", new Types.ObjectId(expert.sub), undefined, "expert", { videoId: videoDocument["_doc"]._id.toString(), fileName: file.filename, }, ); return new RequestManagementDtoRs( request.firstPartyDetails?.firstPartyId || new Types.ObjectId(expert.sub), request?.secondPartyDetails?.secondPartyId || null, currentStepValue, nextStepValue, "Video uploaded successfully", request._id, ); } catch (error) { this.logger.error("Error uploading expert video:", error); if (error instanceof HttpException) { throw error; } throw new InternalServerErrorException("Failed to upload video"); } } /** * Expert uploads voice for expert-initiated file * Only one voice is needed per file (not per party) */ async expertUploadVoice( expert: any, requestId: string, voice: Express.Multer.File, ): Promise { if (!voice) { throw new BadRequestException("Voice file is required"); } const request = await this.requestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Request not found"); } // Verify this is an expert-initiated file if (!request.expertInitiated) { throw new BadRequestException( "This endpoint is only for expert-initiated files", ); } // Verify expert access await this.verifyExpertAccess(request, expert); // Check if voice already exists (check both first and second party steps) if ( request.steps?.includes(StepsEnum.F_addVoice) || request.steps?.includes(StepsEnum.S_addVoice) ) { throw new BadRequestException("Voice has already been uploaded for this file"); } try { // Create voice document const voiceDoc = await this.blameVoiceDbService.create({ fileName: voice.filename, path: voice.path, requestId: new Types.ObjectId(requestId), context: UploadContext.INITIAL, // Use INITIAL context for expert uploads }); // Determine current step and next step based on request state // If description is already added, next step is different const hasDescription = request.steps?.includes(StepsEnum.F_addDescription) || request.steps?.includes(StepsEnum.S_addDescription); const currentStep = request.type === "THIRD_PARTY" ? StepsEnum.F_addVoice : StepsEnum.F_addVoice; const nextStep = hasDescription ? (request.type === "THIRD_PARTY" ? StepsEnum.AddSignatures : StepsEnum.AddSignatures) : (request.type === "THIRD_PARTY" ? StepsEnum.F_addDescription : StepsEnum.F_addDescription); // Update request with voice // For expert uploads, we store it in firstPartyFile but it represents the entire file await this.requestManagementDbService.findAndUpdate( { _id: requestId }, { $push: { "firstPartyDetails.firstPartyFile.voices": voiceDoc["_id"], steps: StepsEnum.F_addVoice, }, $set: { currentStep: currentStep, nextStep: nextStep, }, }, ); // Add history event await this.addHistoryEvent( requestId, "EXPERT_UPLOADED_VOICE", new Types.ObjectId(expert.sub), undefined, "expert", { voiceId: voiceDoc["_id"].toString(), fileName: voice.filename, }, ); const message = hasDescription ? "Voice uploaded successfully. File is ready for signatures." : "Voice uploaded successfully. Please add description."; return new RequestManagementDtoRs( request.firstPartyDetails?.firstPartyId || new Types.ObjectId(expert.sub), request?.secondPartyDetails?.secondPartyId || null, currentStep, nextStep, message, request._id, ); } catch (error) { this.logger.error("Error uploading expert voice:", error); if (error instanceof HttpException) { throw error; } throw new InternalServerErrorException("Failed to upload voice"); } } /** * Expert adds accident fields to expert-initiated file * Can be used for both IN_PERSON and LINK-based files */ async expertAddAccidentFields( expert: any, requestId: string, fields: { accidentWay: any; accidentReason: any; accidentType: any }, ): Promise<{ message: string; requestId: string }> { const request = await this.requestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Request not found"); } // Verify this is an expert-initiated file if (!request.expertInitiated) { throw new BadRequestException( "This endpoint is only for expert-initiated files", ); } // Verify expert access await this.verifyExpertAccess(request, expert); // Determine which reply field to update const isObjection = !!request.expertResendReply; const replyField = isObjection ? "expertSubmitReplyFinal" : "expertSubmitReply"; // Get existing reply or create new structure const existingReply = request[replyField] || {}; const newReplyObject: any = { ...existingReply, fields: { accidentWay: fields.accidentWay, accidentReason: fields.accidentReason, accidentType: fields.accidentType, }, }; // Preserve other fields if they exist (for SubmitReply structure) if (existingReply && typeof existingReply === 'object') { if ('description' in existingReply) { newReplyObject.description = existingReply.description; } else { newReplyObject.description = "Expert on-site assessment"; } if ('guiltyUserId' in existingReply) { newReplyObject.guiltyUserId = existingReply.guiltyUserId; } else { newReplyObject.guiltyUserId = request.firstPartyDetails?.firstPartyId; } if ('submitTime' in existingReply) { newReplyObject.submitTime = existingReply.submitTime; } else { newReplyObject.submitTime = new Date(); } if ('firstPartyComment' in existingReply) { newReplyObject.firstPartyComment = existingReply.firstPartyComment; } else { newReplyObject.firstPartyComment = null; } if ('secondPartyComment' in existingReply) { newReplyObject.secondPartyComment = existingReply.secondPartyComment; } else { newReplyObject.secondPartyComment = null; } } else { // Create new structure if no existing reply newReplyObject.description = "Expert on-site assessment"; newReplyObject.guiltyUserId = request.firstPartyDetails?.firstPartyId; newReplyObject.submitTime = new Date(); newReplyObject.firstPartyComment = null; newReplyObject.secondPartyComment = null; } // Update the file await this.requestManagementDbService.findAndUpdate( { _id: requestId }, { $set: { [replyField]: newReplyObject, }, }, ); // Add history event await this.addHistoryEvent( requestId, "EXPERT_ADDED_ACCIDENT_FIELDS", new Types.ObjectId(expert.sub), undefined, "expert", { hasObjection: isObjection, }, ); return { message: "Accident fields added successfully", requestId: requestId, }; } /** * Check if request is expert-initiated and should be auto-assigned */ async ensureExpertAssignment(requestId: string): Promise { const request = await this.requestManagementDbService.findOne(requestId); if (request?.expertInitiated && request.initiatedBy) { // Ensure the file is assigned to the initiating expert if ( !request.assignedExpertId || String(request.assignedExpertId) !== String(request.initiatedBy) ) { await this.requestManagementDbService.findAndUpdate( { _id: requestId }, { $set: { assignedExpertId: request.initiatedBy, }, }, ); // Add history event await this.addHistoryEvent( requestId, "AUTO_ASSIGNED_TO_EXPERT", request.initiatedBy, undefined, "system", { expertId: request.initiatedBy.toString(), }, ); } } } /** * Get or create a user by phone number * Used for IN_PERSON expert-initiated files where expert provides phone numbers */ private async getOrCreateUserByPhoneNumber(phoneNumber: string): Promise { // Try to find existing user by phone number (username or mobile) let user = await this.userDbService.findOne({ $or: [ { username: phoneNumber }, { mobile: phoneNumber }, ], }); if (user) { return new Types.ObjectId((user as any)._id); } // User doesn't exist, create a new one const newUser = await this.userDbService.createUser({ mobile: phoneNumber, username: phoneNumber, otp: "", tokens: { token: "", rfToken: "", }, fullName: "", nationalCode: "", lastLogin: new Date(), clientKey: new Types.ObjectId(), birthDay: "", city: "", address: "", state: "", otpExpire: 0, }); return new Types.ObjectId((newUser as any)._id); } }