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 { UserResendResponseDto, UserResendUploadResponseDto } from "./dto/user-resend.dto"; import { UserSignatureResponseDto } from "./dto/user-signature.dto"; import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum"; import { buildResendItemsWithUi, isResendPartyItemSatisfied, } from "src/Types&Enums/blame-request-management/resend-item-ui"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum"; 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 { describeSmsError } from "src/utils/sms-manager/sms-provider.exception"; 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 { 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"; import { PublicIdService } from "src/utils/public-id/public-id.service"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { WorkflowStep } from "src/Types&Enums/blame-request-management/blameWorkflow-steps.enum"; import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; import { WorkflowStepDbService } from "src/workflow-step-management/entities/db-service/workflow-step.db.service"; import { WorkflowStepModel } from "src/workflow-step-management/entities/schema/workflow-step.schema"; import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum"; import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; import { UploadContext } from "./entities/schema/blame-voice.schema"; import { HashService } from "src/utils/hash/hash.service"; import { UserAuthService } from "src/auth/auth-services/user.auth.service"; import { buildMutualAgreementExpertDecision, MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS, } from "src/helpers/blame-party-agreement-decision"; @Injectable() export class RequestManagementService { private readonly logger = new Logger(RequestManagementService.name); private stepKeyToPartyRole(stepKey: WorkflowStep): PartyRole { if (String(stepKey).startsWith("FIRST_")) return PartyRole.FIRST; if (String(stepKey).startsWith("SECOND_")) return PartyRole.SECOND; throw new BadRequestException( `Step ${stepKey} is not a party-scoped step`, ); } /** Convert plate (string or { ir, leftDigits, centerAlphabet, centerDigits }) to string for vehicle.plateId */ private plateToPlateIdString(plate: any): string { if (plate == null) return ""; if (typeof plate === "string") return plate; const p = plate as { ir?: number; leftDigits?: number; centerAlphabet?: string; centerDigits?: number }; return [p.ir, p.leftDigits, p.centerAlphabet, p.centerDigits].filter((x) => x != null).join("-"); } private getPartyIndex(req: any, role: PartyRole): number { return Array.isArray(req.parties) ? req.parties.findIndex((p) => p?.role === role) : -1; } private assertPartyOwner(party: any, user: any, errMsg: string) { const partyUserId = party?.person?.userId ? String(party.person.userId) : null; const ok = (partyUserId && partyUserId === String(user?.sub)) || (party?.person?.phoneNumber && party.person.phoneNumber === user?.username); if (!ok) throw new ForbiddenException(errMsg); } private async advanceWorkflowToNext(req: any, submittedStepKey: WorkflowStep) { // CAR_BODY: after first-party description, move to signature step. if ( req.type === BlameRequestType.CAR_BODY && submittedStepKey === WorkflowStep.FIRST_DESCRIPTION ) { const completed = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; if (!completed.includes(submittedStepKey)) completed.push(submittedStepKey); req.workflow.completedSteps = completed; req.workflow.currentStep = WorkflowStep.WAITING_FOR_SIGNATURES; req.workflow.nextStep = undefined; req.status = CaseStatus.WAITING_FOR_SIGNATURES; this.logger.debug( "[WORKFLOW] CAR_BODY: advanced to WAITING_FOR_SIGNATURES after FIRST_DESCRIPTION", ); return; } const submittedStep = await this.getWorkflowStep({ stepKey: submittedStepKey }); this.logger.debug( `[WORKFLOW] Advancing from ${submittedStepKey} (stepNumber=${submittedStep.stepNumber})`, ); // completedSteps includes the step that was just submitted const completed = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; if (!completed.includes(submittedStepKey)) completed.push(submittedStepKey); req.workflow.completedSteps = completed; // Prefer sequential stepNumber, fallback to nextPossibleSteps for branching/end-of-flow const sequentialNextStep = await this.workflowStepDbService.findByStepNumber( submittedStep.stepNumber + 1, ); const nextStepKey = (sequentialNextStep?.stepKey ?? submittedStep.nextPossibleSteps?.[0]) as WorkflowStep | undefined; if (!nextStepKey) { // Terminal state: no more steps req.workflow.currentStep = submittedStepKey; req.workflow.nextStep = undefined; this.logger.debug( `[WORKFLOW] Reached terminal state at ${submittedStepKey}`, ); return; } // Try to get the next step doc; if it doesn't exist in DB, it's a "waiting state" (valid enum but not actionable) let nextStepDoc: WorkflowStepModel | null = null; try { nextStepDoc = sequentialNextStep ?? (await this.workflowStepDbService.findByStepKey(nextStepKey)); } catch (err) { // Step exists in enum but not in DB → it's a waiting/terminal state this.logger.warn( `[WORKFLOW] Step ${nextStepKey} not found in DB (waiting state). Setting as currentStep with no nextStep.`, ); req.workflow.currentStep = nextStepKey; req.workflow.nextStep = undefined; return; } if (!nextStepDoc) { // Same case: valid enum, not in DB this.logger.warn( `[WORKFLOW] Step ${nextStepKey} not found in DB (waiting state). Setting as currentStep with no nextStep.`, ); req.workflow.currentStep = nextStepKey; req.workflow.nextStep = undefined; return; } // For nextStep (what comes after currentStep), prefer sequential, fallback to nextPossibleSteps const sequentialAfterNext = await this.workflowStepDbService.findByStepNumber( nextStepDoc.stepNumber + 1, ); const afterNextKey = (sequentialAfterNext?.stepKey ?? nextStepDoc.nextPossibleSteps?.[0]) as WorkflowStep | undefined; req.workflow.currentStep = nextStepKey; req.workflow.nextStep = afterNextKey; this.logger.debug( `[WORKFLOW] Advanced to currentStep=${req.workflow.currentStep}, nextStep=${req.workflow.nextStep ?? "undefined"}`, ); } /** * Ensures CAR_BODY_ACCIDENT_TYPE step exists in DB (e.g. if initialize was not run after adding CAR_BODY flow). */ private async ensureCarBodyAccidentTypeStepExists(): Promise { const exists = await this.workflowStepDbService.exists( WorkflowStep.CAR_BODY_ACCIDENT_TYPE as any, ); if (exists) return; this.logger.log( "[WORKFLOW] CAR_BODY_ACCIDENT_TYPE step missing; upserting from default definition", ); await this.workflowStepDbService.upsertByStepKey( WorkflowStep.CAR_BODY_ACCIDENT_TYPE as any, { stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE, type: "CAR_BODY", stepNumber: 2, isActive: true, stepName_fa: "نوع حادثه بدنه", stepName_en: "Car Body Accident Type", description_fa: "انتخاب اینکه حادثه با خودرو بوده یا با شیء", description_en: "Select whether accident was with another car or with an object", category: "FIRST_PARTY", requiredPreviousSteps: [WorkflowStep.CREATED], nextPossibleSteps: [WorkflowStep.FIRST_VIDEO], allowedRoles: ["user"], estimatedDuration: 1, fields: [ { id: "507f1f77bcf86cd7994390cb" as any, name_fa: "نوع حادثه", name_en: "accidentType", label_fa: "نوع حادثه", label_en: "Accident Type", placeholder_fa: "حادثه با خودرو یا شیء؟", placeholder_en: "Accident with car or object?", value: [ { label_fa: "با خودرو", label_en: "With Car", value: "car" }, { label_fa: "با شیء", label_en: "With Object", value: "object" }, ], dataType: "select", required: true, order: 1, isActive: true, validation: { enum: ["car", "object"] }, }, ], metadata: { icon: "car", color: "#10B981" }, } as any, ); } private async getWorkflowStep(params: { stepNumber?: number; stepKey?: WorkflowStep; }): Promise { const { stepNumber, stepKey } = params; if (typeof stepNumber !== "number" && !stepKey) { throw new BadRequestException("stepNumber or stepKey is required"); } const stepByNumber = typeof stepNumber === "number" ? await this.workflowStepDbService.findByStepNumber(stepNumber) : null; const stepByKey = stepKey ? await this.workflowStepDbService.findByStepKey(stepKey) : null; const step = stepByNumber ?? stepByKey; if (!step) { throw new InternalServerErrorException( `Workflow step not found (${stepNumber ? `stepNumber=${stepNumber}` : ""}${stepNumber && stepKey ? ", " : "" }${stepKey ? `stepKey=${stepKey}` : ""})`, ); } // If caller passed both, ensure they resolve to the same step. if (stepByNumber && stepByKey && stepByNumber.stepKey !== stepByKey.stepKey) { throw new InternalServerErrorException( `Workflow step mismatch: stepNumber=${stepNumber} is ${stepByNumber.stepKey}, but stepKey=${stepKey} is stepNumber=${stepByKey.stepNumber}`, ); } return step; } constructor( private readonly requestManagementDbService: RequestManagementDbService, private readonly blameRequestDbService: BlameRequestDbService, 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, private readonly claimCaseDbService: ClaimCaseDbService, private readonly publicIdService: PublicIdService, private readonly workflowStepDbService: WorkflowStepDbService, private readonly hashService: HashService, private readonly userAuthService: UserAuthService, ) { } /** * Linked claim cases: block user on damage flow while blame awaits document resend. */ async applyLinkedClaimsBlameResendStarted(blameRequestId: string): Promise { const oid = new Types.ObjectId(blameRequestId); const claims = await this.claimCaseDbService.find({ blameRequestId: oid, }); for (const c of claims as any[]) { const st = c.status as ClaimCaseStatus; if ( st === ClaimCaseStatus.COMPLETED || st === ClaimCaseStatus.CANCELLED || st === ClaimCaseStatus.REJECTED ) { continue; } await this.claimCaseDbService.findByIdAndUpdate(c._id, { $set: { blameDocumentResendPending: true, claimStatus: ClaimStatus.NEEDS_REVISION, }, $push: { history: { type: "BLAME_DOCUMENT_RESEND_STARTED", timestamp: new Date(), metadata: { blameRequestId }, }, }, }); } } async applyLinkedClaimsBlameResendCleared(blameRequestId: string): Promise { const oid = new Types.ObjectId(blameRequestId); const claims = await this.claimCaseDbService.find({ blameRequestId: oid, }); for (const c of claims as any[]) { if (!c.blameDocumentResendPending) { continue; } await this.claimCaseDbService.findByIdAndUpdate(c._id, { $set: { blameDocumentResendPending: false, claimStatus: ClaimStatus.PENDING, }, $push: { history: { type: "BLAME_DOCUMENT_RESEND_CLEARED", timestamp: new Date(), metadata: { blameRequestId }, }, }, }); } } async createRequestV2(user: any, type: BlameRequestType) { const firstStep = await this.getWorkflowStep({ stepNumber: 1 }); if (!firstStep?.stepKey) { throw new InternalServerErrorException( "Workflow stepNumber=1 not configured in step manager", ); } const nextStepFromManager = firstStep.nextPossibleSteps?.[0]; if (!nextStepFromManager) { throw new InternalServerErrorException( "Workflow stepNumber=1 has no nextPossibleSteps configured", ); } // CAR_BODY skips confession and goes to accident-type form const nextStep = type === BlameRequestType.CAR_BODY ? (WorkflowStep.CAR_BODY_ACCIDENT_TYPE as WorkflowStep) : (nextStepFromManager as WorkflowStep); const publicId = await this.publicIdService.generateRequestPublicId(); const created = await this.blameRequestDbService.create({ // we keep both fields for now; the shared spoken id is `publicId` publicId, requestNo: publicId, type, parties: [ { role: PartyRole.FIRST, person: { userId: Types.ObjectId.isValid(user?.sub) ? new Types.ObjectId(user.sub) : undefined, clientId: undefined, phoneNumber: user?.username, fullName: user?.fullName, }, }, ], workflow: { currentStep: firstStep.stepKey as WorkflowStep, nextStep, completedSteps: [firstStep.stepKey as WorkflowStep], locked: false, }, history: [], }); return { requestId: created._id, publicId }; } async blameConfessionV2( requestId: string, body: { imGuilty?: boolean; imDamaged?: boolean; expertOpinion?: boolean }, user: any, ) { const step2 = await this.getWorkflowStep({ stepNumber: 2 }); const step2Key = step2.stepKey as WorkflowStep; const nextStepFromManager = step2.nextPossibleSteps?.[0]; if (!nextStepFromManager) { throw new InternalServerErrorException( "Workflow stepNumber=2 has no nextPossibleSteps configured", ); } const req = await this.blameRequestDbService.findById(requestId); if (!req) { throw new NotFoundException("Request not found"); } if (!req.workflow?.nextStep) { throw new BadRequestException("Request workflow is not initialized"); } // Validate step ordering: only allow this API when nextStep == step #2 key if (req.workflow.nextStep !== step2Key) { throw new BadRequestException( `Invalid step. Expected nextStep=${step2Key} but got ${req.workflow.nextStep}`, ); } const firstPartyIndex = Array.isArray(req.parties) ? req.parties.findIndex((p) => p?.role === PartyRole.FIRST) : -1; if (firstPartyIndex === -1) { throw new BadRequestException("First party not found on request"); } const firstParty = req.parties[firstPartyIndex]; const firstPartyUserId = firstParty?.person?.userId ? String(firstParty.person.userId) : null; const isOwner = (firstPartyUserId && firstPartyUserId === String(user?.sub)) || (firstParty?.person?.phoneNumber && firstParty.person.phoneNumber === user?.username); if (!isOwner) { throw new ForbiddenException("Only first party can submit this step"); } // Set userId for first party if not already set if (!firstParty.person) firstParty.person = {} as any; if (!firstParty.person.userId && user?.sub) { firstParty.person.userId = Types.ObjectId.isValid(user.sub) ? new Types.ObjectId(user.sub) : undefined; } const imGuilty = !!body?.imGuilty; const imDamaged = !!body?.imDamaged; const expertOpinion = !!body?.expertOpinion; let blameStatus: BlameStatus; let resolvedExpertOpinion = false; if (imGuilty || imDamaged) { blameStatus = BlameStatus.AGREED; resolvedExpertOpinion = false; } else if (expertOpinion) { blameStatus = BlameStatus.DISAGREEMENT; resolvedExpertOpinion = true; } else { throw new BadRequestException( "Invalid body: set imGuilty or imDamaged for AGREED, or expertOpinion for DISAGREEMENT", ); } // Persist into first party statement (new model) if (!firstParty.statement) { firstParty.statement = {} as any; } firstParty.statement.admitsGuilt = imGuilty; firstParty.statement.claimsDamage = imDamaged; firstParty.statement.acceptsExpertOpinion = resolvedExpertOpinion; // Update top-level blameStatus req.blameStatus = blameStatus; // Advance workflow // completedSteps should include the step that was just submitted (step2Key), // but currentStep should move forward to the next step to be done. const completed = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; if (!completed.includes(step2Key)) { completed.push(step2Key); } req.workflow.completedSteps = completed; const nextStepKey = nextStepFromManager as WorkflowStep; // e.g. FIRST_VIDEO const nextStepDoc = await this.getWorkflowStep({ stepKey: nextStepKey }); const afterNext = nextStepDoc.nextPossibleSteps?.[0]; if (!afterNext) { throw new InternalServerErrorException( `Workflow step ${nextStepKey} has no nextPossibleSteps configured`, ); } req.workflow.currentStep = nextStepKey; req.workflow.nextStep = afterNext as WorkflowStep; // e.g. FIRST_INITIAL_FORM // History if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "FIRST_BLAME_CONFESSION_SUBMITTED", actor: { actorId: Types.ObjectId.isValid(user?.sub) ? new Types.ObjectId(user.sub) : undefined, actorName: user?.fullName, actorType: "user", }, metadata: { imGuilty, imDamaged, expertOpinion: resolvedExpertOpinion, blameStatus, stepKey: step2Key, }, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId, blameStatus: req.blameStatus, workflow: req.workflow, }; } /** * V2 CAR_BODY: Submit accident type form (car vs object). Replaces confession step for CAR_BODY. */ async carBodyAccidentTypeFormV2( requestId: string, body: { car?: boolean; object?: boolean }, user: any, ) { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if (req.type !== BlameRequestType.CAR_BODY) { throw new BadRequestException("This endpoint is only for CAR_BODY type requests"); } if (!req.workflow?.nextStep) { throw new BadRequestException("Request workflow is not initialized"); } if (req.workflow.nextStep !== WorkflowStep.CAR_BODY_ACCIDENT_TYPE) { throw new BadRequestException( `Invalid step. Expected nextStep=CAR_BODY_ACCIDENT_TYPE but got ${req.workflow.nextStep}`, ); } const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST); if (firstPartyIndex === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstPartyIndex]; this.assertPartyOwner(firstParty, user, "Only first party can submit this step"); if (!firstParty.person) firstParty.person = {} as any; if (!firstParty.person.userId && user?.sub) { firstParty.person.userId = Types.ObjectId.isValid(user.sub) ? new Types.ObjectId(user.sub) : undefined; } const car = !!body?.car; const object = !!body?.object; if (!car && !object) { throw new BadRequestException("Set car or object to true"); } (firstParty as any).carBodyFirstForm = { car, object }; req.blameStatus = BlameStatus.AGREED; await this.ensureCarBodyAccidentTypeStepExists(); await this.advanceWorkflowToNext(req, WorkflowStep.CAR_BODY_ACCIDENT_TYPE); if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "CAR_BODY_ACCIDENT_TYPE_SUBMITTED", actor: { actorId: Types.ObjectId.isValid(user?.sub) ? new Types.ObjectId(user.sub) : undefined, actorName: user?.fullName, actorType: "user", }, metadata: { car, object }, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId, workflow: req.workflow, blameStatus: req.blameStatus, }; } async uploadFirstPartyVideoV2( requestId: string, file: Express.Multer.File, user: any, ) { if (!file) { throw new BadRequestException("File is required"); } const step3 = await this.getWorkflowStep({ stepNumber: 3 }); const step3Key = step3.stepKey as WorkflowStep; // expected: FIRST_VIDEO const req = await this.blameRequestDbService.findById(requestId); if (!req) { throw new NotFoundException("Request not found"); } if (!req.workflow?.currentStep) { throw new BadRequestException("Request workflow is not initialized"); } // Validate step: currentStep must match stepNumber=3 (video) if (req.workflow.currentStep !== step3Key) { throw new BadRequestException( `Invalid step. Expected currentStep=${step3Key} but got ${req.workflow.currentStep}`, ); } const firstPartyIndex = Array.isArray(req.parties) ? req.parties.findIndex((p) => p?.role === PartyRole.FIRST) : -1; if (firstPartyIndex === -1) { throw new BadRequestException("First party not found on request"); } const firstParty = req.parties[firstPartyIndex]; const firstPartyUserId = firstParty?.person?.userId ? String(firstParty.person.userId) : null; const isOwner = (firstPartyUserId && firstPartyUserId === String(user?.sub)) || (firstParty?.person?.phoneNumber && firstParty.person.phoneNumber === user?.username); if (!isOwner) { throw new ForbiddenException("Only first party can upload this video"); } if (firstParty?.evidence?.videoId) { throw new ConflictException("First party video already uploaded"); } const videoDocument = await this.blameVideoDbService.create({ fileName: file.filename, path: file.path, requestId: new Types.ObjectId(req._id), } as any); if (!firstParty.evidence) { firstParty.evidence = {} as any; } firstParty.evidence.videoId = String((videoDocument as any)._id); // Advance workflow const completed = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; if (!completed.includes(step3Key)) { completed.push(step3Key); } req.workflow.completedSteps = completed; const nextStepKey = step3.nextPossibleSteps?.[0] as WorkflowStep | undefined; if (!nextStepKey) { throw new InternalServerErrorException( "Workflow stepNumber=3 has no nextPossibleSteps configured", ); } const nextStepDoc = await this.getWorkflowStep({ stepKey: nextStepKey }); const afterNext = nextStepDoc.nextPossibleSteps?.[0]; if (!afterNext) { throw new InternalServerErrorException( `Workflow step ${nextStepKey} has no nextPossibleSteps configured`, ); } req.workflow.currentStep = nextStepKey; req.workflow.nextStep = afterNext as WorkflowStep; // History if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "FIRST_VIDEO_UPLOADED", actor: { actorId: Types.ObjectId.isValid(user?.sub) ? new Types.ObjectId(user.sub) : undefined, actorName: user?.fullName, actorType: "user", }, metadata: { stepKey: step3Key, videoId: firstParty.evidence.videoId, fileName: file.filename, }, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId, workflow: req.workflow, videoId: firstParty.evidence.videoId, }; } async initialFormV2(requestId: string, body: AddPlateDto, user: any) { const req = await this.blameRequestDbService.findById(requestId); if (!req) { throw new NotFoundException("Request not found"); } if (!req.workflow?.currentStep) { throw new BadRequestException("Request workflow is not initialized"); } const stepKey = req.workflow.currentStep as WorkflowStep; if ( stepKey !== WorkflowStep.FIRST_INITIAL_FORM && stepKey !== WorkflowStep.SECOND_INITIAL_FORM ) { throw new BadRequestException( `Invalid step. Expected FIRST_INITIAL_FORM or SECOND_INITIAL_FORM but got ${stepKey}`, ); } // Detect which party from step const role = this.stepKeyToPartyRole(stepKey); const idx = this.getPartyIndex(req, role); if (idx === -1) { throw new BadRequestException(`${role} party not found on request`); } const party = req.parties[idx]; this.assertPartyOwner(party, user, `Only ${role} party can submit this step`); // Set userId for party if not already set (important for second party) if (!party.person) party.person = {} as any; if (!party.person.userId && user?.sub) { party.person.userId = Types.ObjectId.isValid(user.sub) ? new Types.ObjectId(user.sub) : undefined; } // Validation: driver/insurer sameness rules if (body.driverIsInsurer === false) { if ( body.nationalCodeOfInsurer === body.nationalCodeOfDriver || body.insurerLicense === body.driverLicense ) { throw new BadRequestException( "Insurer and Driver should be two different persons in this mode.", ); } } else if (body.driverIsInsurer === true) { const sameNat = body.nationalCodeOfInsurer === body.nationalCodeOfDriver; const sameLic = body.insurerLicense === body.driverLicense; const sameBirthday = body.driverBirthday == null || String(body.driverBirthday) === String(body.insurerBirthday); if (!sameNat || !sameLic || !sameBirthday) { throw new BadRequestException( "When driverIsInsurer is true, insurer and driver data must be the same.", ); } } // ---- External inquiry 1: Tejarat block inquiry ---- let inquiryRaw: any; let inquiryMapped: any; try { const inquiry = await this.sandHubService.getTejaratBlockInquiry({ plate: body.plate, nationalCodeOfInsurer: body.nationalCodeOfInsurer, }); inquiryRaw = inquiry.raw; inquiryMapped = inquiry.mapped; this.logger.log( `[TEJARAT] block inquiry raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`, ); this.logger.log( `[TEJARAT] block inquiry mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`, ); } catch (err: any) { this.logger.error( `[TEJARAT] block inquiry failed for request=${req._id}: ${err?.message || err}`, ); if (err?.response) { this.logger.error( `[TEJARAT] block inquiry response for request=${req._id}: status=${err.response.status }, data=${JSON.stringify(err.response.data)}`, ); } throw new HttpException( "Inquiry failed", HttpStatus.BAD_REQUEST, ); } if (inquiryMapped?.Error) { this.logger.warn( `[TEJARAT] block inquiry error for request=${req._id}: ${JSON.stringify( inquiryMapped.Error, )}`, ); throw new HttpException( inquiryMapped.Error.Message || "Inquiry returned error", HttpStatus.BAD_REQUEST, ); } // NOTE: personal inquiry is not part of Tejarat block inquiry flow. // Find client by company code const clientName = inquiryMapped?.CompanyName; const companyCode = inquiryMapped?.CompanyCode; const client = await this.clientService.findClientWithCompanyCode( +companyCode, ); if (!client) { throw new HttpException("Client not found", HttpStatus.CONFLICT); } // Persist inquiry/body data into new model if (!party.person) party.person = {} as any; const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; party.person.clientId = resolvedClientId; party.person.nationalCodeOfInsurer = body.nationalCodeOfInsurer; party.person.nationalCodeOfDriver = body.nationalCodeOfDriver; party.person.insurerLicense = body.insurerLicense; party.person.driverLicense = body.driverLicense; party.person.driverIsInsurer = body.driverIsInsurer; party.person.isNewCar = body.isNewCar; party.person.userNoCertificate = body.userNoCertificate; party.person.insurerBirthday = body.insurerBirthday; party.person.driverBirthday = body.driverBirthday ?? (body.driverIsInsurer ? String(body.insurerBirthday) : null); if (!party.vehicle) party.vehicle = {} as any; party.vehicle.isNew = body.isNewCar; party.vehicle.plateId = typeof body.plateId === "string" ? body.plateId : this.plateToPlateIdString(body.plate); party.vehicle.name = inquiryMapped?.MapTypNam; party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`; party.vehicle.inquiry = { source: "TEJARAT_BLOCK_INQUIRY", raw: inquiryRaw, mapped: inquiryMapped, }; if (!party.insurance) party.insurance = {} as any; party.insurance.policyNumber = inquiryMapped?.LastCompanyDocumentNumber; party.insurance.company = clientName; party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; party.insurance.startDate = inquiryMapped?.IssueDate; party.insurance.endDate = inquiryMapped?.EndDate; // CAR_BODY: persist mocked car-body insurance in party's insurance (same place as usual inquiry) if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) { const carBodyInfo = await this.mockCarBodyInsuranceInquiry( String(req._id), body.plate, body.nationalCodeOfInsurer, ); (party.insurance as any).carBodyInsurance = carBodyInfo; } // Advance workflow await this.advanceWorkflowToNext(req, stepKey); // History if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: `${stepKey}_SUBMITTED`, actor: { actorId: Types.ObjectId.isValid(user?.sub) ? new Types.ObjectId(user.sub) : undefined, actorName: user?.fullName, actorType: "user", }, metadata: { role, companyCode, companyName: clientName, }, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId, workflow: req.workflow, blameStatus: req.blameStatus, status: req.status, }; } async addDetailLocationV2(requestId: string, body: LocationDto, user: any) { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if (!req.workflow?.currentStep) { throw new BadRequestException("Request workflow is not initialized"); } const stepKey = req.workflow.currentStep as WorkflowStep; if ( stepKey !== WorkflowStep.FIRST_LOCATION && stepKey !== WorkflowStep.SECOND_LOCATION ) { throw new BadRequestException( `Invalid step. Expected FIRST_LOCATION or SECOND_LOCATION but got ${stepKey}`, ); } const role = this.stepKeyToPartyRole(stepKey); const idx = this.getPartyIndex(req, role); if (idx === -1) throw new BadRequestException(`${role} party not found`); const party = req.parties[idx]; this.assertPartyOwner(party, user, "Only the related party can submit location"); // Set userId for party if not already set (important for second party) if (!party.person) party.person = {} as any; if (!party.person.userId && user?.sub) { party.person.userId = Types.ObjectId.isValid(user.sub) ? new Types.ObjectId(user.sub) : undefined; } party.location = body as any; await this.advanceWorkflowToNext(req, stepKey); if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: `${stepKey}_SUBMITTED`, actor: { actorId: Types.ObjectId.isValid(user?.sub) ? new Types.ObjectId(user.sub) : undefined, actorName: user?.fullName, actorType: "user", }, metadata: { location: body, role }, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId, workflow: req.workflow }; } async uploadVoiceV2( requestId: string, voice: Express.Multer.File, user: any, ) { if (!voice) throw new BadRequestException("File is required"); const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if (!req.workflow?.currentStep) { throw new BadRequestException("Request workflow is not initialized"); } const stepKey = req.workflow.currentStep as WorkflowStep; if ( stepKey !== WorkflowStep.FIRST_VOICE && stepKey !== WorkflowStep.SECOND_VOICE ) { throw new BadRequestException( `Invalid step. Expected FIRST_VOICE or SECOND_VOICE but got ${stepKey}`, ); } const role = this.stepKeyToPartyRole(stepKey); const idx = this.getPartyIndex(req, role); if (idx === -1) throw new BadRequestException(`${role} party not found`); const party = req.parties[idx]; this.assertPartyOwner(party, user, "Only the related party can upload voice"); // Set userId for party if not already set (important for second party) if (!party.person) party.person = {} as any; if (!party.person.userId && user?.sub) { party.person.userId = Types.ObjectId.isValid(user.sub) ? new Types.ObjectId(user.sub) : undefined; } const voiceDoc = await this.blameVoiceDbService.create({ fileName: voice.filename, path: voice.path, requestId: new Types.ObjectId(req._id), context: UploadContext.INITIAL, } as any); if (!party.evidence) party.evidence = {} as any; if (!Array.isArray(party.evidence.voices)) party.evidence.voices = []; party.evidence.voices.push(String((voiceDoc as any)._id)); await this.advanceWorkflowToNext(req, stepKey); if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: `${stepKey}_UPLOADED`, actor: { actorId: Types.ObjectId.isValid(user?.sub) ? new Types.ObjectId(user.sub) : undefined, actorName: user?.fullName, actorType: "user", }, metadata: { voiceId: String((voiceDoc as any)._id), fileName: voice.filename, role, }, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId, workflow: req.workflow, voiceId: String((voiceDoc as any)._id), }; } async addDescriptionV2( requestId: string, body: DescriptionDto, user: any, ) { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if (!req.workflow?.currentStep) { throw new BadRequestException("Request workflow is not initialized"); } const stepKey = req.workflow.currentStep as WorkflowStep; if ( stepKey !== WorkflowStep.FIRST_DESCRIPTION && stepKey !== WorkflowStep.SECOND_DESCRIPTION ) { throw new BadRequestException( `Invalid step. Expected FIRST_DESCRIPTION or SECOND_DESCRIPTION but got ${stepKey}`, ); } const role = this.stepKeyToPartyRole(stepKey); const idx = this.getPartyIndex(req, role); if (idx === -1) throw new BadRequestException(`${role} party not found`); const party = req.parties[idx]; this.assertPartyOwner(party, user, "Only the related party can submit description"); // Set userId for party if not already set (important for second party) if (!party.person) party.person = {} as any; if (!party.person.userId && user?.sub) { party.person.userId = Types.ObjectId.isValid(user.sub) ? new Types.ObjectId(user.sub) : undefined; } if (!party.statement) party.statement = {} as any; party.statement.description = body.desc; // THIRD_PARTY: only desc is accepted; reject CAR_BODY-only fields if (req.type === BlameRequestType.THIRD_PARTY) { const hasCarBodyFields = body.accidentDate != null || body.accidentTime != null || body.weatherCondition != null || body.roadCondition != null || body.lightCondition != null; if (hasCarBodyFields) { throw new BadRequestException( "THIRD_PARTY description step accepts only the desc field. Omit accidentDate, accidentTime, weatherCondition, roadCondition, and lightCondition.", ); } } else if (req.type === BlameRequestType.CAR_BODY) { // CAR_BODY: require desc + accident date/time and conditions if ( body.accidentDate == null || body.accidentTime == null || body.weatherCondition == null || body.roadCondition == null || body.lightCondition == null ) { throw new BadRequestException( "CAR_BODY description step requires desc, accidentDate, accidentTime, weatherCondition, roadCondition, and lightCondition.", ); } party.statement.accidentDate = body.accidentDate as any; party.statement.accidentTime = body.accidentTime; (party.statement as any).weatherCondition = body.weatherCondition; (party.statement as any).roadCondition = body.roadCondition; (party.statement as any).lightCondition = body.lightCondition; } const isSecondThirdParty = stepKey === WorkflowStep.SECOND_DESCRIPTION && req.type === BlameRequestType.THIRD_PARTY; let closedByMutualAgreement = false; // THIRD_PARTY + AGREED: guilt/damage already implied by first-party confession — no field expert needed. if (isSecondThirdParty && req.blameStatus === BlameStatus.AGREED) { const built = buildMutualAgreementExpertDecision( (req as any).toObject ? (req as any).toObject() : { ...req }, ); if (built) { if (!(req as any).expert) (req as any).expert = {}; (req as any).expert.decision = built as any; const completed = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; if (!completed.includes(stepKey)) completed.push(stepKey); if (!completed.includes(WorkflowStep.COMPLETED as any)) { completed.push(WorkflowStep.COMPLETED as any); } req.workflow.completedSteps = completed; req.workflow.currentStep = WorkflowStep.COMPLETED; req.workflow.nextStep = undefined; req.status = CaseStatus.COMPLETED; closedByMutualAgreement = true; } } if (!closedByMutualAgreement) { if (stepKey === WorkflowStep.SECOND_DESCRIPTION) { req.status = CaseStatus.WAITING_FOR_EXPERT; } await this.advanceWorkflowToNext(req, stepKey); if ( stepKey === WorkflowStep.SECOND_DESCRIPTION && req.type === BlameRequestType.THIRD_PARTY && !(req as any).expert?.decision?.guiltyPartyId ) { const built = buildMutualAgreementExpertDecision( (req as any).toObject ? (req as any).toObject() : { ...req }, ); if (built) { if (!(req as any).expert) (req as any).expert = {}; (req as any).expert.decision = built as any; } } } if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: `${stepKey}_SUBMITTED`, actor: { actorId: Types.ObjectId.isValid(user?.sub) ? new Types.ObjectId(user.sub) : undefined, actorName: user?.fullName, actorType: "user", }, metadata: { role, ...(closedByMutualAgreement ? { mutualAgreementCaseComplete: true, closedWithoutExpert: true } : {}), }, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId, workflow: req.workflow, status: req.status, }; } async addSecondPartyV2( requestId: string, phoneNumber: string, frontendRoute: string, user: any, ) { try { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if (!req.workflow?.currentStep) { throw new BadRequestException("Request workflow is not initialized"); } const stepKey = req.workflow.currentStep as WorkflowStep; if (stepKey !== WorkflowStep.FIRST_INVITE_SECOND) { throw new BadRequestException( `Invalid step. Expected FIRST_INVITE_SECOND but got ${stepKey}`, ); } const firstPartyIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstPartyIdx === -1) { throw new BadRequestException("First party not found"); } const firstParty = req.parties[firstPartyIdx]; this.assertPartyOwner( firstParty, user, "Only first party can invite second party", ); // Validate second party phone number is different if (firstParty.person?.phoneNumber === phoneNumber) { throw new BadRequestException( "Second party phone number cannot be the same as first party", ); } // Check if second party already exists (e.g. expert-initiated LINK: expert already added both parties) const secondPartyIdx = this.getPartyIndex(req, PartyRole.SECOND); if (secondPartyIdx !== -1) { if ( req.expertInitiated && req.creationMethod === CreationMethod.LINK ) { // Just advance workflow; second party already in parties. Optionally resend SMS. const secondPartyPhone = (req.parties[secondPartyIdx]?.person as any)?.phoneNumber || phoneNumber; req.status = CaseStatus.WAITING_FOR_SECOND_PARTY; await this.advanceWorkflowToNext(req, stepKey); if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "SECOND_PARTY_INVITED", actor: { actorId: Types.ObjectId.isValid(user?.sub) ? new Types.ObjectId(user.sub) : undefined, actorName: user?.fullName, actorType: "user", }, metadata: { secondPartyPhone, expertInitiatedLink: true }, } as any); await (req as any).save(); const url = `${process.env.URL}/${frontendRoute}?token=${requestId}`; try { await this.smsManagerService.verifyLookUp({ token: url, template: "yara724-invite-link", receptor: secondPartyPhone, }); this.logger.log( `[SMS] Invitation resent to ${secondPartyPhone} for expert-initiated request ${req.publicId}`, ); } catch (err) { this.logger.error( `[SMS] Failed to send invitation to ${secondPartyPhone}: ${describeSmsError(err)}`, ); } return { requestId: req._id, publicId: req.publicId, workflow: req.workflow, invitationUrl: url, }; } throw new ConflictException("Second party already added to this request"); } // Add SECOND party with phone number if (!Array.isArray(req.parties)) req.parties = []; req.parties.push({ role: PartyRole.SECOND, person: { phoneNumber, }, } as any); // Update case status: now waiting for second party to complete their steps req.status = CaseStatus.WAITING_FOR_SECOND_PARTY; await this.advanceWorkflowToNext(req, stepKey); if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "SECOND_PARTY_INVITED", actor: { actorId: Types.ObjectId.isValid(user?.sub) ? new Types.ObjectId(user.sub) : undefined, actorName: user?.fullName, actorType: "user", }, metadata: { secondPartyPhone: phoneNumber, }, } as any); await (req as any).save(); // Send SMS invitation const url = `${process.env.URL}/${frontendRoute}?token=${requestId}`; try { await this.smsManagerService.verifyLookUp({ token: url, template: "yara724-invite-link", receptor: phoneNumber, }); this.logger.log( `[SMS] Invitation sent to ${phoneNumber} for request ${req.publicId}`, ); } catch (err) { this.logger.error( `[SMS] Failed to send invitation to ${phoneNumber}: ${describeSmsError(err)}`, ); // Don't block the request flow if SMS fails } return { requestId: req._id, publicId: req.publicId, workflow: req.workflow, invitationUrl: url, }; } catch (err) { this.logger.error( `[addSecondPartyV2] Error for request ${requestId}: ${err?.message || err}`, ); if (err instanceof HttpException) { throw err; } throw new InternalServerErrorException("Failed to add second party"); } } async createRequest(user, type) { const reqNumber = new ShortUniqueId({ counter: 1 }); const publicId = await this.publicIdService.generateRequestPublicId(); 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(), publicId, 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.", ); } } } // TODO: Activate // --- 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 { await this.smsManagerService.verifyLookUp({ token: URL, template: "yara724-invite-link", receptor: phoneNumber, }); this.logger.log( `[SMS] Invite link verifyLookUp ok receptor=${phoneNumber}`, ); } catch (er) { this.logger.error( `[SMS] Invite link failed receptor=${phoneNumber}: ${describeSmsError(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 = `متاسفانه طرف مقابل با نظر کارشناس مخالفت کرد. فرآیند آنلاین این پرونده بسته شده است و جهت پیگیری حضوری اقدام نمایید.`; try { await this.smsManagerService.sendMessage({ receptor: phoneNumberToNotify, message, }); } catch (err) { this.logger.error( `[SMS] PartiesDisagree notify failed receptor=${phoneNumberToNotify}: ${describeSmsError(err)}`, ); } } } 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 try { await this.smsManagerService.sendMessage({ receptor: phoneNumber, message, }); } catch (err) { this.logger.error( `[SMS] One-party-replied notify failed receptor=${phoneNumber}: ${describeSmsError(err)}`, ); } } 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", ); } } /** * Verify the current user (field expert) can access this BlameRequest (v2). */ private verifyExpertAccessForBlameV2(req: any, expert: any): void { if (req?.expertInitiated && req?.initiatedByFieldExpertId) { if (String(req.initiatedByFieldExpertId) !== String(expert?.sub)) { throw new ForbiddenException( "You can only access files that you have initiated", ); } return; } if (req?.registrarInitiated && req?.initiatedByRegistrarId) { if (String(req.initiatedByRegistrarId) !== String(expert?.sub)) { throw new ForbiddenException( "You can only access files that you have initiated", ); } return; } throw new ForbiddenException( "This file is not initiator-scoped or has no initiating actor", ); } async createRegistrarInitiatedBlame( registrar: any, dto: { type: "THIRD_PARTY" | "CAR_BODY" }, ): Promise<{ requestId: string; publicId: string }> { const registrarId = new Types.ObjectId(registrar.sub); const type = dto.type === "CAR_BODY" ? BlameRequestType.CAR_BODY : BlameRequestType.THIRD_PARTY; const firstStep = await this.getWorkflowStep({ stepNumber: 1 }); if (!firstStep?.stepKey) { throw new InternalServerErrorException( "Workflow stepNumber=1 not configured in step manager", ); } const nextStepFromManager = firstStep.nextPossibleSteps?.[0]; const nextStep = type === BlameRequestType.CAR_BODY ? (WorkflowStep.CAR_BODY_ACCIDENT_TYPE as WorkflowStep) : (nextStepFromManager as WorkflowStep); if (!nextStep) { throw new InternalServerErrorException( "Workflow stepNumber=1 has no nextPossibleSteps configured", ); } const publicId = await this.publicIdService.generateRequestPublicId(); const parties: any[] = [{ role: PartyRole.FIRST, person: {} }]; if (type === BlameRequestType.THIRD_PARTY) { parties.push({ role: PartyRole.SECOND, person: {} }); } const created = await this.blameRequestDbService.create({ publicId, requestNo: publicId, type, parties, workflow: { currentStep: firstStep.stepKey as WorkflowStep, nextStep, completedSteps: [firstStep.stepKey as WorkflowStep], locked: false, }, history: [], registrarInitiated: true, initiatedByRegistrarId: registrarId, creationMethod: CreationMethod.IN_PERSON, filledBy: FilledBy.REGISTRAR, } as any); if (!Array.isArray((created as any).history)) (created as any).history = []; (created as any).history.push({ type: "FILE_CREATED_BY_REGISTRAR", actor: { actorId: registrarId, actorName: `${registrar.firstName || ""} ${registrar.lastName || ""}`.trim(), actorType: "registrar", }, metadata: { creationMethod: CreationMethod.IN_PERSON, type: dto.type }, }); await (created as any).save(); return { requestId: String((created as any)._id), publicId: (created as any).publicId }; } /** * 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); const publicId = await this.publicIdService.generateRequestPublicId(); // 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(), publicId, 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, }; } /** * V2: Field expert creates an expert-initiated blame file (BlameRequest with workflow). * LINK: creates request with parties identified by phone; expert sends link to user(s) who then fill via normal v2 flow. * IN_PERSON: creates minimal request; expert fills everything via complete-blame-data v2. */ async createExpertInitiatedBlameV2( expert: any, dto: CreateExpertInitiatedFileDto, ): Promise<{ requestId: string; publicId: string; linkUrl?: string }> { const expertId = new Types.ObjectId(expert.sub); const type = dto.type === "CAR_BODY" ? BlameRequestType.CAR_BODY : BlameRequestType.THIRD_PARTY; if (dto.creationMethod === CreationMethod.LINK) { if (!dto.firstPartyPhoneNumber) { throw new BadRequestException( "First party phone number is required for LINK method", ); } if ( type === BlameRequestType.THIRD_PARTY && !dto.secondPartyPhoneNumber ) { throw new BadRequestException( "Second party phone number is required for LINK method with THIRD_PARTY type", ); } if ( type === BlameRequestType.THIRD_PARTY && dto.firstPartyPhoneNumber === dto.secondPartyPhoneNumber ) { throw new BadRequestException( "First and second party phone numbers must be different", ); } } const firstStep = await this.getWorkflowStep({ stepNumber: 1 }); if (!firstStep?.stepKey) { throw new InternalServerErrorException( "Workflow stepNumber=1 not configured in step manager", ); } const nextStepFromManager = firstStep.nextPossibleSteps?.[0]; const nextStep = type === BlameRequestType.CAR_BODY ? (WorkflowStep.CAR_BODY_ACCIDENT_TYPE as WorkflowStep) : (nextStepFromManager as WorkflowStep); if (!nextStep) { throw new InternalServerErrorException( "Workflow stepNumber=1 has no nextPossibleSteps configured", ); } const publicId = await this.publicIdService.generateRequestPublicId(); const parties: any[] = []; if (dto.creationMethod === CreationMethod.LINK) { const firstUserId = await this.getOrCreateUserByPhoneNumber( dto.firstPartyPhoneNumber, ); parties.push({ role: PartyRole.FIRST, person: { userId: firstUserId, phoneNumber: dto.firstPartyPhoneNumber, }, }); if ( type === BlameRequestType.THIRD_PARTY && dto.secondPartyPhoneNumber ) { const secondUserId = await this.getOrCreateUserByPhoneNumber( dto.secondPartyPhoneNumber, ); parties.push({ role: PartyRole.SECOND, person: { userId: secondUserId, phoneNumber: dto.secondPartyPhoneNumber, }, }); } } else { parties.push({ role: PartyRole.FIRST, person: {}, }); } const created = await this.blameRequestDbService.create({ publicId, requestNo: publicId, type, parties, workflow: { currentStep: firstStep.stepKey as WorkflowStep, nextStep, completedSteps: [firstStep.stepKey as WorkflowStep], locked: false, }, history: [], expertInitiated: true, initiatedByFieldExpertId: expertId, creationMethod: dto.creationMethod, filledBy: dto.creationMethod === CreationMethod.IN_PERSON ? FilledBy.EXPERT : FilledBy.CUSTOMER, }); const requestId = String((created as any)._id); if (!Array.isArray((created as any).history)) (created as any).history = []; (created as any).history.push({ type: "FILE_CREATED_BY_FIELD_EXPERT", actor: { actorId: expertId, actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "field_expert", }, metadata: { creationMethod: dto.creationMethod, type: dto.type }, }); await (created as any).save(); const result: { requestId: string; publicId: string; linkUrl?: string; } = { requestId, publicId: (created as any).publicId, }; if (dto.creationMethod === CreationMethod.LINK) { const baseUrl = process.env.FRONTEND_BASE_URL || process.env.APP_URL || ""; result.linkUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/blame/${requestId}` : undefined; } return result; } /** * V2: Send blame link to first party (and second party for THIRD_PARTY) for expert-initiated LINK method. * SMS delivery is mocked for now; replace with real SMS provider later. First party opens the link and fills the form via normal v2 flow. */ async sendLinkV2( expert: any, requestId: string, ): Promise<{ sent: boolean; linkUrl: string; sentTo: { role: string; phoneNumber: string }[]; }> { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if (!req.expertInitiated || req.creationMethod !== CreationMethod.LINK) { throw new BadRequestException( "This endpoint is only for expert-initiated LINK blame files. Create the file with creationMethod LINK first.", ); } this.verifyExpertAccessForBlameV2(req, expert); const baseUrl = process.env.FRONTEND_BASE_URL || process.env.APP_URL || ""; const linkUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/blame/${requestId}` : ""; const sentTo: { role: string; phoneNumber: string }[] = []; const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx !== -1 && req.parties[firstIdx]?.person?.phoneNumber) { const phone = req.parties[firstIdx].person.phoneNumber; this.logger.log( `[MOCK SMS] Would send link to first party ${phone}: ${linkUrl}`, ); sentTo.push({ role: PartyRole.FIRST, phoneNumber: phone }); } if (req.type === BlameRequestType.THIRD_PARTY) { const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); if (secondIdx !== -1 && req.parties[secondIdx]?.person?.phoneNumber) { const phone = req.parties[secondIdx].person.phoneNumber; this.logger.log( `[MOCK SMS] Would send link to second party ${phone}: ${linkUrl}`, ); sentTo.push({ role: PartyRole.SECOND, phoneNumber: phone }); } } if (!Array.isArray((req as any).history)) (req as any).history = []; (req as any).history.push({ type: "LINK_SENT", actor: { actorId: new Types.ObjectId(expert.sub), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "field_expert", }, metadata: { linkUrl, sentTo }, }); await (req as any).save(); return { sent: true, linkUrl, sentTo }; } /** * V2: Send OTP via SMS to one or two parties for expert-initiated IN_PERSON blame. * Uses the same flow as /user/send-otp (same template, expiry). After this, parties receive SMS; expert collects OTPs and calls verify-party-otps. */ async sendPartyOtpsV2( expert: any, requestId: string, dto: { firstPartyPhoneNumber: string; secondPartyPhoneNumber?: string }, ): Promise<{ sent: boolean; message: string }> { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if ( (!req.expertInitiated && !req.registrarInitiated) || req.creationMethod !== CreationMethod.IN_PERSON ) { throw new BadRequestException( "This endpoint is only for expert-initiated IN_PERSON blame files.", ); } this.verifyExpertAccessForBlameV2(req, expert); if (req.type === BlameRequestType.THIRD_PARTY && !dto.secondPartyPhoneNumber) { throw new BadRequestException( "Second party phone number is required for THIRD_PARTY. Provide secondPartyPhoneNumber to send OTP to both parties.", ); } const sent: string[] = []; try { await this.userAuthService.sendOtpRequest(dto.firstPartyPhoneNumber); sent.push(dto.firstPartyPhoneNumber); } catch (e: any) { if (e?.message?.includes("Wait for expiry")) { throw new BadRequestException( `First party (${dto.firstPartyPhoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`, ); } throw e; } if (dto.secondPartyPhoneNumber) { try { await this.userAuthService.sendOtpRequest(dto.secondPartyPhoneNumber); sent.push(dto.secondPartyPhoneNumber); } catch (e: any) { if (e?.message?.includes("Wait for expiry")) { throw new BadRequestException( `Second party (${dto.secondPartyPhoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`, ); } throw e; } } return { sent: true, message: sent.length === 2 ? `OTP sent to both parties (${sent.join(", ")}). Have them tell you the code, then call verify-party-otps.` : `OTP sent to ${sent[0]}. Have the party tell you the code, then call verify-party-otps.`, }; } /** * V2: Verify one or two party OTPs for expert-initiated IN_PERSON blame. * Call this after send-party-otps (or after parties requested OTP via /user/send-otp). On success, parties are linked to their user ids so the expert can proceed with complete-blame-data. */ async verifyPartyOtpsV2( expert: any, requestId: string, dto: { firstPartyPhoneNumber: string; firstPartyOtp: string; secondPartyPhoneNumber?: string; secondPartyOtp?: string; }, ): Promise<{ verified: boolean; message: string }> { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if ( (!req.expertInitiated && !req.registrarInitiated) || req.creationMethod !== CreationMethod.IN_PERSON ) { throw new BadRequestException( "This endpoint is only for expert-initiated IN_PERSON blame files.", ); } this.verifyExpertAccessForBlameV2(req, expert); const now = Date.now(); const verifyOne = async (phone: string, otp: string): Promise => { const user = await this.userDbService.findOne({ $or: [{ username: phone }, { mobile: phone }], }); if (!user) throw new BadRequestException(`User not found for phone: ${phone}`); const u = user as any; if (u.otp == null) throw new BadRequestException(`No OTP requested for ${phone}. User must call /user/send-otp first.`); if (u.otpExpire < now) throw new BadRequestException(`OTP expired for ${phone}. User must request a new OTP.`); const valid = await this.hashService.compare(otp, u.otp); if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`); return u._id; }; const firstUserId = await verifyOne(dto.firstPartyPhoneNumber, dto.firstPartyOtp); if (!Array.isArray(req.parties)) req.parties = []; const firstIdx = req.parties.findIndex((p: any) => p?.role === PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found on request"); if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any; req.parties[firstIdx].person.userId = firstUserId; req.parties[firstIdx].person.phoneNumber = dto.firstPartyPhoneNumber; if (req.type === BlameRequestType.THIRD_PARTY && dto.secondPartyPhoneNumber && dto.secondPartyOtp) { const secondUserId = await verifyOne(dto.secondPartyPhoneNumber, dto.secondPartyOtp); let secondIdx = req.parties.findIndex((p: any) => p?.role === PartyRole.SECOND); if (secondIdx === -1) { req.parties.push({ role: PartyRole.SECOND, person: { userId: secondUserId, phoneNumber: dto.secondPartyPhoneNumber, }, } as any); } else { if (!req.parties[secondIdx].person) req.parties[secondIdx].person = {} as any; req.parties[secondIdx].person.userId = secondUserId; req.parties[secondIdx].person.phoneNumber = dto.secondPartyPhoneNumber; } } else if (req.type === BlameRequestType.THIRD_PARTY && (dto.secondPartyPhoneNumber || dto.secondPartyOtp)) { throw new BadRequestException("For THIRD_PARTY both secondPartyPhoneNumber and secondPartyOtp are required."); } if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "PARTY_OTPS_VERIFIED", actor: { actorId: new Types.ObjectId(expert.sub), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: expert?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert", }, metadata: { firstPartyVerified: true, secondPartyVerified: req.type === BlameRequestType.THIRD_PARTY && !!dto.secondPartyPhoneNumber, }, } as any); await (req as any).save(); return { verified: true, message: req.type === BlameRequestType.THIRD_PARTY && dto.secondPartyPhoneNumber ? "Both parties verified. You can proceed to fill the blame form." : "First party verified. You can proceed to fill the blame form.", }; } /** * 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, })); } /** * V2: List expert-initiated blame files (BlameRequest) for the current field expert. * Only files where initiatedByFieldExpertId === current expert are returned. */ async getMyExpertInitiatedFilesV2(expert: any): Promise { const expertId = new Types.ObjectId(expert.sub); const files = await this.blameRequestDbService.find({ blameStatus: { $ne: BlameStatus.UNKNOWN }, $or: [ { expertInitiated: true, initiatedByFieldExpertId: expertId }, { registrarInitiated: true, initiatedByRegistrarId: expertId }, ], }); return (files || []).map((f: any) => ({ _id: f._id, publicId: f.publicId, requestNo: f.requestNo, type: f.type, creationMethod: f.creationMethod, filledBy: f.filledBy, status: f.status, blameStatus: f.blameStatus, workflow: f.workflow, partiesCount: Array.isArray(f.parties) ? f.parties.length : 0, createdAt: f.createdAt, })); } async getAllBlameRequestsV2(user: any): Promise { try { const userIdFilter = user?.sub && Types.ObjectId.isValid(user.sub) ? { "parties.person.userId": new Types.ObjectId(user.sub) } : null; const phoneFilter = user?.username ? { "parties.person.phoneNumber": user.username } : null; const filters = [userIdFilter, phoneFilter].filter(Boolean); if (filters.length === 0) return []; const requests = await this.blameRequestDbService.find( filters.length === 1 ? (filters[0] as any) : ({ $or: filters } as any), { select: "requestNo type status blameStatus createdAt updatedAt parties", } ); const enriched = requests.map((req: any) => { const party = req.parties.find( (p: any) => (p?.person?.userId && String(p.person.userId) === String(user.sub)) || (p?.person?.phoneNumber && p.person.phoneNumber === user?.username), ); const obj = req.toObject(); delete obj.parties; // remove parties completely return { ...obj, userSide: party?.role ?? null, }; }); return enriched; } catch (err) { this.logger.error("Error: ", err); throw new InternalServerErrorException("Something Went Wrong"); } } /** * V2: Hint for UX — damaged user should open create-claim when blame is done and no claim exists. * Mirrors eligibility rules in createClaimFromBlameV2 (without throwing). */ private async computeClaimCreationHintForViewer( blame: any, user: any, ): Promise<{ hasClaim: boolean; shouldGuideToCreateClaim: boolean }> { const none = { hasClaim: false, shouldGuideToCreateClaim: false }; if (!user?.sub || !Types.ObjectId.isValid(String(user.sub))) { return none; } const currentUserId = String(user.sub); const existingClaim = await this.claimCaseDbService.findOne({ blameRequestId: blame._id, }); const hasClaim = !!existingClaim; if (hasClaim) { return { hasClaim: true, shouldGuideToCreateClaim: false }; } if (blame.status !== CaseStatus.COMPLETED) { return { hasClaim: false, shouldGuideToCreateClaim: false }; } const parties = blame.parties || []; if (blame.type === BlameRequestType.CAR_BODY) { const first = parties.find( (p: any) => p?.role === PartyRole.FIRST || p?.role === "FIRST", ); const damagedId = first?.person?.userId ? String(first.person.userId) : null; if (!damagedId || damagedId !== currentUserId) { return { hasClaim: false, shouldGuideToCreateClaim: false }; } return { hasClaim: false, shouldGuideToCreateClaim: true }; } if (blame.type === BlameRequestType.THIRD_PARTY) { const guiltyRaw = blame.expert?.decision?.guiltyPartyId; if (!guiltyRaw) { return { hasClaim: false, shouldGuideToCreateClaim: false }; } const guiltyId = String(guiltyRaw); if (currentUserId === guiltyId) { return { hasClaim: false, shouldGuideToCreateClaim: false }; } const userParty = parties.find( (p: any) => p?.person?.userId && String(p.person.userId) === currentUserId, ); if (!userParty) { return { hasClaim: false, shouldGuideToCreateClaim: false }; } if (parties.length < 2) { return { hasClaim: false, shouldGuideToCreateClaim: false }; } const allSigned = parties.every( (p: any) => p.confirmation !== undefined && p.confirmation !== null, ); const allAccepted = parties.every( (p: any) => p.confirmation?.accepted === true, ); if (!allSigned || !allAccepted) { return { hasClaim: false, shouldGuideToCreateClaim: false }; } return { hasClaim: false, shouldGuideToCreateClaim: true }; } return none; } /** * V2: Get one blame request by id. Access allowed if current user is a party (by userId or phone) * or the initiating field expert. For expert-initiated LINK, party access by phone is sufficient. */ async getBlameRequestV2(requestId: string, user: any): Promise { const req = await this.blameRequestDbService.findById(requestId); if (!req) { throw new NotFoundException("Request not found"); } const isFieldExpertOwner = req.expertInitiated && req.initiatedByFieldExpertId && String(req.initiatedByFieldExpertId) === String(user?.sub); const isRegistrarOwner = req.registrarInitiated && req.initiatedByRegistrarId && String(req.initiatedByRegistrarId) === String(user?.sub); const isParty = Array.isArray(req.parties) && req.parties.some((p: any) => { const pid = p?.person?.userId ? String(p.person.userId) : null; const phone = p?.person?.phoneNumber; return (pid && pid === String(user?.sub)) || (phone && phone === user?.username); }); if (!isFieldExpertOwner && !isRegistrarOwner && !isParty) { throw new ForbiddenException("You do not have access to this request"); } // Optionally bind userId to party when user opens via LINK (phone match, no userId yet) if (!isFieldExpertOwner && !isRegistrarOwner && user?.sub && Types.ObjectId.isValid(user.sub)) { let updated = false; for (const p of req.parties || []) { if ( p?.person?.phoneNumber === user?.username && !p.person?.userId ) { p.person = p.person || {}; (p.person as any).userId = new Types.ObjectId(user.sub); updated = true; } } if (updated) { await (req as any).save(); } } const claimCreation = await this.computeClaimCreationHintForViewer(req, user); const plain = typeof (req as any).toObject === "function" ? (req as any).toObject({ versionKey: false }) : { ...(req as any) }; return { ...plain, claimCreation }; } /** * 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.", ); } /** * V2: Expert completes blame data for IN_PERSON BlameRequest. * Delegates to expertCompleteThirdPartyFormV2 or expertCompleteCarBodyFormV2. */ async expertCompleteBlameDataV2( expert: any, requestId: string, formData: any, ): Promise { const req = await this.blameRequestDbService.findById(requestId); if (!req) { throw new NotFoundException("Request not found"); } if ( (!req.expertInitiated && !req.registrarInitiated) || req.creationMethod !== CreationMethod.IN_PERSON ) { throw new BadRequestException( "This endpoint is only for expert-initiated IN_PERSON BlameRequest files.", ); } this.verifyExpertAccessForBlameV2(req, expert); if (req.type === BlameRequestType.THIRD_PARTY) { return this.expertCompleteThirdPartyFormV2(expert, requestId, formData); } if (req.type === BlameRequestType.CAR_BODY) { return this.expertCompleteCarBodyFormV2(expert, requestId, formData); } throw new BadRequestException( "Unknown file type. Must be THIRD_PARTY or CAR_BODY.", ); } /** * V2: Expert completes IN_PERSON CAR_BODY BlameRequest in one go. */ async expertCompleteCarBodyFormV2( expert: any, requestId: string, formData: any, ): Promise { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if ( (!req.expertInitiated && !req.registrarInitiated) || req.creationMethod !== CreationMethod.IN_PERSON ) { throw new BadRequestException("Only expert-initiated IN_PERSON CAR_BODY files."); } if (req.type !== BlameRequestType.CAR_BODY) { throw new BadRequestException("File type is not CAR_BODY."); } this.verifyExpertAccessForBlameV2(req, expert); if (!formData.carBodyForm) { throw new BadRequestException("carBodyForm is required."); } if (!formData?.expertDescription?.desc) { throw new BadRequestException("expertDescription.desc is required."); } const firstPartyUserId = await this.getOrCreateUserByPhoneNumber( formData.firstPartyPhoneNumber, ); const firstPartyPlate = formData.firstPartyPlate; let sandHubReport: any; try { const sandHubResponse = await this.sandHubService.getSandHubResponse({ plate: firstPartyPlate.plate, nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, }); sandHubReport = sandHubResponse["_doc"] || sandHubResponse; } catch (e) { this.logger.error("SandHub error in expertCompleteCarBodyFormV2:", e); throw new InternalServerErrorException("Failed to process plate information."); } const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; const companyCode = sandHubReport?.CompanyCode; const client = companyCode ? await this.clientService.findClientWithCompanyCode(+companyCode) : await this.clientService.findOne({ clientName }); if (!client) { throw new NotFoundException(`Client not found for company: ${clientName}`); } const carBodyInfo = await this.mockCarBodyInsuranceInquiry( requestId, firstPartyPlate.plate, firstPartyPlate.nationalCodeOfInsurer, ); const firstParty: any = { role: PartyRole.FIRST, person: { userId: firstPartyUserId, phoneNumber: formData.firstPartyPhoneNumber, nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, nationalCodeOfDriver: firstPartyPlate.nationalCodeOfDriver, insurerLicense: firstPartyPlate.insurerLicense, driverLicense: firstPartyPlate.driverLicense, driverIsInsurer: firstPartyPlate.driverIsInsurer, isNewCar: firstPartyPlate.isNewCar, userNoCertificate: firstPartyPlate.userNoCertificate, insurerBirthday: firstPartyPlate.insurerBirthday, driverBirthday: firstPartyPlate.driverBirthday, }, vehicle: { plateId: this.plateToPlateIdString(firstPartyPlate.plate) || (firstPartyPlate as any).plateId, name: sandHubReport?.MapTypNam || sandHubReport?.CarName, model: sandHubReport?.MapTypNam, type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`, isNew: firstPartyPlate.isNewCar, inquiry: sandHubReport, }, insurance: { policyNumber: sandHubReport?.LastCompanyDocumentNumber || sandHubReport?.DocumentNumber, company: clientName, startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate, endDate: sandHubReport?.EndDate, financialCeiling: sandHubReport?.FinancialCvrCptl || sandHubReport?.FinancialCommitment, carBodyInsurance: { policyNumber: carBodyInfo.policyNumber, startDate: carBodyInfo.startDate, endDate: carBodyInfo.endDate, insurerCompany: carBodyInfo.insurerCompany, coverages: carBodyInfo.coverages, }, }, statement: { description: formData.expertDescription?.desc, accidentDate: formData.expertDescription?.accidentDate, accidentTime: formData.expertDescription?.accidentTime, weatherCondition: formData.expertDescription?.weatherCondition, roadCondition: formData.expertDescription?.roadCondition, lightCondition: formData.expertDescription?.lightCondition, }, location: req.parties?.[0]?.location, carBodyFirstForm: { car: !!formData.carBodyForm.car, object: !!formData.carBodyForm.object, }, evidence: req.parties?.[0]?.evidence ? { ...req.parties[0].evidence } : {}, }; req.parties = [firstParty]; req.workflow = { currentStep: WorkflowStep.FIRST_LOCATION as any, nextStep: WorkflowStep.WAITING_FOR_SIGNATURES as any, completedSteps: [ WorkflowStep.CREATED, WorkflowStep.CAR_BODY_ACCIDENT_TYPE, WorkflowStep.FIRST_VIDEO, WorkflowStep.FIRST_INITIAL_FORM, WorkflowStep.FIRST_VOICE, WorkflowStep.FIRST_DESCRIPTION, ].filter((s) => s), locked: false, }; req.status = CaseStatus.OPEN; req.blameStatus = BlameStatus.AGREED; (req as any).filledBy = FilledBy.EXPERT; if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "EXPERT_COMPLETED_CAR_BODY_FORM_V2", actor: { actorId: new Types.ObjectId(expert.sub), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "field_expert", }, metadata: {}, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId, workflow: req.workflow, status: req.status, }; } /** * V2: Expert completes IN_PERSON THIRD_PARTY BlameRequest in one go. */ async expertCompleteThirdPartyFormV2( expert: any, requestId: string, formData: any, ): Promise { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) { throw new BadRequestException("Only expert-initiated IN_PERSON THIRD_PARTY files."); } if (req.type !== BlameRequestType.THIRD_PARTY) { throw new BadRequestException("File type is not THIRD_PARTY."); } this.verifyExpertAccessForBlameV2(req, expert); if (!formData.secondParty || !formData.guiltyPartyPhoneNumber) { throw new BadRequestException("secondParty and guiltyPartyPhoneNumber are required."); } if (!formData?.expertDescription?.desc) { throw new BadRequestException("expertDescription.desc is required."); } const firstPartyUserId = await this.getOrCreateUserByPhoneNumber( formData.firstPartyPhoneNumber, ); const secondPartyUserId = await this.getOrCreateUserByPhoneNumber( formData.secondParty.phoneNumber, ); const buildPartyFromForm = async ( phoneNumber: string, userId: Types.ObjectId, initialForm: any, plateDto: any, desc: string, role: PartyRole, ) => { const sandHubResponse = await this.sandHubService.getSandHubResponse({ plate: plateDto.plate, nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer, }); const sandHubReport = (sandHubResponse["_doc"] || sandHubResponse) as any; const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; const companyCode = sandHubReport?.CompanyCode; const client = companyCode ? await this.clientService.findClientWithCompanyCode(+companyCode) : await this.clientService.findOne({ clientName }); if (!client) { throw new NotFoundException(`Client not found for company: ${clientName}`); } return { role, person: { userId, phoneNumber, nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer, nationalCodeOfDriver: plateDto.nationalCodeOfDriver, insurerLicense: plateDto.insurerLicense, driverLicense: plateDto.driverLicense, driverIsInsurer: plateDto.driverIsInsurer, isNewCar: plateDto.isNewCar, userNoCertificate: plateDto.userNoCertificate, insurerBirthday: plateDto.insurerBirthday, driverBirthday: plateDto.driverBirthday, }, vehicle: { plateId: this.plateToPlateIdString(plateDto.plate) || (plateDto as any).plateId, name: sandHubReport?.MapTypNam || sandHubReport?.CarName, model: sandHubReport?.MapTypNam, type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`, isNew: plateDto.isNewCar, inquiry: sandHubReport, }, insurance: { policyNumber: sandHubReport?.LastCompanyDocumentNumber || sandHubReport?.DocumentNumber, company: clientName, startDate: sandHubReport?.IssueDate || sandHubReport?.StartDate, endDate: sandHubReport?.EndDate, financialCeiling: sandHubReport?.FinancialCvrCptl || sandHubReport?.FinancialCommitment, }, statement: { description: desc }, location: undefined, evidence: {}, }; }; const firstParty = await buildPartyFromForm( formData.firstPartyPhoneNumber, firstPartyUserId, formData.firstPartyInitialForm, formData.firstPartyPlate, formData.expertDescription?.desc || "", PartyRole.FIRST, ); const secondParty = await buildPartyFromForm( formData.secondParty.phoneNumber, secondPartyUserId, formData.secondParty.initialForm, formData.secondParty.plate, formData.expertDescription?.desc || "", PartyRole.SECOND, ); req.parties = [firstParty, secondParty]; const guiltyPartyId = formData.guiltyPartyPhoneNumber === formData.firstPartyPhoneNumber ? String(firstPartyUserId) : String(secondPartyUserId); const damagedPhone = formData.guiltyPartyPhoneNumber === formData.firstPartyPhoneNumber ? formData.secondParty.phoneNumber : formData.firstPartyPhoneNumber; if (!req.expert) req.expert = {} as any; req.expert.decision = { guiltyPartyId: new Types.ObjectId(guiltyPartyId), description: `با توافق طرفین مقصر و زیان دیده مشخص شد. کاربر ${formData.guiltyPartyPhoneNumber} مقصر و کاربر ${damagedPhone} زیان دیده می باشد.`, decidedAt: new Date(), decidedByExpertId: new Types.ObjectId(expert.sub), fields: { ...MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS }, } as any; req.workflow = { currentStep: WorkflowStep.FIRST_LOCATION as any, nextStep: WorkflowStep.SECOND_LOCATION as any, completedSteps: [ WorkflowStep.CREATED, WorkflowStep.FIRST_BLAME_CONFESSION, WorkflowStep.FIRST_VIDEO, WorkflowStep.FIRST_INITIAL_FORM, WorkflowStep.FIRST_VOICE, WorkflowStep.FIRST_DESCRIPTION, WorkflowStep.SECOND_INITIAL_FORM, WorkflowStep.SECOND_VOICE, WorkflowStep.SECOND_DESCRIPTION, ].filter((s) => s), locked: false, }; req.status = CaseStatus.OPEN; req.blameStatus = BlameStatus.AGREED; (req as any).filledBy = FilledBy.EXPERT; if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "EXPERT_COMPLETED_THIRD_PARTY_FORM_V2", actor: { actorId: new Types.ObjectId(expert.sub), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "field_expert", }, metadata: { guiltyPartyPhoneNumber: formData.guiltyPartyPhoneNumber }, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId, workflow: req.workflow, status: req.status, }; } /** * V2: Expert submits location(s) for expert-initiated IN_PERSON blame. * This is separated from complete-blame-data to support dedicated location UI step. */ async expertAddLocationsForBlameV2( expert: any, requestId: string, dto: { location: LocationDto }, ): Promise { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if ( (!req.expertInitiated && !req.registrarInitiated) || req.creationMethod !== CreationMethod.IN_PERSON ) { throw new BadRequestException( "This endpoint is only for expert-initiated IN_PERSON blame files.", ); } this.verifyExpertAccessForBlameV2(req, expert); const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); if (!dto?.location) { throw new BadRequestException("location is required"); } req.parties[firstIdx].location = dto.location as any; const completed = Array.isArray(req.workflow?.completedSteps) ? req.workflow.completedSteps : []; if (!completed.includes(WorkflowStep.FIRST_LOCATION as any)) { completed.push(WorkflowStep.FIRST_LOCATION as any); } req.workflow = { ...(req.workflow || {}), currentStep: WorkflowStep.WAITING_FOR_SIGNATURES as any, nextStep: undefined, completedSteps: completed, locked: false, } as any; req.status = CaseStatus.WAITING_FOR_SIGNATURES; if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "EXPERT_ADDED_LOCATIONS_V2", actor: { actorId: new Types.ObjectId(expert.sub), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: expert?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert", }, metadata: { hasLocation: true, }, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId, workflow: req.workflow, status: req.status, }; } /** * V2: Expert uploads video for expert-initiated BlameRequest (first party). */ async expertUploadVideoForBlameV2( expert: any, requestId: string, file: Express.Multer.File, ): Promise { if (!file) throw new BadRequestException("Video file is required"); const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if (!req.expertInitiated && !req.registrarInitiated) { throw new BadRequestException("This endpoint is only for expert-initiated files."); } this.verifyExpertAccessForBlameV2(req, expert); const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST); if (firstPartyIndex === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstPartyIndex]; if (firstParty?.evidence?.videoId) { throw new ConflictException("Video already uploaded for this file"); } const videoDocument = await this.blameVideoDbService.create({ fileName: file.filename, path: file.path, requestId: new Types.ObjectId(requestId), } as any); if (!firstParty.evidence) firstParty.evidence = {} as any; firstParty.evidence.videoId = String((videoDocument as any)._id); await (req as any).save(); if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "EXPERT_UPLOADED_VIDEO_V2", actor: { actorId: new Types.ObjectId(expert.sub), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "field_expert", }, metadata: { videoId: firstParty.evidence.videoId }, } as any); await (req as any).save(); return { requestId: req._id, videoId: firstParty.evidence.videoId }; } /** * V2: Expert uploads voice for expert-initiated BlameRequest (first party). */ async expertUploadVoiceForBlameV2( expert: any, requestId: string, voiceFile: Express.Multer.File, ): Promise { if (!voiceFile) throw new BadRequestException("Voice file is required"); const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if (!req.expertInitiated) { throw new BadRequestException("This endpoint is only for expert-initiated files."); } this.verifyExpertAccessForBlameV2(req, expert); const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST); if (firstPartyIndex === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstPartyIndex]; if ( Array.isArray(firstParty?.evidence?.voices) && firstParty.evidence.voices.length > 0 ) { throw new ConflictException("Voice already uploaded for this file"); } const voiceDocument = await this.blameVoiceDbService.create({ fileName: voiceFile.filename, path: voiceFile.path, requestId: new Types.ObjectId(requestId), } as any); if (!firstParty.evidence) firstParty.evidence = {} as any; if (!Array.isArray(firstParty.evidence.voices)) firstParty.evidence.voices = []; firstParty.evidence.voices.push(String((voiceDocument as any)._id)); await (req as any).save(); if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "EXPERT_UPLOADED_VOICE_V2", actor: { actorId: new Types.ObjectId(expert.sub), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "field_expert", }, metadata: {}, } as any); await (req as any).save(); return { requestId: req._id }; } /** * V2: Expert uploads a party's signature for expert-initiated IN_PERSON blame. * CAR_BODY: upload FIRST only. THIRD_PARTY: upload FIRST then SECOND. * When all required parties have signed, status becomes COMPLETED. */ async expertUploadPartySignatureV2( expert: any, requestId: string, partyRole: PartyRole, isAccept: boolean, signFile: Express.Multer.File, ): Promise { if (!signFile) { throw new BadRequestException("A signature file is required"); } const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) { throw new BadRequestException( "This endpoint is only for expert-initiated IN_PERSON blame files.", ); } this.verifyExpertAccessForBlameV2(req, expert); if (req.status !== CaseStatus.WAITING_FOR_SIGNATURES) { throw new BadRequestException( "Request is not waiting for signatures. Current status: " + req.status, ); } const partyIndex = this.getPartyIndex(req, partyRole); if (partyIndex === -1) { throw new BadRequestException(`Party ${partyRole} not found on this request`); } if (req.type === BlameRequestType.CAR_BODY && partyRole === PartyRole.SECOND) { throw new BadRequestException("CAR_BODY has only first party; use partyRole FIRST."); } const party = req.parties[partyIndex]; if (party.confirmation) { throw new BadRequestException( `Party ${partyRole} has already signed.`, ); } const partyUserId = party.person?.userId ? String(party.person.userId) : undefined; const signDoc = await this.userSign.create({ fileName: signFile.filename, userId: partyUserId || expert.sub, path: signFile.path, requestId: new Types.ObjectId(requestId), }); const signatureData = { fileId: String((signDoc as any)._id), fileName: signFile.filename, fileUrl: signFile.path, }; const updatePayload: any = { $set: { [`parties.${partyIndex}.confirmation`]: { partyRole: party.role, accepted: isAccept, signature: signatureData, }, }, }; await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload); const updatedRequest = await this.blameRequestDbService.findById(requestId); const requiredCount = req.type === BlameRequestType.CAR_BODY ? 1 : 2; const signedParties = (updatedRequest.parties || []).filter( (p) => p.confirmation != null && p.confirmation !== undefined, ); let finalStatus = CaseStatus.WAITING_FOR_SIGNATURES; let message = "Signature recorded successfully."; if (signedParties.length >= requiredCount) { const allAccepted = updatedRequest.parties .slice(0, requiredCount) .every((p) => p.confirmation?.accepted === true); if (allAccepted) { finalStatus = CaseStatus.COMPLETED; message = "All parties have signed. Blame case completed."; await this.blameRequestDbService.findByIdAndUpdate(requestId, { $set: { status: CaseStatus.COMPLETED, "workflow.currentStep": WorkflowStep.COMPLETED as any, "workflow.nextStep": null, }, $push: { "workflow.completedSteps": WorkflowStep.WAITING_FOR_SIGNATURES as any, }, }); } else { finalStatus = CaseStatus.CANCELLED; message = "One or both parties rejected. Case requires in-person resolution."; await this.blameRequestDbService.findByIdAndUpdate(requestId, { $set: { status: CaseStatus.CANCELLED, "workflow.currentStep": WorkflowStep.COMPLETED as any, "workflow.nextStep": null, }, $push: { "workflow.completedSteps": WorkflowStep.WAITING_FOR_SIGNATURES as any, }, }); } } return { requestId: String(req._id), accepted: isAccept, status: finalStatus, message, }; } /** * V2: Expert adds accident fields to expert-initiated BlameRequest. */ async expertAddAccidentFieldsForBlameV2( expert: any, requestId: string, fields: any, ): Promise { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if (!req.expertInitiated) { throw new BadRequestException("This endpoint is only for expert-initiated files."); } this.verifyExpertAccessForBlameV2(req, expert); if (!req.expert) req.expert = {} as any; if (!req.expert.decision) req.expert.decision = {} as any; (req.expert as any).decision = { ...(req.expert as any).decision, fields: { accidentWay: fields.accidentWay, accidentReason: fields.accidentReason, accidentType: fields.accidentType, }, }; await (req as any).save(); return { requestId: req._id }; } /** * 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); } /** * V2: Get list of documents/items the current user needs to resend */ async getResendRequirementsV2( requestId: string, userId: string, ): Promise { const request = await this.blameRequestDbService.findById(requestId); if (!request) { throw new NotFoundException("Request not found"); } // Check if status is WAITING_FOR_DOCUMENT_RESEND if (request.status !== CaseStatus.WAITING_FOR_DOCUMENT_RESEND) { throw new BadRequestException( "This request is not waiting for document resend", ); } // Find the current user's party const parties = request.parties || []; const userParty = parties.find( (p) => String(p.person?.userId) === String(userId), ); if (!userParty) { throw new ForbiddenException( "You are not a party in this request", ); } // Find resend request for this party (partyId matches person.userId) const resendParties = request.expert?.resend?.parties || []; const userResendRequest = resendParties.find( (r) => String(r.partyId) === String(userParty.person?.userId), ); if (!userResendRequest) { throw new NotFoundException( "No resend request found for your party. Please contact support.", ); } const items = (userResendRequest.requestedItems || []) as string[]; const row = userResendRequest as any; const merged = { uploadedDocuments: { ...(typeof row.uploadedDocuments === "object" && row.uploadedDocuments ? { ...row.uploadedDocuments } : {}), }, resendVoiceId: row.resendVoiceId, resendVideoId: row.resendVideoId, userTextDescription: row.userTextDescription, }; const remainingItems = items.filter((i) => !isResendPartyItemSatisfied(i, merged)); return { requestId: String(request._id), requestedItems: userResendRequest.requestedItems, description: userResendRequest.description || "", completed: userResendRequest.completed || false, requestedItemsUi: buildResendItemsWithUi(items), remainingItems, }; } /** * V2: Upload requested documents/evidence for resend */ async uploadResendDocumentsV2( requestId: string, userId: string, files: { drivingLicense?: Express.Multer.File[]; carCertificate?: Express.Multer.File[]; nationalCertificate?: Express.Multer.File[]; carGreenCard?: Express.Multer.File[]; voice?: Express.Multer.File[]; video?: Express.Multer.File[]; }, textDescription?: string, ): Promise { const request = await this.blameRequestDbService.findById(requestId); if (!request) { throw new NotFoundException("Request not found"); } // Check status if (request.status !== CaseStatus.WAITING_FOR_DOCUMENT_RESEND) { throw new BadRequestException( "This request is not waiting for document resend", ); } // Find user's party const parties = request.parties || []; const userParty = parties.find( (p) => String(p.person?.userId) === String(userId), ); if (!userParty) { throw new ForbiddenException("You are not a party in this request"); } const partyIndex = parties.findIndex( (p) => String(p.person?.userId) === String(userId), ); if (partyIndex === -1) { throw new ForbiddenException("Party not found in request"); } // Find resend request for this party (partyId matches person.userId) const resendParties = request.expert?.resend?.parties || []; const resendIndex = resendParties.findIndex( (r) => String(r.partyId) === String(userParty.person?.userId), ); if (resendIndex === -1) { throw new NotFoundException( "No resend request found for your party", ); } const userResendRequest = resendParties[resendIndex]; const requestedItems = userResendRequest.requestedItems || []; const row = userResendRequest as any; const mergedRow = { uploadedDocuments: { ...(typeof row.uploadedDocuments === "object" && row.uploadedDocuments ? { ...row.uploadedDocuments } : {}), }, resendVoiceId: row.resendVoiceId, resendVideoId: row.resendVideoId, userTextDescription: row.userTextDescription, }; const hasAnyFile = Object.keys(files || {}).some((fieldName) => { const fileArray = files[fieldName as keyof typeof files]; return fileArray && fileArray.length > 0 && fileArray[0]; }); const descTrim = textDescription != null ? String(textDescription).trim() : ""; const sendingDescription = requestedItems.includes(ResendItemType.DESCRIPTION) && descTrim.length > 0; if (!hasAnyFile && !sendingDescription) { throw new BadRequestException( "Provide at least one requested file or a text description when description was requested.", ); } const uploadedItems: string[] = []; const updatePayload: any = { $set: {} }; if (sendingDescription) { updatePayload.$set[ `expert.resend.parties.${resendIndex}.userTextDescription` ] = descTrim; mergedRow.userTextDescription = descTrim; uploadedItems.push(ResendItemType.DESCRIPTION); } for (const fieldName in files) { const fileArray = files[fieldName as keyof typeof files]; if (!fileArray || fileArray.length === 0) continue; const file = fileArray[0]; if (!requestedItems.includes(fieldName)) { throw new BadRequestException( `${fieldName} was not requested. Requested items: ${requestedItems.join(", ")}`, ); } if (fieldName === "voice") { const voiceDoc = await this.blameVoiceDbService.create({ path: file.path, requestId: new Types.ObjectId(requestId), fileName: file.filename, context: "EXPERT_RESEND" as any, }); updatePayload.$addToSet = updatePayload.$addToSet || {}; updatePayload.$addToSet[`parties.${partyIndex}.evidence.voices`] = (voiceDoc as any)._id; updatePayload.$set[ `expert.resend.parties.${resendIndex}.resendVoiceId` ] = (voiceDoc as any)._id; mergedRow.resendVoiceId = (voiceDoc as any)._id; uploadedItems.push(fieldName); } else if (fieldName === "video") { const videoDoc = await this.blameVideoDbService.create({ path: file.path, requestId: new Types.ObjectId(requestId), fileName: file.filename, }); updatePayload.$set[`parties.${partyIndex}.evidence.videoId`] = String((videoDoc as any)._id); updatePayload.$set[ `expert.resend.parties.${resendIndex}.resendVideoId` ] = (videoDoc as any)._id; mergedRow.resendVideoId = (videoDoc as any)._id; uploadedItems.push(fieldName); } else { const docType = fieldName as BlameDocumentType; const doc = await this.blameDocumentDbService.create({ path: file.path, requestId: new Types.ObjectId(requestId), fileName: file.filename, documentType: docType, }); updatePayload.$set[ `expert.resend.parties.${resendIndex}.uploadedDocuments.${fieldName}` ] = (doc as any)._id; mergedRow.uploadedDocuments[fieldName] = (doc as any)._id; uploadedItems.push(fieldName); } } const allItemsCompleted = (requestedItems as string[]).every((item) => isResendPartyItemSatisfied(item, mergedRow), ); if (allItemsCompleted) { updatePayload.$set[`expert.resend.parties.${resendIndex}.completed`] = true; updatePayload.$set[`expert.resend.parties.${resendIndex}.completedAt`] = new Date(); } await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload); const updatedRequest = await this.blameRequestDbService.findById(requestId); const allPartiesCompleted = updatedRequest.expert?.resend?.parties?.every( (p) => p.completed, ); if (allPartiesCompleted) { await this.blameRequestDbService.findByIdAndUpdate(requestId, { $set: { status: CaseStatus.WAITING_FOR_EXPERT, "workflow.currentStep": WorkflowStep.WAITING_FOR_GUILT_DECISION, "workflow.nextStep": null, }, $push: { "workflow.completedSteps": WorkflowStep.WAITING_FOR_DOCUMENT_RESEND, }, }); await this.applyLinkedClaimsBlameResendCleared(requestId); this.logger.log( `All parties completed resend for request ${requestId}. Status changed to WAITING_FOR_EXPERT`, ); } return { message: allItemsCompleted ? "All requested documents uploaded successfully" : "Documents uploaded successfully. Please upload remaining items.", uploadedItems, allItemsCompleted, }; } /** * V2: User signs and accepts/rejects (expert decision for THIRD_PARTY, or final acknowledgment for CAR_BODY). */ async userSignDecisionV2( requestId: string, userId: string, isAccept: boolean, signFile: Express.Multer.File, ): Promise { if (!signFile) { throw new BadRequestException("A signature file is required"); } const request = await this.blameRequestDbService.findById(requestId); if (!request) { throw new NotFoundException("Request not found"); } // Validate status is WAITING_FOR_SIGNATURES if (request.status !== CaseStatus.WAITING_FOR_SIGNATURES) { throw new BadRequestException( "Request is not waiting for signatures. Current status: " + request.status, ); } // THIRD_PARTY: blame expert must have issued a guilt decision before signatures. // CAR_BODY: single-party flow; no blame expert / no expert.decision by design. if ( request.type !== BlameRequestType.CAR_BODY && !request.expert?.decision ) { throw new BadRequestException( "Expert has not made a decision yet", ); } // Find user's party const parties = request.parties || []; const partyIndex = parties.findIndex( (p) => String(p.person?.userId) === String(userId), ); if (partyIndex === -1) { throw new ForbiddenException("You are not a party in this request"); } const userParty = parties[partyIndex]; // Check if user already signed if (userParty.confirmation) { throw new BadRequestException( "You have already signed this decision", ); } // Create signature document const signDoc = await this.userSign.create({ fileName: signFile.filename, userId: userId, path: signFile.path, requestId: new Types.ObjectId(requestId), }); const signatureData = { fileId: String((signDoc as any)._id), fileName: signFile.filename, fileUrl: signFile.path, }; const confirmationPayload = { partyRole: userParty.role, accepted: isAccept, signature: signatureData, }; // Any single rejection stops the case immediately (in-person resolution required). if (!isAccept) { await this.blameRequestDbService.findByIdAndUpdate(requestId, { $set: { status: CaseStatus.STOPPED, [`parties.${partyIndex}.confirmation`]: confirmationPayload, "workflow.nextStep": null, }, $push: { history: { type: "PARTY_REJECTED_EXPERT_DECISION", timestamp: new Date(), metadata: { userId, partyRole: userParty.role, }, }, }, }); this.logger.log( `Party rejected expert decision for request ${requestId}; status STOPPED.`, ); return { requestId: String(request._id), accepted: false, status: CaseStatus.STOPPED, message: "You rejected the expert decision. This case is stopped and requires in-person resolution.", }; } await this.blameRequestDbService.findByIdAndUpdate(requestId, { $set: { [`parties.${partyIndex}.confirmation`]: confirmationPayload, }, }); const updatedRequest = await this.blameRequestDbService.findById(requestId); const allPartiesSigned = updatedRequest.parties.every( (p) => p.confirmation !== undefined && p.confirmation !== null, ); let finalStatus = CaseStatus.WAITING_FOR_SIGNATURES; let message = "Your signature has been recorded successfully"; if (allPartiesSigned) { const allAccepted = updatedRequest.parties.every( (p) => p.confirmation?.accepted === true, ); if (allAccepted) { finalStatus = CaseStatus.COMPLETED; message = updatedRequest.parties.length === 1 ? "Case completed successfully." : "Both parties have accepted. Case completed successfully."; await this.blameRequestDbService.findByIdAndUpdate(requestId, { $set: { status: CaseStatus.COMPLETED, "workflow.currentStep": "COMPLETED", "workflow.nextStep": null, }, $push: { "workflow.completedSteps": "WAITING_FOR_SIGNATURES", }, }); } else { finalStatus = CaseStatus.STOPPED; message = "One party accepted and another rejected the decision. Case is stopped; in-person resolution required."; await this.blameRequestDbService.findByIdAndUpdate(requestId, { $set: { status: CaseStatus.STOPPED, "workflow.nextStep": null, }, $push: { history: { type: "PARTIES_DISAGREED_ON_EXPERT_DECISION", timestamp: new Date(), metadata: {}, }, }, }); } this.logger.log( `All parties signed for request ${requestId}. Final status: ${finalStatus}`, ); } return { requestId: String(request._id), accepted: isAccept, status: finalStatus, message, }; } }