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 { ClientService } from "src/client/client.service"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { CarBodyFormDto, CarBodySecondForm, InitialFormDto, InitialFormVinDto, } 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, cloneResendUploadedDocuments, isResendPartyItemSatisfied, normalizeResendRequestedItemKey, normalizeResendRequestedItemsList, } 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 { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { resolveClaimOwnerFieldsFromBlame } from "src/helpers/blame-damaged-party"; import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto"; import { RunInquiriesV3Dto, RunInquiriesVinV3Dto, } from "./dto/run-inquiries-v3.dto"; 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 { FanavaranLocationService } from "src/fanavaran/fanavaran-location.service"; import { isOtpExpiryActive } from "src/helpers/user-otp-expiry"; import { parseIranLocalDateTime } from "src/helpers/iran-datetime"; import { applyListQueryV2 } from "src/helpers/list-query-v2"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { GetUserBlameListV2ResponseDto } from "src/request-management/dto/blame-list-user-v2.dto"; import { AutoCloseRequestService } from "src/utils/cron/cron.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { DescriptionDto, DescriptionV4Dto, 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 { BusinessRuleException, BusinessErrorCode, } from "src/expert-claim/exceptions/business-rule.exception"; import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service"; import { ExpertFileActivityType, ExpertFileKind, } from "src/users/entities/schema/expert-file-activity.schema"; 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"; import { buildFileLink } from "src/helpers/urlCreator"; import { buildUserLookupByPhone, iranMobileLookupVariants, normalizeIranMobile, partyPersonMatchesUser, } from "src/helpers/iran-mobile"; import { buildBlamePartyAccessOrConditions, collectUserIdVariants, } from "src/helpers/party-access-queries"; import { blameCaseTouchesClient, requireActorClientKey, } from "src/helpers/tenant-scope"; import { resolveLinkedUserIdStrings } from "src/helpers/user-access-resolver"; import { normalizePlateText } from "src/utils/plate-normalizer/plate-normalizer.service"; import { fanavaranClaimReferences } from "src/claim-request-management/fanavaran-claim-references"; import { isMappedPolicyCurrent, InquirySubjects, NormalizedInquirySubmission, normalizeInquirySubmission, resolveInquirySubjects, runPlateInquiryWithFallback, } from "./inquiry-participant-resolver"; /** * Formats a compact Jalali date (number or string like 13780624) as YYYY/MM/DD. * Returns the original value as a string if it cannot be parsed. */ function formatJalaliCompact( raw: number | string | null | undefined, ): string | null | undefined { if (raw == null) return raw as null | undefined; const s = String(raw).replace(/\D/g, ""); if (s.length === 8) { return `${s.slice(0, 4)}/${s.slice(4, 6)}/${s.slice(6, 8)}`; } return String(raw); } @Injectable() export class RequestManagementService { private readonly logger = new Logger(RequestManagementService.name); /** * Only the guilty (currently first) party's THIRD_PARTY policy must belong * to this deployment. CAR_BODY flows deliberately do not constrain that * separate third-party policy; their car-body lookup is checked on its own. */ private policyInquiryOptions( requestType: BlameRequestType | string, partyRole: PartyRole, clientId?: string, ) { const enforceDeploymentClientMatch = requestType === BlameRequestType.THIRD_PARTY && partyRole === PartyRole.FIRST; if (!clientId && !enforceDeploymentClientMatch) return undefined; return { ...(clientId ? { clientId } : {}), ...(enforceDeploymentClientMatch ? { enforceDeploymentClientMatch: true } : {}), }; } /** Keep eligibility rejections distinct from an unavailable CAR_BODY provider. */ private throwCarBodyInquiryFailure(err: unknown): never { if (err instanceof ForbiddenException) throw err; throw new HttpException("Car body inquiry failed", HttpStatus.BAD_REQUEST); } /** * CAR_BODY ownership is the deployment insurer, not the insurer returned by * the preceding third-party lookup. Processed Parsian responses have no * company id, so resolve the configured client from CLIENT_ID explicitly. */ private async resolveCarBodyPolicyClientId( mapped: Record, ): Promise { const configuredCode = Number(process.env.CLIENT_ID); if (!Number.isFinite(configuredCode)) { throw new InternalServerErrorException( "CLIENT_ID must be configured to save CAR_BODY policy ownership.", ); } let client: any = await this.clientService.findOne({ clientCode: configuredCode, }); if (!client) { const companyName = mapped.CompanyName ?? mapped.companyPersianName; if (typeof companyName === "string" && companyName.trim()) { client = await this.clientService.findOrCreateClientByCompanyCode( configuredCode, companyName, ); } } const clientId = (client as any)?._id ?? (client as any)?._doc?._id; if (!clientId) { throw new InternalServerErrorException( "Configured CAR_BODY insurer client could not be resolved.", ); } return clientId; } 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`); } private resolveBlameHistoryActorType(actor: any): string { switch (actor?.role) { case RoleEnum.FILE_MAKER: return "file_maker"; case RoleEnum.FILE_REVIEWER: return "file_reviewer"; case RoleEnum.REGISTRAR: return "registrar"; case RoleEnum.CALL_CENTER: return "call_center"; case RoleEnum.DAMAGE_EXPERT: return "damage_expert"; case RoleEnum.EXPERT: return "expert"; case RoleEnum.FIELD_EXPERT: default: return "field_expert"; } } /** * Reverse map: Fanavaran/Tejarat numeric letter code → Persian plate letter. * Tejarat inquiry stores Plk2 as a numeric code (e.g. 12 → "م", 5 → "د"). * ESG VIN inquiry via parsePlkString already returns the actual letter string, * but the mock and some Tejarat responses use the numeric code form. */ private static readonly PLATE_LETTER_CODE_TO_LETTER: Record = { 1: "الف", 2: "ب", 3: "پ", 4: "ج", 5: "د", 6: "س", 7: "ص", 8: "ط", 9: "ع", 10: "ق", 11: "ل", 12: "م", 13: "ن", 14: "و", 15: "ه", 16: "ی", 17: "ک", 18: "ژ", 19: "ت", 20: "ث", 21: "ز", 22: "ش", 23: "ف", 24: "گ", }; /** * Resolve a plate center letter from whatever shape Plk2 arrives in: * - already a Persian string → returned as-is (after normalization) * - a numeric code (number or numeric string) → looked up in the reverse map */ private resolvePlk2Letter(plk2: any): string { if (plk2 == null) return ""; const code = Number(plk2); if (Number.isFinite(code) && code > 0) { return RequestManagementService.PLATE_LETTER_CODE_TO_LETTER[code] ?? ""; } return normalizePlateText(String(plk2).trim()); } /** 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 normalizeInquiryInput>( type: BlameRequestType, input: T, partyRole?: PartyRole, ): NormalizedInquirySubmission { return normalizeInquirySubmission(type, input, { allowUnknownThirdPartyPolicyholder: type === BlameRequestType.THIRD_PARTY && partyRole === PartyRole.SECOND, }); } private applyInquiryParticipantsToParty( party: any, submission: NormalizedInquirySubmission>, ): void { party.participants = submission.participants.participants; party.participantRoles = submission.participants.roles; if (!party.vehicle) party.vehicle = {}; if (submission.vehicle) { party.vehicle.registrationState = submission.vehicle.registrationState; party.vehicle.plateId = this.plateToPlateIdString( submission.vehicle.currentPlate, ); party.vehicle.previousPlateId = submission.vehicle.previousPlate ? this.plateToPlateIdString(submission.vehicle.previousPlate) : undefined; if (submission.vehicle.vin) party.vehicle.vin = submission.vehicle.vin; } } private recordInquiryParticipantContext( req: any, role: PartyRole, submission: NormalizedInquirySubmission>, ): void { this.recordPartyCaseInquiryStatus(req, "participantContext", role, true, { participants: submission.participants.participants, participantRoles: submission.participants.roles, vehicle: submission.vehicle, }); } private async getThirdPartyPlateInquiry( submission: NormalizedInquirySubmission>, options?: Record, ): Promise { if (submission.thirdPartyPolicyholder.unknown) { return { raw: null, mapped: {}, skipped: true, plateKind: "CURRENT", attempts: [], }; } const subjects = resolveInquirySubjects(submission); const result = await runPlateInquiryWithFallback({ vehicle: submission.vehicle, fallbackCurrentPlate: submission.dto.plate, query: (plate) => this.sandHubService.getTejaratBlockInquiry( { plate: plate as any, nationalCodeOfInsurer: subjects.thirdPartyPolicyNationalCode, }, options, ), isUsable: (value) => !value?.mapped?.Error && !!value?.mapped?.CompanyName && isMappedPolicyCurrent(value.mapped), mappedValue: (value) => value?.mapped ?? {}, }); return { ...result.value, plateKind: result.plateKind, attempts: result.attempts, }; } private async getCarBodyPlateInquiry( submission: NormalizedInquirySubmission>, ): Promise { const subjects = resolveInquirySubjects(submission); const policyholderNationalCode = subjects.carBodyPolicyNationalCode; if (!policyholderNationalCode) { throw new BadRequestException( "Car-body policyholder identity is required for a CAR_BODY inquiry.", ); } const result = await runPlateInquiryWithFallback({ vehicle: submission.vehicle, fallbackCurrentPlate: submission.dto.plate, query: (plate) => this.sandHubService.getCarBodyInquiry({ nationalCodeOfInsurer: policyholderNationalCode, plate: plate as any, }), isUsable: (value) => !!value?.mapped && isMappedPolicyCurrent(value.mapped) && !!( value.mapped.policyNumber || value.mapped.CompanyName || value.mapped.companyId ), mappedValue: (value) => value?.mapped ?? {}, }); return { ...result.value, plateKind: result.plateKind, attempts: result.attempts, }; } private async getThirdPartyVinInquiry( submission: NormalizedInquirySubmission>, options?: Record, ): Promise { const chassis = String( submission.vehicle?.vin ?? submission.dto.vin ?? "", ).trim(); if (!chassis) { throw new BadRequestException( "vehicle.vin is required for a VIN/chassis inquiry.", ); } if (submission.thirdPartyPolicyholder.unknown) { return { raw: null, mapped: {}, skipped: true }; } const subjects = resolveInquirySubjects(submission); const result = await this.sandHubService.getPolicyByChassisInquiry( { nationalCode: subjects.thirdPartyPolicyNationalCode, chassis, }, options, ); return { ...result, skipped: false }; } private async runParticipantPersonalInquiries( req: any, role: PartyRole, submission: NormalizedInquirySubmission>, options?: Record, ): Promise { const participants = submission.participants.legacy ? [submission.thirdPartyPolicyholder] : submission.participants.participants.filter( (participant) => !participant.unknown, ); const results: Record = {}; for (const participant of participants) { if (!participant.nationalCode || !participant.birthday) { throw new BadRequestException( `${participant.participantId} requires nationalCode and birthday for personal inquiry.`, ); } try { results[participant.participantId] = await this.sandHubService.getPersonalInquiry( participant.nationalCode, participant.birthday, options, ); } catch (error: any) { this.recordPartyCaseInquiryStatus( req, "person", role, false, { participantId: participant.participantId, completedParticipants: results, }, error, ); await this.persistBlameInquiryAudit(req); throw new BadRequestException( `${participant.participantId} personal identity inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`, ); } } this.recordPartyCaseInquiryStatus( req, "person", role, true, submission.participants.legacy ? results[submission.thirdPartyPolicyholder.participantId] : { participants: results }, ); } private async runVehicleOwnershipInquiry( req: any, role: PartyRole, submission: NormalizedInquirySubmission>, options?: Record, ): Promise { if (submission.participants.legacy || !submission.vehicleOwner) return; const plate = submission.vehicle?.currentPlate ?? submission.dto.plate; if (!plate) { throw new BadRequestException( "Current plate is required for vehicle ownership inquiry.", ); } try { const result = await this.sandHubService.getCarOwnershipInfo( plate, submission.vehicleOwner.nationalCode, options, ); this.recordPartyCaseInquiryStatus(req, "ownership", role, true, { participantId: submission.vehicleOwner.participantId, result, }); } catch (error: any) { this.recordPartyCaseInquiryStatus( req, "ownership", role, false, { participantId: submission.vehicleOwner.participantId }, error, ); await this.persistBlameInquiryAudit(req); throw new BadRequestException( `Vehicle ownership inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`, ); } } private async runRoleCompleteDriverLicenceInquiry( req: any, role: PartyRole, submission: NormalizedInquirySubmission>, options?: Record, ): Promise { if (submission.participants.legacy) return; if (submission.driver.hasDrivingLicense === false) { this.recordPartyCaseInquiryStatus(req, "drivingLicence", role, true, { participantId: submission.driver.participantId, skipped: true, reason: "Driver declared no driving licence", }); return; } if (!submission.driver.licenseNumber) { throw new BadRequestException( "Driver licence number is required when the driver has a licence.", ); } try { const result = await this.sandHubService.getDrivingLicenseInfo( submission.driver.nationalCode, submission.driver.licenseNumber, options, ); this.recordPartyCaseInquiryStatus(req, "drivingLicence", role, true, { participantId: submission.driver.participantId, result, }); } catch (error: any) { this.recordPartyCaseInquiryStatus( req, "drivingLicence", role, false, { participantId: submission.driver.participantId }, error, ); await this.persistBlameInquiryAudit(req); throw new BadRequestException( `Driver licence inquiry failed: ${error?.message || "استعلام در دسترس نیست"}`, ); } } /** Inquiry bundle for the still-routable legacy RequestManagement model. */ private async collectLegacyParticipantInquiries( submission: NormalizedInquirySubmission>, ): Promise | undefined> { if (submission.participants.legacy) return undefined; const personal: Record = {}; for (const participant of submission.participants.participants) { if (participant.unknown) continue; personal[participant.participantId] = await this.sandHubService.getPersonalInquiry( participant.nationalCode, participant.birthday, ); } const plate = submission.vehicle?.currentPlate ?? submission.dto.plate; const ownership = submission.vehicleOwner && plate ? await this.sandHubService.getCarOwnershipInfo( plate, submission.vehicleOwner.nationalCode, ) : undefined; const drivingLicence = submission.driver.hasDrivingLicense === false ? { skipped: true, reason: "Driver declared no driving licence", } : await this.sandHubService.getDrivingLicenseInfo( submission.driver.nationalCode, submission.driver.licenseNumber!, ); return { personal, ownership, drivingLicence }; } private async persistBlameInquiryAudit(req: any): Promise { if (!req?._id || !req.inquiries) return; try { await this.blameRequestDbService.findByIdAndUpdate(req._id, { $set: { inquiries: req.inquiries, ...(Array.isArray(req.parties) ? { parties: req.parties } : {}), }, }); } catch (error: any) { this.logger.error( `Failed to persist inquiry audit for blame request ${req._id}: ${error?.message || error}`, ); } } private async persistLegacyInquiryAudit( requestId: string, partyDetails: "firstPartyDetails" | "secondPartyDetails", submission: NormalizedInquirySubmission>, audit: Record, ): Promise { if (submission.participants.legacy) return; const prefix = partyDetails; try { await this.requestManagementDbService.findAndUpdate( { _id: requestId }, { $set: { [`${prefix}.participants`]: submission.participants.participants, [`${prefix}.participantRoles`]: submission.participants.roles, [`${prefix}.registrationState`]: submission.vehicle?.registrationState, [`${prefix}.previousPlateId`]: submission.vehicle?.previousPlate ? this.plateToPlateIdString(submission.vehicle.previousPlate) : undefined, [`${prefix}.vehicleVin`]: submission.vehicle?.vin, [`${prefix}.policyInquiry`]: audit, }, }, ); } catch (error: any) { this.logger.error( `Failed to persist legacy inquiry audit for request ${requestId}: ${error?.message || error}`, ); } } private getPartyIndex(req: any, role: PartyRole): number { return Array.isArray(req.parties) ? req.parties.findIndex((p) => p?.role === role) : -1; } private normalizeInquiryError(err: any): any { if (!err) return undefined; return { message: err?.message || String(err), status: err?.response?.status, data: err?.response?.data, ...(Array.isArray(err?.attempts) ? { attempts: err.attempts } : {}), }; } private recordCaseInquiryStatus( req: any, key: "thirdParty" | "carBody" | "person" | "drivingLicence" | string, has: boolean, data: any = {}, error?: any, ): void { const existingInquiries = req.inquiries?.toObject?.() ?? (req.inquiries && typeof req.inquiries === "object" ? req.inquiries : {}); req.inquiries = { ...existingInquiries, [key]: { has, data: data ?? {}, ...(error ? { error: this.normalizeInquiryError(error) } : {}), updatedAt: new Date(), }, }; } private recordPartyCaseInquiryStatus( req: any, key: "thirdParty" | "carBody" | "person" | "drivingLicence" | string, role: PartyRole, has: boolean, data: any = {}, error?: any, ): void { const existingData = req?.inquiries?.[key]?.data?.toObject?.() ?? req?.inquiries?.[key]?.data; const dataByRole = existingData && typeof existingData === "object" && !Array.isArray(existingData) ? existingData : {}; this.recordCaseInquiryStatus( req, key, has, { ...dataByRole, [role]: data ?? {}, }, error, ); } /** * True when `user` is the field-expert/registrar who created this IN_PERSON * file and is therefore allowed to act on behalf of its parties (fill the * normal granular steps for both parties). Normal USER callers never match. */ private isBlameOnBehalfActor(req: any, user: any): boolean { if (!req || !user) return false; if (req.creationMethod !== CreationMethod.IN_PERSON) return false; if (user.role === RoleEnum.FIELD_EXPERT) { return ( !!req.expertInitiated && !!req.initiatedByFieldExpertId && String(req.initiatedByFieldExpertId) === String(user.sub) ); } if (user.role === RoleEnum.REGISTRAR) { return ( !!req.registrarInitiated && !!req.initiatedByRegistrarId && String(req.initiatedByRegistrarId) === String(user.sub) ); } return false; } private assertPartyOwner(party: any, user: any, errMsg: string, req?: any) { // The initiating field-expert/registrar fills steps on behalf of parties. if (req && this.isBlameOnBehalfActor(req, user)) return; if (partyPersonMatchesUser(party?.person, user)) return; throw new ForbiddenException(errMsg); } /** * For the on-behalf (expert/registrar) flow only: when the caller explicitly * labels which party a submission belongs to (`partyRole`), make sure it * matches the party the file is currently collecting data for. Keeps the * sequential workflow intact while guaranteeing the expert never attributes a * submission to the wrong party. No-op for normal users (who never pass it). */ private assertExpectedPartyRole( expectedRole: string | undefined, actualRole: PartyRole, onBehalf: boolean, ) { if (!onBehalf || expectedRole == null || expectedRole === "") return; const want = String(expectedRole).toUpperCase() === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST; if (want !== actualRole) { throw new BadRequestException( `partyRole mismatch: this file is currently collecting the ${actualRole} party's data, but partyRole=${want} was sent. Submit ${actualRole} party data (advance the file to the other party first if needed).`, ); } } private async advanceWorkflowToNext( req: any, submittedStepKey: WorkflowStep, ) { // V6 call-center initiated THIRD_PARTY: skip FIRST_INITIAL_FORM because the // call-center agent already ran the inquiry during run-inquiry. // After FIRST_VIDEO, jump straight to FIRST_LOCATION. if ( req.callCenterInitiated && req.type === BlameRequestType.THIRD_PARTY && submittedStepKey === WorkflowStep.FIRST_VIDEO ) { const completed = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; if (!completed.includes(submittedStepKey)) completed.push(submittedStepKey); // Also mark FIRST_INITIAL_FORM as completed so the step chain is consistent if (!completed.includes(WorkflowStep.FIRST_INITIAL_FORM)) { completed.push(WorkflowStep.FIRST_INITIAL_FORM); } req.workflow.completedSteps = completed; req.workflow.currentStep = WorkflowStep.FIRST_LOCATION; req.workflow.nextStep = WorkflowStep.FIRST_VOICE; this.logger.debug( "[WORKFLOW] V6 call-center: skipped FIRST_INITIAL_FORM after FIRST_VIDEO, advanced to FIRST_LOCATION", ); return; } // 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; } // IN_PERSON THIRD_PARTY (expert/registrar-initiated): after first-party description // the expert collects the first party's signature before moving to the second party. // Skip FIRST_INVITE_SECOND and hold at WAITING_FOR_SIGNATURES so the sign endpoint // can be called for the first party. The workflow advances to FIRST_INVITE_SECOND // only after expertUploadPartySignatureV2/V3 records the first-party confirmation. if ( req.type === BlameRequestType.THIRD_PARTY && req.creationMethod === CreationMethod.IN_PERSON && (req.expertInitiated || req.registrarInitiated) && 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] IN_PERSON THIRD_PARTY: advanced to WAITING_FOR_SIGNATURES after FIRST_DESCRIPTION (first-party sign before second-party invite)", ); return; } // IN_PERSON THIRD_PARTY: guilt is decided on-site by the expert — there is // no waiting-for-field-expert phase. Replace WAITING_FOR_GUILT_DECISION // with WAITING_FOR_SIGNATURES wherever it would surface as current/next step. const isInPersonThirdParty = req.type === BlameRequestType.THIRD_PARTY && req.creationMethod === CreationMethod.IN_PERSON && (req.expertInitiated || req.registrarInitiated); // V6 call-center THIRD_PARTY: guilt is pre-decided (first party is always guilty), // so skip the WAITING_FOR_GUILT_DECISION waiting state just like IN_PERSON. const isCallCenterThirdParty = req.type === BlameRequestType.THIRD_PARTY && !!req.callCenterInitiated; const resolveStep = ( step: WorkflowStep | undefined, ): WorkflowStep | undefined => { if ( (isInPersonThirdParty || isCallCenterThirdParty) && step === WorkflowStep.WAITING_FOR_GUILT_DECISION ) { return WorkflowStep.WAITING_FOR_SIGNATURES; } return step; }; 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 = resolveStep( (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 = resolveStep(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 smsOrchestrationService: SmsOrchestrationService, private readonly expertDbService: ExpertDbService, private readonly expertFileActivityDbService: ExpertFileActivityDbService, 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, private readonly fanavaranLocationService: FanavaranLocationService, ) {} /** * Reject CAR_BODY submissions whose accident is older than the per-client * window (see `ClientService.getCarBodyAccidentMaxAgeDays`). The check is * scoped to CAR_BODY because THIRD_PARTY files do not record accidentDate * during the description step. * * `clientId` is best-effort — pass `party.person.clientId`, * `firstPartyDetails.firstPartyClient.clientId`, the SandHub-resolved * `client._id`, etc., whatever is available at the call site. When the * value is missing or invalid the helper falls back to the system default * window so the gate is still enforced. * * Throws `BadRequestException` when the accident instant cannot be parsed, * is in the future, or is older than the configured window. */ private async assertCarBodyAccidentWithinWindow(params: { clientId?: string | Types.ObjectId | null; accidentDate: Date | string | null | undefined; accidentTime?: string | null; }): Promise { const { clientId, accidentDate, accidentTime } = params; if (accidentDate == null) { throw new BadRequestException({ code: "CAR_BODY_ACCIDENT_DATE_REQUIRED", message: "CAR_BODY files require an accident date to enforce the submission window.", }); } const instant = this.parseAccidentInstant( accidentDate, accidentTime ?? undefined, ); if (!instant || Number.isNaN(instant.getTime())) { throw new BadRequestException({ code: "CAR_BODY_ACCIDENT_DATE_INVALID", message: `Invalid accidentDate/accidentTime: "${String(accidentDate)}"${ accidentTime ? ` "${accidentTime}"` : "" }.`, }); } const ageMs = Date.now() - instant.getTime(); if (ageMs < 0) { throw new BadRequestException({ code: "CAR_BODY_ACCIDENT_DATE_IN_FUTURE", message: "Accident date cannot be in the future.", }); } const maxAgeDays = await this.clientService.getCarBodyAccidentMaxAgeDays( clientId ?? undefined, ); const ageDays = ageMs / (24 * 60 * 60 * 1000); if (ageDays > maxAgeDays) { throw new BadRequestException({ code: "CAR_BODY_ACCIDENT_TOO_OLD", message: `CAR_BODY files must be submitted within ${maxAgeDays} day(s) of the accident. ` + `This accident occurred about ${Math.floor(ageDays)} day(s) ago.`, maxAgeDays, ageDays: Math.floor(ageDays), }); } } /** * Best-effort parser that combines a date input with an optional `HH:MM` * time in Iran (Asia/Tehran, UTC+3:30). Accepts a `Date`, an ISO datetime * string, or an ISO date-only string so DTO payloads (which send * `"YYYY-MM-DD"` + `"HH:MM"` in local Iran time) can be passed through * directly. Returns `null` when the result is not a finite instant. */ private parseAccidentInstant( date: Date | string, time?: string, ): Date | null { return parseIranLocalDateTime(date, time); } /** * 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, expectedRole?: string, ) { 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 && !this.isBlameOnBehalfActor(req, user)) { throw new ForbiddenException("Only first party can submit this step"); } this.assertExpectedPartyRole( expectedRole, PartyRole.FIRST, this.isBlameOnBehalfActor(req, user), ); // Set userId for first party if not already set if (!firstParty.person) firstParty.person = {} as any; if ( !firstParty.person.userId && user?.sub && !this.isBlameOnBehalfActor(req, user) ) { 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, expectedRole?: string, ) { 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?.currentStep) { throw new BadRequestException("Request workflow is not initialized"); } // Normal user flow: currentStep=CREATED, nextStep=CAR_BODY_ACCIDENT_TYPE // Expert IN_PERSON: currentStep=CAR_BODY_ACCIDENT_TYPE, nextStep=FIRST_VIDEO const isCorrectStep = req.workflow.currentStep === WorkflowStep.CAR_BODY_ACCIDENT_TYPE || req.workflow.nextStep === WorkflowStep.CAR_BODY_ACCIDENT_TYPE; if (!isCorrectStep) { throw new BadRequestException( `Invalid step. Expected currentStep or nextStep to be CAR_BODY_ACCIDENT_TYPE but got currentStep=${req.workflow.currentStep}, nextStep=${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", req, ); this.assertExpectedPartyRole( expectedRole, PartyRole.FIRST, this.isBlameOnBehalfActor(req, user), ); if (!firstParty.person) firstParty.person = {} as any; if ( !firstParty.person.userId && user?.sub && !this.isBlameOnBehalfActor(req, user) ) { 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 && !this.isBlameOnBehalfActor(req, user)) { 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); } // V6 call-center initiated THIRD_PARTY: inquiry already done by agent — skip // FIRST_INITIAL_FORM and go straight to FIRST_LOCATION. if (req.callCenterInitiated && req.type === BlameRequestType.THIRD_PARTY) { if (!completed.includes(WorkflowStep.FIRST_INITIAL_FORM)) { completed.push(WorkflowStep.FIRST_INITIAL_FORM); } req.workflow.completedSteps = completed; req.workflow.currentStep = WorkflowStep.FIRST_LOCATION; req.workflow.nextStep = WorkflowStep.FIRST_VOICE; } else { 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, expectedRole?: string, ) { 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_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`, req, ); this.assertExpectedPartyRole( expectedRole, role, this.isBlameOnBehalfActor(req, user), ); // 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 && !this.isBlameOnBehalfActor(req, user) ) { party.person.userId = Types.ObjectId.isValid(user.sub) ? new Types.ObjectId(user.sub) : undefined; } const inquirySubmission = this.normalizeInquiryInput( req.type, body, role, ); body = inquirySubmission.dto as unknown as AddPlateDto; this.applyInquiryParticipantsToParty(party, inquirySubmission); const policyholderUnknown = inquirySubmission.thirdPartyPolicyholder.unknown === true; // Validation: driver/insurer sameness rules if (body.driverIsInsurer === false) { if ( body.nationalCodeOfInsurer === body.nationalCodeOfDriver || (body.insurerLicense && body.driverLicense && 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; let inquirySource = "TEJARAT_BLOCK_INQUIRY"; let inquiryPlateKind = "CURRENT"; let inquiryAttempts: any[] = []; const inquiryClientId = party.person?.clientId ? String(party.person.clientId) : undefined; const inquiryOptions = this.policyInquiryOptions( req.type, role, inquiryClientId, ); try { const inquiry = await this.getThirdPartyPlateInquiry( inquirySubmission, inquiryOptions, ); inquiryRaw = inquiry.raw; inquiryMapped = inquiry.mapped; inquiryPlateKind = inquiry.plateKind; inquiryAttempts = inquiry.attempts; if (inquiry.offline?.source) { inquirySource = inquiry.offline.source; } 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)}`, ); this.recordPartyCaseInquiryStatus( req, "thirdParty", role, !inquiry.skipped, { source: inquirySource, plateKind: inquiry.plateKind, attempts: inquiry.attempts, raw: inquiryRaw, mapped: inquiryMapped, ...(inquiry.skipped ? { skipped: true, reason: "Third-party policyholder is unknown", } : {}), }, ); if (inquiry.offline?.fanavaranDriverId != null) { if (!party.person) party.person = {} as any; party.person.fanavaranDriverId = inquiry.offline.fanavaranDriverId; } } 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)}`, ); } this.recordPartyCaseInquiryStatus( req, "thirdParty", role, false, {}, err, ); await this.persistBlameInquiryAudit(req); 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, )}`, ); this.recordPartyCaseInquiryStatus(req, "thirdParty", role, false, { source: "TEJARAT_BLOCK_INQUIRY", raw: inquiryRaw, mapped: inquiryMapped, }); await this.persistBlameInquiryAudit(req); throw new HttpException( inquiryMapped.Error.Message || "Inquiry returned error", HttpStatus.BAD_REQUEST, ); } await this.runParticipantPersonalInquiries( req, role, inquirySubmission, inquiryOptions, ); await this.runVehicleOwnershipInquiry( req, role, inquirySubmission, inquiryOptions, ); await this.runRoleCompleteDriverLicenceInquiry( req, role, inquirySubmission, inquiryOptions, ); // Find client by company code const clientName = inquiryMapped?.CompanyName; if (!clientName && !policyholderUnknown) { const error = new BadRequestException( `CompanyName missing from inquiry response`, ); this.recordPartyCaseInquiryStatus( req, "thirdParty", role, false, { source: inquirySource, plateKind: inquiryPlateKind, attempts: inquiryAttempts, raw: inquiryRaw, mapped: inquiryMapped, }, error, ); await this.persistBlameInquiryAudit(req); throw error; } const companyCode = inquiryMapped?.CompanyCode; const client = clientName ? await this.clientService.findOrCreateClientByCompanyCode( companyCode, clientName, ) : null; if (clientName && !client) { const error = new BadRequestException( `CompanyCode missing or invalid in inquiry response`, ); this.recordPartyCaseInquiryStatus( req, "thirdParty", role, false, { source: inquirySource, plateKind: inquiryPlateKind, attempts: inquiryAttempts, raw: inquiryRaw, mapped: inquiryMapped, }, error, ); await this.persistBlameInquiryAudit(req); throw error; } // 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; if (resolvedClientId) 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: inquirySource, plateKind: inquiryPlateKind, attempts: inquiryAttempts, raw: inquiryRaw, mapped: inquiryMapped, }; if (!party.insurance) party.insurance = {} as any; party.insurance.policyNumber = inquiryMapped?.PrntCmpDocNo || inquiryMapped?.PrntPlcyCmpDocNo || inquiryMapped?.printNumber || inquiryMapped?.insuranceNumber; party.insurance.company = clientName; party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; party.insurance.startDate = inquiryMapped?.StartDate || inquiryMapped?.HBgnDte || inquiryMapped?.persianStartDate || inquiryMapped?.SatrtDate || inquiryMapped?.IssueDate; party.insurance.endDate = inquiryMapped?.EndDate || inquiryMapped?.HEndDte || inquiryMapped?.persianEndDate; // CAR BODY EXTERNAL API INQUIRY if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) { let carBodyInfo: any; try { carBodyInfo = await this.getCarBodyPlateInquiry(inquirySubmission); this.recordPartyCaseInquiryStatus(req, "carBody", role, true, { source: carBodyInfo.source, plateKind: carBodyInfo.plateKind, attempts: carBodyInfo.attempts, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }); } catch (err: any) { this.logger.error( `[CAR_BODY] inquiry failed for request=${req._id}: ${err?.message || err}`, ); this.recordPartyCaseInquiryStatus( req, "carBody", role, false, {}, err, ); await this.persistBlameInquiryAudit(req); this.throwCarBodyInquiryFailure(err); } // Raw + mapped stored under vehicle party.vehicle.inquiry = { ...party.vehicle.inquiry, carBody: { source: carBodyInfo.source, plateKind: carBodyInfo.plateKind, attempts: carBodyInfo.attempts, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }, }; // Flat insurance fields from car body inquiry const m = carBodyInfo.mapped; (party.insurance as any).carBodyInsurance = { policyNumber: m.policyNumber ?? null, policyId: m.policyId ?? null, contractId: m.contractId ?? null, companyId: m.companyId ?? null, companyName: m.CompanyName ?? null, insurerName: m.insurerName ?? null, insurerNationalCode: m.insurerNationalCode ?? null, ownerNationalCode: m.ownerNationalCode ?? null, ownerName: m.ownerName ?? null, customerName: m.customerName ?? null, customerLastName: m.customerLastName ?? null, customerFatherName: m.customerFatherName ?? null, customerMobile: m.customerMobile ?? null, customerAddress: m.customerAddress ?? null, customerPostalCode: m.customerPostalCode ?? null, chassisNumber: m.ChassisNumberField ?? null, vin: m.VinNumberField ?? null, motorNumber: m.EngineNumberField ?? null, vehicleGroup: m.vehicleGroupTitle ?? null, vehicleSystem: m.vehicleSystemTitle ?? null, vehicleKind: m.vehicleKind ?? null, builtYear: m.builtYear ?? null, cylinderCount: m.cylinderCount ?? null, passengerCount: m.passengerCount ?? null, usage: m.usage ?? null, startDate: m.StartDate ?? null, endDate: m.EndDate ?? null, issueDate: m.IssueDate ?? null, vehicleValue: m.vehicleValue ?? null, totalPremium: m.totalPremium ?? null, noLossYearsCount: m.noLossYearsCount ?? null, lossDocuments: m.lossDocuments ?? [], hasEndorsement: m.hasEndorsement ?? null, }; party.person.clientId = await this.resolveCarBodyPolicyClientId(m); } // Advance workflow await this.advanceWorkflowToNext(req, stepKey); // History const historyEntry: any = { 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, }, }; // Use findByIdAndUpdate instead of save() to avoid Mongoose VersionError // when two parties submit their forms concurrently. Each party only writes // its own paths, so the $set operations are non-overlapping and safe. await this.blameRequestDbService.findByIdAndUpdate(req._id, { $set: { [`parties.${idx}.person`]: party.person, [`parties.${idx}.participants`]: party.participants, [`parties.${idx}.participantRoles`]: party.participantRoles, [`parties.${idx}.vehicle`]: party.vehicle, [`parties.${idx}.insurance`]: party.insurance, inquiries: req.inquiries, "workflow.completedSteps": req.workflow.completedSteps, "workflow.currentStep": req.workflow.currentStep, "workflow.nextStep": req.workflow.nextStep, status: req.status, }, $push: { history: historyEntry }, }); return { requestId: req._id, publicId: req.publicId, workflow: req.workflow, blameStatus: req.blameStatus, status: req.status, }; } catch (error) { throw error; } } /** * V2 initial-form submitted with a VIN/chassis number instead of a plate. * * Performs: * 1. ESG `/inquiry/policyByChassis` (or its mock when `vinChassis` is off) * 2. Personal identity inquiry (same as the plate path) * * The inquiry result is mapped through the same `mapEsgPolicyByPlateToOldFormat` * normaliser used by `getTejaratBlockInquiry`, so all downstream party/vehicle * /insurance field assignments are identical. */ async initialFormVinV2( requestId: string, body: InitialFormVinDto, user: any, expectedRole?: string, ) { 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_INITIAL_FORM && stepKey !== WorkflowStep.SECOND_INITIAL_FORM ) { throw new BadRequestException( `Invalid step. Expected FIRST_INITIAL_FORM or SECOND_INITIAL_FORM but got ${stepKey}`, ); } 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`, req, ); this.assertExpectedPartyRole( expectedRole, role, this.isBlameOnBehalfActor(req, user), ); if (!party.person) party.person = {} as any; if ( !party.person.userId && user?.sub && !this.isBlameOnBehalfActor(req, user) ) { party.person.userId = Types.ObjectId.isValid(user.sub) ? new Types.ObjectId(user.sub) : undefined; } const inquirySubmission = this.normalizeInquiryInput( req.type, body, role, ); body = inquirySubmission.dto as unknown as InitialFormVinDto; const subjects = resolveInquirySubjects(inquirySubmission); this.applyInquiryParticipantsToParty(party, inquirySubmission); const policyholderUnknown = inquirySubmission.thirdPartyPolicyholder.unknown === true; // Validation: driver/insurer sameness rules (identical to plate path) if (body.driverIsInsurer === false) { if ( body.nationalCodeOfInsurer === body.nationalCodeOfDriver || (body.insurerLicense && body.driverLicense && 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: ESG policyByChassis ---- const inquiryClientId = party.person?.clientId ? String(party.person.clientId) : undefined; const inquiryOptions = this.policyInquiryOptions( req.type, role, inquiryClientId, ); let inquiryRaw: any; let inquiryMapped: any; try { const inquiry = await this.getThirdPartyVinInquiry( inquirySubmission, inquiryOptions, ); inquiryRaw = inquiry.raw; inquiryMapped = inquiry.mapped; this.logger.log( `[ESG] policyByChassis raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`, ); this.logger.log( `[ESG] policyByChassis mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`, ); this.recordPartyCaseInquiryStatus( req, "thirdParty", role, !inquiry.skipped, { source: "ESG_VIN_INQUIRY", raw: inquiryRaw, mapped: inquiryMapped, ...(inquiry.skipped ? { skipped: true, reason: "Third-party policyholder is unknown", } : {}), }, ); } catch (err: any) { this.logger.error( `[ESG] policyByChassis failed for request=${req._id}: ${err?.message || err}`, ); if (err?.response) { this.logger.error( `[ESG] policyByChassis response for request=${req._id}: status=${ err.response.status }, data=${JSON.stringify(err.response.data)}`, ); } this.recordPartyCaseInquiryStatus( req, "thirdParty", role, false, {}, err, ); await this.persistBlameInquiryAudit(req); throw new HttpException("VIN inquiry failed", HttpStatus.BAD_REQUEST); } if (inquiryMapped?.Error) { this.logger.warn( `[ESG] policyByChassis error for request=${req._id}: ${JSON.stringify(inquiryMapped.Error)}`, ); this.recordPartyCaseInquiryStatus(req, "thirdParty", role, false, { source: "ESG_VIN_INQUIRY", raw: inquiryRaw, mapped: inquiryMapped, }); await this.persistBlameInquiryAudit(req); throw new HttpException( inquiryMapped.Error.Message || "VIN inquiry returned error", HttpStatus.BAD_REQUEST, ); } await this.runParticipantPersonalInquiries( req, role, inquirySubmission, inquiryOptions, ); await this.runVehicleOwnershipInquiry( req, role, inquirySubmission, inquiryOptions, ); await this.runRoleCompleteDriverLicenceInquiry( req, role, inquirySubmission, inquiryOptions, ); // Resolve insurer client from inquiry response const clientName = inquiryMapped?.CompanyName; if (!clientName && !policyholderUnknown) { const error = new BadRequestException( "CompanyName missing from VIN inquiry response", ); this.recordPartyCaseInquiryStatus( req, "thirdParty", role, false, { source: "ESG_VIN_INQUIRY", raw: inquiryRaw, mapped: inquiryMapped, }, error, ); await this.persistBlameInquiryAudit(req); throw error; } const companyCode = inquiryMapped?.CompanyCode; const client = clientName ? await this.clientService.findOrCreateClientByCompanyCode( companyCode, clientName, ) : null; if (clientName && !client) { const error = new BadRequestException( "CompanyCode missing or invalid in VIN inquiry response", ); this.recordPartyCaseInquiryStatus( req, "thirdParty", role, false, { source: "ESG_VIN_INQUIRY", raw: inquiryRaw, mapped: inquiryMapped, }, error, ); await this.persistBlameInquiryAudit(req); throw error; } // Persist party data const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; if (resolvedClientId) 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; // Derive plateId from the plate parts returned by the VIN inquiry. // Plk2 may be a numeric letter code (e.g. 5 → "د") or already a Persian // letter string — resolvePlk2Letter handles both forms. // Falls back to the submitted VIN so plateId is never empty. party.vehicle.plateId = inquirySubmission.vehicle?.currentPlate ? this.plateToPlateIdString(inquirySubmission.vehicle.currentPlate) : this.plateToPlateIdString({ ir: inquiryMapped?.PlkSrl, leftDigits: inquiryMapped?.Plk1, centerAlphabet: this.resolvePlk2Letter( inquiryMapped?.Plk2 ?? inquiryMapped?.plateLetterid, ), centerDigits: inquiryMapped?.Plk3, }) || body.vin; party.vehicle.name = inquiryMapped?.MapTypNam; party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`; party.vehicle.inquiry = { source: "ESG_VIN_INQUIRY", raw: inquiryRaw, mapped: inquiryMapped, }; if (!party.insurance) party.insurance = {} as any; party.insurance.policyNumber = inquiryMapped?.PrntCmpDocNo || inquiryMapped?.PrntPlcyCmpDocNo || inquiryMapped?.printNumber || inquiryMapped?.insuranceNumber; party.insurance.company = clientName; party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; party.insurance.startDate = inquiryMapped?.StartDate || inquiryMapped?.HBgnDte || inquiryMapped?.persianStartDate || inquiryMapped?.SatrtDate || inquiryMapped?.IssueDate; party.insurance.endDate = inquiryMapped?.EndDate || inquiryMapped?.HEndDte || inquiryMapped?.persianEndDate; // CAR_BODY eligibility is independent of the third-party policy above. // The VIN flow must make the same deployment-scoped lookup as the plate // flow before the case is allowed to advance. if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) { try { const carBodyInfo = await this.sandHubService.getCarBodyInquiry({ nationalCodeOfInsurer: subjects.carBodyPolicyNationalCode!, plate: body.vin, }); this.recordPartyCaseInquiryStatus(req, "carBody", role, true, { source: carBodyInfo.source, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }); party.vehicle.inquiry = { ...party.vehicle.inquiry, carBody: { source: carBodyInfo.source, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }, }; const m = carBodyInfo.mapped as any; (party.insurance as any).carBodyInsurance = { policyNumber: m.policyNumber ?? null, companyId: m.companyId ?? m.CompanyCode ?? null, companyName: m.CompanyName ?? null, insurerName: m.insurerName ?? null, chassisNumber: m.ChassisNumberField ?? null, vin: m.VinNumberField ?? null, motorNumber: m.EngineNumberField ?? null, startDate: m.StartDate ?? null, endDate: m.EndDate ?? null, issueDate: m.IssueDate ?? null, noLossYearsCount: m.noLossYearsCount ?? null, lossDocuments: m.lossDocuments ?? [], }; party.person.clientId = await this.resolveCarBodyPolicyClientId(m); } catch (err: any) { this.logger.error( `[CAR_BODY] VIN inquiry failed for request=${req._id}: ${err?.message || err}`, ); this.recordPartyCaseInquiryStatus( req, "carBody", role, false, {}, err, ); await this.persistBlameInquiryAudit(req); this.throwCarBodyInquiryFailure(err); } } // Advance workflow await this.advanceWorkflowToNext(req, stepKey); const historyEntry: any = { 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, inquiryType: "VIN", vin: body.vin, }, }; await this.blameRequestDbService.findByIdAndUpdate(req._id, { $set: { [`parties.${idx}.person`]: party.person, [`parties.${idx}.participants`]: party.participants, [`parties.${idx}.participantRoles`]: party.participantRoles, [`parties.${idx}.vehicle`]: party.vehicle, [`parties.${idx}.insurance`]: party.insurance, inquiries: req.inquiries, "workflow.completedSteps": req.workflow.completedSteps, "workflow.currentStep": req.workflow.currentStep, "workflow.nextStep": req.workflow.nextStep, status: req.status, }, $push: { history: historyEntry }, }); return { requestId: req._id, publicId: req.publicId, workflow: req.workflow, blameStatus: req.blameStatus, status: req.status, }; } catch (error) { throw error; } } async addDetailLocationV2( requestId: string, body: LocationDto, user: any, expectedRole?: string, ) { 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", req, ); this.assertExpectedPartyRole( expectedRole, role, this.isBlameOnBehalfActor(req, user), ); // 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 && !this.isBlameOnBehalfActor(req, user) ) { 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, expectedRole?: string, ) { 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", req, ); this.assertExpectedPartyRole( expectedRole, role, this.isBlameOnBehalfActor(req, user), ); // 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 && !this.isBlameOnBehalfActor(req, user) ) { 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, expectedRole?: string, ) { 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", req, ); this.assertExpectedPartyRole( expectedRole, role, this.isBlameOnBehalfActor(req, user), ); // 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 && !this.isBlameOnBehalfActor(req, user) ) { 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.", ); } // Gate stale CAR_BODY submissions per the party's client window // (falls back to the system default when no client is attached yet). await this.assertCarBodyAccidentWithinWindow({ clientId: party.person?.clientId, accidentDate: body.accidentDate, accidentTime: body.accidentTime, }); 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; // V6 call-center initiated THIRD_PARTY: first party is always the guilty party. // If blameStatus is still UNKNOWN here (pre-fix file or race condition), force it to AGREED now. if ( isSecondThirdParty && req.callCenterInitiated && req.blameStatus !== BlameStatus.AGREED ) { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx !== -1) { if (!req.parties[firstIdx].statement) req.parties[firstIdx].statement = {} as any; req.parties[firstIdx].statement.admitsGuilt = true; req.parties[firstIdx].statement.claimsDamage = false; req.parties[firstIdx].statement.acceptsExpertOpinion = false; } req.blameStatus = BlameStatus.AGREED; } // 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); req.workflow.completedSteps = completed; req.workflow.currentStep = WorkflowStep.WAITING_FOR_SIGNATURES; req.workflow.nextStep = WorkflowStep.WAITING_FOR_SIGNATURES; req.status = CaseStatus.WAITING_FOR_SIGNATURES; closedByMutualAgreement = true; if ( isSecondThirdParty && closedByMutualAgreement && req.status === CaseStatus.WAITING_FOR_SIGNATURES && req.blameStatus === BlameStatus.AGREED && !this.isBlameOnBehalfActor(req, user) ) { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); const firstPhone = (req.parties?.[firstIdx]?.person as any) ?.phoneNumber; const secondPhone = (req.parties?.[secondIdx]?.person as any) ?.phoneNumber; const baseUrl = process.env.URL; const token = String(req._id); const targets = [ firstPhone ? { receptor: firstPhone, link: `${baseUrl}/user?token=${token}`, } : null, secondPhone ? { receptor: secondPhone, link: `${baseUrl}/user2?token=${token}`, } : null, ].filter( (t): t is { receptor: string; link: string } => !!t?.receptor && !!t?.link, ); for (const target of targets) { await this.smsOrchestrationService.sendThirdPartyAgreementSignNotice( { receptor: target.receptor, publicId: req.publicId, link: target.link, }, ); } } } } const expertOnBehalfThirdPartySecond = !closedByMutualAgreement && isSecondThirdParty && this.isBlameOnBehalfActor(req, user); if (expertOnBehalfThirdPartySecond) { // Expert-initiated IN_PERSON: the field expert is on the accident scene // and decides guilt directly. By contract the FIRST party (the one the // expert registers first) is always the guilty party, so we record the // decision here and move straight to signatures — there is no // waiting-for-field-expert phase for these files. const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); const guiltyUserId = req.parties?.[firstIdx]?.person?.userId; if (!guiltyUserId) { throw new BadRequestException( "First party must be verified (OTP) before finishing the blame file.", ); } const guiltyPhone = (req.parties?.[firstIdx]?.person as any)?.phoneNumber; const damagedPhone = (req.parties?.[secondIdx]?.person as any) ?.phoneNumber; if (!(req as any).expert) (req as any).expert = {}; (req as any).expert.decision = { guiltyPartyId: new Types.ObjectId(String(guiltyUserId)), description: `مقصر توسط کارشناس در محل حادثه مشخص شد. کاربر ${guiltyPhone ?? ""} مقصر و کاربر ${damagedPhone ?? ""} زیان دیده می‌باشد.`, } as any; const completed = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; if (!completed.includes(stepKey)) completed.push(stepKey); req.workflow.completedSteps = completed; req.workflow.currentStep = WorkflowStep.WAITING_FOR_SIGNATURES; req.workflow.nextStep = WorkflowStep.WAITING_FOR_SIGNATURES; req.status = CaseStatus.WAITING_FOR_SIGNATURES; } else 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 ? { mutualAgreementWithoutExpert: true, closedWithoutExpert: true, awaitingSignatures: 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", req, ); // 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 = this.smsOrchestrationService.buildInviteLink( frontendRoute, requestId, "v1", ); await this.smsOrchestrationService.sendInviteLink( secondPartyPhone, req.publicId, url, ); 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 = this.smsOrchestrationService.buildInviteLink( frontendRoute, requestId, "v1", ); await this.smsOrchestrationService.sendInviteLink( phoneNumber, req.publicId, url, ); 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"); } } /** * Expert-initiated IN_PERSON variant of `addSecondPartyV2`. * * The expert is physically with both parties, so there is no invite link/SMS: * we simply ensure the SECOND party exists and advance the workflow from * FIRST_INVITE_SECOND to SECOND_INITIAL_FORM so the expert can fill the * second party's steps using the same granular endpoints. */ async expertAdvanceToSecondPartyV2( requestId: string, user: any, phoneNumber?: string, ) { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if (!this.isBlameOnBehalfActor(req, user)) { throw new ForbiddenException( "Only the initiating expert/registrar can advance this in-person file.", ); } if (req.type !== BlameRequestType.THIRD_PARTY) { throw new BadRequestException( "Only THIRD_PARTY files have a second party.", ); } 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}`, ); } if (!phoneNumber) { throw new BadRequestException( "phoneNumber is required to register the second party", ); } const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if ( firstIdx !== -1 && (req.parties[firstIdx]?.person as any)?.phoneNumber === phoneNumber ) { throw new BadRequestException( "Second party phone number cannot be the same as first party", ); } // Create second party placeholder (no userId yet — will be bound after OTP verify) let secondIdx = this.getPartyIndex(req, PartyRole.SECOND); if (secondIdx === -1) { if (!Array.isArray(req.parties)) req.parties = []; req.parties.push({ role: PartyRole.SECOND, person: { phoneNumber } as any, } as any); } else { if (!req.parties[secondIdx].person) req.parties[secondIdx].person = {} as any; (req.parties[secondIdx].person as any).phoneNumber = phoneNumber; } // If a valid OTP is already active for this number, return 200 immediately — // no duplicate SMS, no unnecessary write, no error shown to the expert. const canonicalPhoneAdv = normalizeIranMobile(phoneNumber) ?? phoneNumber; const existingUserAdv = await this.userDbService.findOne( buildUserLookupByPhone(canonicalPhoneAdv), ); if (existingUserAdv && isOtpExpiryActive(existingUserAdv.otpExpire)) { return { requestId: req._id, publicId: req.publicId, workflow: req.workflow, message: `OTP already sent to ${phoneNumber} and is still valid. Collect the code and call verify-party-otp.`, }; } // Send OTP to second party — expert collects it from them in person try { await this.userAuthService.sendOtpRequest(phoneNumber); } catch (e: any) { if (e?.message?.includes("Wait for expiry")) { return { requestId: req._id, publicId: req.publicId, workflow: req.workflow, message: `OTP already sent to ${phoneNumber} and is still valid. Collect the code and call verify-party-otp.`, }; } throw e; } // Stay at FIRST_INVITE_SECOND — workflow will advance only after verifyPartyOtp req.status = CaseStatus.WAITING_FOR_SECOND_PARTY; if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "SECOND_PARTY_OTP_SENT", actor: { actorId: Types.ObjectId.isValid(user?.sub) ? new Types.ObjectId(user.sub) : undefined, actorName: user?.fullName, actorType: "user", }, metadata: { inPersonExpert: true, phoneNumber, note: "OTP sent; call verify-party-otp to bind and advance to SECOND_INITIAL_FORM", }, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId, workflow: req.workflow, message: `OTP sent to ${phoneNumber}. Collect the code and call verify-party-otp.`, }; } // 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", inquirySubmission?: NormalizedInquirySubmission>, policyInquiry?: Record, participantInquiries?: Record, ) { if (request.type === "THIRD_PARTY" && partyType === "firstParty") { this.sandHubService.assertInsuranceMatchesDeployment( sandHubReport, "THIRD_PARTY", ); } const clientName = sandHubReport?.CompanyName; const companyCode = sandHubReport?.CompanyCode; const policyholderUnknown = inquirySubmission?.thirdPartyPolicyholder.unknown === true; const client = clientName ? await this.clientService.findOrCreateClientByCompanyCode( companyCode, clientName, ) : null; if (!client && !policyholderUnknown) { 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 { if (client) { await this.userDbService.findOneAndUpdate( { _id: user.sub }, { clientKey: (client as any)["_doc"]?._id ?? (client as any)._id }, ); } const sandHubDocData = { ...sandHubReport, plate: body.plate, nationalCode: body.nationalCodeOfInsurer, }; const sandHubDoc = policyInquiry?.skipped ? null : await this.sandHubService.sandHubDocumentCreator( user.sub, request["_doc"]._id, sandHubDocData, ); if (partyType === "firstParty" && sandHubDoc) { 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}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, }; if (client) { setFields[`${partyDetails}.${partyType}Client.clientId`] = (client as any)["_doc"]?._id ?? (client as any)._id; setFields[`${partyDetails}.${partyType}Client.clientName`] = clientName; } if (inquirySubmission && !inquirySubmission.participants.legacy) { setFields[`${partyDetails}.participants`] = inquirySubmission.participants.participants; setFields[`${partyDetails}.participantRoles`] = inquirySubmission.participants.roles; setFields[`${partyDetails}.participantInquiries`] = participantInquiries ?? {}; setFields[`${partyDetails}.policyInquiry`] = policyInquiry ?? {}; setFields[`${partyDetails}.registrationState`] = inquirySubmission.vehicle?.registrationState; setFields[`${partyDetails}.previousPlateId`] = inquirySubmission.vehicle ?.previousPlate ? this.plateToPlateIdString(inquirySubmission.vehicle.previousPlate) : undefined; setFields[`${partyDetails}.vehicleVin`] = inquirySubmission.vehicle?.vin; } // For CAR_BODY type, persist the provider response and its mapped fields. if (request.type === "CAR_BODY" && partyType === "firstParty") { const carBodyInquiry = inquirySubmission && !inquirySubmission.participants.legacy ? await this.getCarBodyPlateInquiry(inquirySubmission) : await this.sandHubService.getCarBodyInquiry({ nationalCodeOfInsurer: body.nationalCodeOfInsurer, plate: body.plate, } as any); const carBodyInfo = carBodyInquiry.mapped as any; 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; setFields["firstPartyDetails.firstPartyCarBodyInsuranceDetail.raw"] = carBodyInquiry.raw; setFields["firstPartyDetails.firstPartyCarBodyInsuranceDetail.source"] = carBodyInquiry.source; if (inquirySubmission && !inquirySubmission.participants.legacy) { setFields[`${partyDetails}.policyInquiry`] = { thirdParty: policyInquiry, carBody: { source: carBodyInquiry.source, plateKind: carBodyInquiry.plateKind, attempts: carBodyInquiry.attempts, raw: carBodyInquiry.raw, mapped: carBodyInquiry.mapped, }, }; } setFields[`${partyDetails}.${partyType}Client.clientId`] = await this.resolveCarBodyPolicyClientId(carBodyInfo); } // 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); if (er instanceof HttpException) throw 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; const partyRole = isFirstParty ? PartyRole.FIRST : PartyRole.SECOND; const inquirySubmission = this.normalizeInquiryInput( request.type as BlameRequestType, body, partyRole, ); body = inquirySubmission.dto as unknown as AddPlateDto; 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 legacyPartyPath = isFirstParty ? "firstPartyDetails" : "secondPartyDetails"; let policyInquiry: any; try { policyInquiry = inquirySubmission.participants.legacy ? await (async () => { const response = await this.sandHubService.getSandHubResponse({ plate: body.plate, nationalCodeOfInsurer: body.nationalCodeOfInsurer, }); return { raw: response, mapped: response["_doc"] || response, plateKind: "CURRENT", attempts: [ { plateKind: "CURRENT", succeeded: true, usable: true }, ], }; })() : await this.getThirdPartyPlateInquiry(inquirySubmission); await this.persistLegacyInquiryAudit( requestId, legacyPartyPath, inquirySubmission, policyInquiry, ); } catch (error: any) { await this.persistLegacyInquiryAudit( requestId, legacyPartyPath, inquirySubmission, { failed: true, attempts: error?.attempts ?? [], error: this.normalizeInquiryError(error), }, ); throw error; } const sanHubResponse = policyInquiry.mapped; let participantInquiries: Record | undefined; try { participantInquiries = await this.collectLegacyParticipantInquiries(inquirySubmission); } catch (error: any) { await this.persistLegacyInquiryAudit( requestId, legacyPartyPath, inquirySubmission, { ...policyInquiry, participantInquiryFailure: this.normalizeInquiryError(error), }, ); throw error; } // 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", inquirySubmission, policyInquiry, participantInquiries, ); } 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 = this.smsOrchestrationService.buildInviteLink( frontendRoutes, requestId, "v1", ); await this.smsOrchestrationService.sendInviteLink( phoneNumber, request.publicId, URL, ); 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) { 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 recordBlameExpertActivity(args: { expertId: string; tenantId: string; requestId: string; eventType: ExpertFileActivityType; idempotencyKey?: string; }): Promise { if ( !Types.ObjectId.isValid(args.expertId) || !Types.ObjectId.isValid(args.tenantId) || !Types.ObjectId.isValid(args.requestId) ) { return; } await this.expertFileActivityDbService.recordEvent({ expertId: args.expertId, tenantId: args.tenantId, fileId: args.requestId, fileType: ExpertFileKind.BLAME, eventType: args.eventType, idempotencyKey: args.idempotencyKey, }); } 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) { await this.smsOrchestrationService.sendTextByKey( phoneNumberToNotify, "parties_disagree_notify", "متاسفانه طرف مقابل با نظر کارشناس مخالفت کرد. فرآیند آنلاین این پرونده بسته شده است و جهت پیگیری حضوری اقدام نمایید.", ); } } 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); const tenantIdStr = request.firstPartyDetails?.firstPartyClient?.clientId?.toString?.() || request.secondPartyDetails?.secondPartyClient?.clientId?.toString?.() || ""; await this.recordBlameExpertActivity({ expertId: actorIdStr, tenantId: tenantIdStr, requestId: requestIdStr, eventType: ExpertFileActivityType.HANDLED, idempotencyKey: `blame:${requestIdStr}:handled:${actorIdStr}`, }); } 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) { const key = message.includes("24 ساعت") ? "one_party_accepted_wait_24h" : "one_party_signed_wait_signature"; await this.smsOrchestrationService.sendTextByKey( phoneNumber, key, message, ); } // await this.autoCloseRequestService.scheduleAutoClose({ // requestId: updatedReq._id.toString(), // runAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // }); } async userReply( props, isAccept: boolean, file: Express.Multer.File, ): Promise { const { requestId, user } = props; if (!file) throw new BadRequestException("A signature file is required."); const request = await this.requestManagementDbService.findOne(requestId); if (!request) throw new NotFoundException("Request not found."); const signDetail = await this.userSign.create({ fileName: file.filename, userId: user.sub, path: file.path, requestId, }); const updatePayload = this.buildReplyUpdate( request, user.sub, isAccept, signDetail, ); await this.requestManagementDbService.findAndUpdate( { _id: requestId }, updatePayload, ); const updatedReq = await this.requestManagementDbService.findOne(requestId); await this.handlePostReplyStateChanges(updatedReq, request); return new UserReplyDto(requestId); } async userResend( files: { [key: string]: Express.Multer.File[] }, user: any, requestId: string, description: string, ) { const request = await this.requestManagementDbService.findOne(requestId); if (!request) { throw new NotFoundException("Request not found"); } const partyKey = this.determinePartyAndValidate(request, user.sub); const { voiceId, uploadedDocumentIds } = await this.processResendUploads( files, requestId, ); const updatePayload: any = { $set: { [`expertResendReply.${partyKey}.userReply`]: description, }, }; if (voiceId) { updatePayload.$set[`expertResendReply.${partyKey}.voice`] = voiceId; } for (const docType in uploadedDocumentIds) { updatePayload.$set[`expertResendReply.${partyKey}.documents.${docType}`] = uploadedDocumentIds[docType]; } await this.requestManagementDbService.findByIdAndUpdate( requestId, updatePayload, ); await this.updateRequestStatusIfNeeded(requestId); return { message: "Update saved successfully." }; } 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, }, 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 async verifyExpertAccessForBlameV2( req: any, expert: any, ): Promise { // FILE_REVIEWER can access V4/V5 FileMaker-sealed files. // They must be the assigned reviewer (or the file is still open for taking). if (expert?.role === RoleEnum.FILE_REVIEWER) { if ( !req?.isMadeByFileMaker || !req?.expertInitiated || req?.creationMethod !== CreationMethod.IN_PERSON ) { throw new ForbiddenException( "FileReviewer can only access V4/V5 FileMaker files.", ); } const clientKey = requireActorClientKey(expert); if (!blameCaseTouchesClient(req, clientKey)) { throw new ForbiddenException( "This file does not belong to your organization.", ); } if ( req.status !== CaseStatus.WAITING_FOR_FILE_REVIEWER && req.status !== CaseStatus.WAITING_FOR_EXPERT && req.status !== CaseStatus.COMPLETED ) { throw new ForbiddenException( "This file has not been sealed by a FileMaker yet.", ); } // Enforce reviewer assignment: must be assigned to them or open (no reviewer yet). // If the file is open (no assignedFileReviewerId) we atomically claim it now — // this handles the case where a reviewer skipped the explicit lock step and went // straight to working on the file (e.g. accident-fields before calling the lock // endpoint). Without this, assignedFileReviewerId is never written and the file // disappears from the reviewer's list after the blame moves to WAITING_FOR_EXPERT. const assignedId = req.assignedFileReviewerId ? String(req.assignedFileReviewerId) : null; if (assignedId && assignedId !== String(expert.sub)) { throw new ForbiddenException( "This file has been taken by another FileReviewer.", ); } if (!assignedId) { // Atomically claim — ignore if another reviewer won the race (they would have // been caught by the assignedId check above on their own first call). await this.fanavaranLocationService.assertMakerReviewerLocationCompatible( { fileMakerId: req?.initiatedByFieldExpertId ? String(req.initiatedByFieldExpertId) : null, fileReviewerId: String(expert.sub), }, ); await this.blameRequestDbService.findOneAndUpdate( { _id: req._id, $or: [ { assignedFileReviewerId: { $exists: false } }, { assignedFileReviewerId: null }, ], }, { $set: { assignedFileReviewerId: new Types.ObjectId(String(expert.sub)), }, }, { new: false }, ); } else { // Already assigned to this reviewer — re-check location compatibility. await this.fanavaranLocationService.assertMakerReviewerLocationCompatible( { fileMakerId: req?.initiatedByFieldExpertId ? String(req.initiatedByFieldExpertId) : null, fileReviewerId: String(expert.sub), }, ); } return; } 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; 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(); // Always start with an empty first-party placeholder. // For LINK files the phone + userId are stored when send-link is called. // For IN_PERSON files the phone + userId are stored when verify-party-otp is called. const parties: any[] = [{ role: PartyRole.FIRST, person: {} }]; // Before creating, clean up any orphaned IN_PERSON shell from this expert // that never had its first party OTP verified. if (dto.creationMethod === CreationMethod.IN_PERSON) { await this.blameRequestDbService.deleteMany({ initiatedByFieldExpertId: expertId, creationMethod: CreationMethod.IN_PERSON, "workflow.currentStep": firstStep.stepKey, // still at CREATED "parties.0.person.userId": { $exists: false }, // no verified party yet }); } const isFileMakerRole = (expert as any)?.role === RoleEnum.FILE_MAKER; 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, ...(isFileMakerRole ? { isMadeByFileMaker: true } : {}), // V5 only: flag that causes the resulting claim to require FileMaker // approval before fanavaran submission. V4 files never set this. ...(dto.requiresFileMakerApproval ? { requiresFileMakerApproval: true } : {}), }); 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: this.resolveBlameHistoryActorType(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, dto: { phoneNumber: string }, ): Promise<{ sent: boolean; sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: 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.", ); } await this.verifyExpertAccessForBlameV2(req, expert); const phone = (dto?.phoneNumber || "").trim(); if (!phone) throw new BadRequestException("phoneNumber is required"); if (!process.env.URL) { throw new InternalServerErrorException( "URL environment variable is not configured", ); } // Store / update the first party's phone and bind a user account const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found on request"); const userId = await this.getOrCreateUserByPhoneNumber(phone); if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any; req.parties[firstIdx].person.phoneNumber = normalizeIranMobile(phone) ?? phone; req.parties[firstIdx].person.userId = userId; const expertName = `${expert?.lastName || ""}`.trim() || "کارشناس"; const sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: string; }[] = []; const firstLink = this.smsOrchestrationService.buildBlamePartyLink( requestId, "FIRST", "v2", ); const smsSent = await this.smsOrchestrationService.sendFieldExpertLink({ receptor: phone, type: req.type, expertLastName: expertName, link: firstLink, }); sentTo.push({ role: PartyRole.FIRST, phoneNumber: phone, smsSent, linkUrl: firstLink, }); 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: this.resolveBlameHistoryActorType(expert), }, metadata: { sentTo, template: "yara-field-expert-link" }, }); await (req as any).save(); return { sent: sentTo.length > 0 && sentTo.every((x) => x.smsSent), 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.", ); } await 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[] = []; // Helper: send OTP or silently succeed when a valid OTP already exists. const sendOrSkipIfActive = async (phone: string): Promise => { const canonical = normalizeIranMobile(phone) ?? phone; const existing = await this.userDbService.findOne( buildUserLookupByPhone(canonical), ); if (existing && isOtpExpiryActive(existing.otpExpire)) { sent.push(phone); // already has a live OTP — treat as sent return; } try { await this.userAuthService.sendOtpRequest(phone); sent.push(phone); } catch (e: any) { if (e?.message?.includes("Wait for expiry")) { sent.push(phone); // race: sendOtpRequest found it active too — treat as sent return; } throw e; } }; await sendOrSkipIfActive(dto.firstPartyPhoneNumber); if (dto.secondPartyPhoneNumber) { await sendOrSkipIfActive(dto.secondPartyPhoneNumber); } 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.", ); } await this.verifyExpertAccessForBlameV2(req, expert); const now = Date.now(); const verifyOne = async ( phone: string, otp: string, ): Promise => { const user = await this.userDbService.findOne( buildUserLookupByPhone(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 = normalizeIranMobile(dto.firstPartyPhoneNumber) ?? 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 = normalizeIranMobile(dto.secondPartyPhoneNumber) ?? 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: this.resolveBlameHistoryActorType(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.", }; } /** * Send an OTP to a single party phone (one-at-a-time IN_PERSON flow). * The expert sends to the first party, collects the code and verifies, fills * their data, then enters the second party's phone and repeats — instead of * sending an invite link, the OTP registers/authenticates the second party. */ async sendPartyOtpV2( actor: any, requestId: string, dto: { phoneNumber: 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.", ); } await this.verifyExpertAccessForBlameV2(req, actor); const phone = (dto?.phoneNumber || "").trim(); if (!phone) throw new BadRequestException("phoneNumber is required"); // --- Cooldown guard (in-memory history, catches already-persisted sends) --- const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000); const recentlySentToPhone = (req.history || []) .slice() .reverse() .find( (event: any) => event?.type === "PARTY_OTP_SENT" && event?.metadata?.phoneNumber === phone && event?.timestamp && new Date(event.timestamp) > twoMinutesAgo, ); if (recentlySentToPhone) { return { sent: true, message: `OTP already sent to ${phone} and is still valid. Collect the code from the party, then call verify-party-otp.`, }; } // --- DB-level cooldown guard (catches burst/concurrent requests before history is saved) --- // Read the user's otpExpire directly from the users collection. If a live // OTP already exists return 200 immediately — no duplicate SMS, no DB writes. const canonicalPhone = normalizeIranMobile(phone) ?? phone; const existingUser = await this.userDbService.findOne( buildUserLookupByPhone(canonicalPhone), ); if (existingUser && isOtpExpiryActive(existingUser.otpExpire)) { return { sent: true, message: `OTP already sent to ${phone} and is still valid. Collect the code from the party, then call verify-party-otp.`, }; } try { await this.userAuthService.sendOtpRequest(phone); } catch (e: any) { if (e?.message?.includes("Wait for expiry")) { return { sent: true, message: `OTP already sent to ${phone} and is still valid. Collect the code from the party, then call verify-party-otp.`, }; } throw e; } if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "PARTY_OTP_SENT", actor: { actorId: new Types.ObjectId(actor.sub), actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(actor), }, metadata: { phoneNumber: phone }, } as any); await (req as any).save(); return { sent: true, message: `OTP sent to ${phone}. Collect the code from the party, then call verify-party-otp.`, }; } /** * Verify a single party OTP and bind that party's user account. * `partyRole` is optional: when omitted, FIRST is bound if it has no user yet, * otherwise SECOND (created if missing). For THIRD_PARTY the second party is * registered here instead of receiving an invite link. */ async verifyPartyOtpV2( actor: any, requestId: string, dto: { phoneNumber: string; otp: string; partyRole?: string }, ): Promise<{ verified: boolean; partyRole: PartyRole; 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.", ); } await this.verifyExpertAccessForBlameV2(req, actor); const phone = (dto?.phoneNumber || "").trim(); const otp = (dto?.otp || "").trim(); if (!phone || !otp) { throw new BadRequestException("phoneNumber and otp are required"); } if (!Array.isArray(req.parties)) req.parties = []; const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); // Resolve which party this phone belongs to. let role: PartyRole; if (dto.partyRole === "SECOND" || dto.partyRole === "FIRST") { role = dto.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST; } else { const firstHasUser = !!( firstIdx !== -1 && req.parties[firstIdx]?.person?.userId ); role = firstHasUser ? PartyRole.SECOND : PartyRole.FIRST; } if ( role === PartyRole.SECOND && req.type !== BlameRequestType.THIRD_PARTY ) { throw new BadRequestException("CAR_BODY has only one (first) party."); } // Verify the OTP and resolve the user id. const now = Date.now(); const user = await this.userDbService.findOne( buildUserLookupByPhone(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}. Send an OTP first.`, ); if (u.otpExpire < now) throw new BadRequestException( `OTP expired for ${phone}. Request a new OTP.`, ); const valid = await this.hashService.compare(otp, u.otp); if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`); const userId = u._id as Types.ObjectId; const canonicalPhone = normalizeIranMobile(phone) ?? phone; const firstPartyUserId = firstIdx !== -1 ? req.parties[firstIdx]?.person?.userId : undefined; if ( role === PartyRole.SECOND && firstPartyUserId != null && String(firstPartyUserId) === String(userId) ) { throw new BadRequestException( "The damaged party must verify OTP with a different phone number than the guilty party. " + "Both parties cannot be the same user account.", ); } if (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 = userId; req.parties[firstIdx].person.phoneNumber = canonicalPhone; } else { const firstPhone = firstIdx !== -1 ? (req.parties[firstIdx]?.person as any)?.phoneNumber : undefined; const firstVariants = firstPhone ? iranMobileLookupVariants(firstPhone) : []; if ( firstPhone && (firstPhone === phone || firstVariants.includes(phone) || firstVariants.includes(canonicalPhone)) ) { throw new BadRequestException( "Second party phone number cannot be the same as first party.", ); } const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); if (secondIdx === -1) { req.parties.push({ role: PartyRole.SECOND, person: { userId, phoneNumber: canonicalPhone }, } as any); } else { if (!req.parties[secondIdx].person) req.parties[secondIdx].person = {} as any; req.parties[secondIdx].person.userId = userId; req.parties[secondIdx].person.phoneNumber = canonicalPhone; } } if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "PARTY_OTP_VERIFIED", actor: { actorId: new Types.ObjectId(actor.sub), actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(actor), }, metadata: { partyRole: role, phoneNumber: phone }, } as any); // For IN_PERSON expert/registrar flow: after first party OTP is verified and // the workflow is still at CREATED, advance past any intro step to the first // real data-entry step. // // THIRD_PARTY: skip FIRST_BLAME_CONFESSION (first party is always guilty) and // land on FIRST_VIDEO. // CAR_BODY: no confession exists; advance from CREATED to CAR_BODY_ACCIDENT_TYPE // so the expert can fill the car-body form before video. if ( role === PartyRole.FIRST && req.workflow?.currentStep === WorkflowStep.CREATED ) { const completed = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; if (req.type === BlameRequestType.CAR_BODY) { // Advance CREATED → CAR_BODY_ACCIDENT_TYPE // nextStep was already set to CAR_BODY_ACCIDENT_TYPE at creation time; // look up its own nextPossibleSteps so we can set nextStep correctly. const carBodyStepDoc = await this.getWorkflowStep({ stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE, }); const afterCarBody = (carBodyStepDoc?.nextPossibleSteps?.[0] as WorkflowStep) ?? WorkflowStep.FIRST_VIDEO; req.workflow.completedSteps = completed; req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE; req.workflow.nextStep = afterCarBody; req.history.push({ type: "AUTO_ADVANCED_TO_CAR_BODY_FORM", actor: { actorId: new Types.ObjectId(actor.sub), actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(actor), }, metadata: { advancedTo: WorkflowStep.CAR_BODY_ACCIDENT_TYPE }, } as any); } else { // THIRD_PARTY: skip confession — first party is always guilty in IN_PERSON const step2 = await this.getWorkflowStep({ stepNumber: 2 }); // FIRST_BLAME_CONFESSION const step2Key = step2.stepKey as WorkflowStep; const step3 = await this.getWorkflowStep({ stepNumber: 3 }); // FIRST_VIDEO const step3Key = step3.stepKey as WorkflowStep; const nextAfterVideo = (step3.nextPossibleSteps?.[0] as WorkflowStep) ?? WorkflowStep.FIRST_INITIAL_FORM; if (!completed.includes(step2Key)) completed.push(step2Key); req.workflow.completedSteps = completed; req.workflow.currentStep = step3Key; req.workflow.nextStep = nextAfterVideo; // Auto-guilt: first party is always guilty in expert-initiated IN_PERSON const fIdx = this.getPartyIndex(req, PartyRole.FIRST); if (fIdx !== -1) { if (!req.parties[fIdx].statement) req.parties[fIdx].statement = {} as any; req.parties[fIdx].statement.admitsGuilt = true; req.parties[fIdx].statement.claimsDamage = false; req.parties[fIdx].statement.acceptsExpertOpinion = false; } req.blameStatus = BlameStatus.AGREED; req.history.push({ type: "AUTO_CONFESSION_SKIPPED", actor: { actorId: new Types.ObjectId(actor.sub), actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(actor), }, metadata: { reason: "IN_PERSON expert-initiated: first party is always guilty; confession auto-resolved", stepKey: step2Key, advancedTo: step3Key, }, } as any); } } // For IN_PERSON expert/registrar flow: after second party OTP is verified and // the workflow is at FIRST_INVITE_SECOND, advance to SECOND_INITIAL_FORM. if ( role === PartyRole.SECOND && req.workflow?.currentStep === WorkflowStep.FIRST_INVITE_SECOND ) { await this.advanceWorkflowToNext(req, WorkflowStep.FIRST_INVITE_SECOND); req.status = CaseStatus.WAITING_FOR_SECOND_PARTY; req.history.push({ type: "SECOND_PARTY_OTP_VERIFIED_ADVANCED", actor: { actorId: new Types.ObjectId(actor.sub), actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(actor), }, metadata: { phoneNumber: phone, advancedTo: WorkflowStep.SECOND_INITIAL_FORM, }, } as any); } await (req as any).save(); return { verified: true, partyRole: role, message: `${role} party verified and bound to phone ${phone}.`, }; } /** * 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, query: ListQueryV2Dto = {}, ): Promise { try { const linkedUserIds = user?.role === RoleEnum.USER || !user?.role ? await resolveLinkedUserIdStrings(this.userDbService, user) : []; const orConditions = buildBlamePartyAccessOrConditions( user, linkedUserIds, ); if (user?.role === RoleEnum.FIELD_EXPERT && user?.sub) { orConditions.push({ expertInitiated: true, initiatedByFieldExpertId: new Types.ObjectId(user.sub), }); } else if (user?.role === RoleEnum.REGISTRAR && user?.sub) { orConditions.push({ registrarInitiated: true, initiatedByRegistrarId: new Types.ObjectId(user.sub), }); } // Snapshot-party fallback: find blames via claims where this user appears // in claim.snapshot.parties (the most reliable path for IN_PERSON flows). const idVariants = collectUserIdVariants( ...(linkedUserIds.length > 0 ? linkedUserIds : [user?.sub].filter(Boolean)), ); if (idVariants.length > 0) { const snapshotFilter = idVariants.length === 1 ? { "snapshot.parties.person.userId": idVariants[0] } : { "snapshot.parties.person.userId": { $in: idVariants } }; const linkedClaims = (await this.claimCaseDbService.find( snapshotFilter as any, { lean: true, select: "blameRequestId owner.userId" }, )) as any[]; for (const c of linkedClaims) { if (!c.blameRequestId) continue; // Exclude if this user IS the owner (guilty party) — they already show // up via the claim-owner route; we only want the damaged party here. const ownerStr = c.owner?.userId ? String(c.owner.userId) : null; const isOwner = ownerStr ? idVariants.some((id) => String(id) === ownerStr) : false; if (isOwner) continue; orConditions.push({ _id: c.blameRequestId }); } } if (orConditions.length === 0) { return { list: [], total: 0 }; } const requests = await this.blameRequestDbService.find( orConditions.length === 1 ? (orConditions[0] as any) : ({ $or: orConditions } as any), { select: "publicId requestNo type status blameStatus createdAt updatedAt parties expertInitiated registrarInitiated initiatedByFieldExpertId initiatedByRegistrarId creationMethod", }, ); const enriched = requests.map((req: any) => { const isInitiator = (user?.role === RoleEnum.FIELD_EXPERT && req.expertInitiated && String(req.initiatedByFieldExpertId) === String(user.sub)) || (user?.role === RoleEnum.REGISTRAR && req.registrarInitiated && String(req.initiatedByRegistrarId) === String(user.sub)); const party = req.parties?.find((p: any) => partyPersonMatchesUser(p?.person, user, linkedUserIds), ); const obj = req.toObject(); delete obj.parties; return { ...obj, userSide: party?.role ?? null, initiatedByMe: isInitiator, }; }); const paged = applyListQueryV2( enriched, { publicId: (r) => String((r as { publicId?: string }).publicId ?? ""), createdAt: (r) => (r as { createdAt?: Date }).createdAt, requestNo: (r) => String((r as { requestNo?: string }).requestNo ?? ""), status: (r) => String((r as { status?: string }).status ?? ""), searchExtras: (r) => { const row = r as { blameStatus?: string; type?: string; userSide?: string; }; return [ row.blameStatus, row.type, row.userSide, String((r as { _id?: unknown })._id ?? ""), ].filter(Boolean) as string[]; }, }, query, ); return { list: paged.list, total: paged.total, page: paged.page, limit: paged.limit, totalPages: paged.totalPages, }; } 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; } /** Party payload for user blame detail: role links are visible, but participant PII and inquiry blobs remain private. */ private async sanitizePartyForBlameUserView( party: any, ): Promise> { if (!party || typeof party !== "object") { return {}; } const person = party.person; const safePerson = person ? { userId: person.userId != null ? String(person.userId) : undefined, fullName: person.fullName, clientId: person.clientId != null ? String(person.clientId) : undefined, } : undefined; const vehicle = party.vehicle ? { plateId: party.vehicle.plateId, previousPlateId: party.vehicle.previousPlateId, registrationState: party.vehicle.registrationState, name: party.vehicle.name, model: party.vehicle.model, type: party.vehicle.type, isNew: party.vehicle.isNew, } : undefined; const participants = Array.isArray(party.participants) ? party.participants.map((participant: any) => ({ participantId: participant.participantId, fullName: participant.fullName, hasDrivingLicense: participant.hasDrivingLicense, unknown: participant.unknown, })) : undefined; const evidenceRaw = party.evidence as Record | undefined; const evidence: Record | undefined = evidenceRaw ? { ...evidenceRaw } : undefined; if (evidence) { if (party.role === PartyRole.FIRST && evidence.videoId) { const videoDoc = await this.blameVideoDbService.findById( String(evidence.videoId), ); if (videoDoc?.path) { evidence.videoUrl = buildFileLink(videoDoc.path); } } delete evidence.videoId; const voiceIds = evidence.voices; if (Array.isArray(voiceIds)) { const voiceUrls: string[] = []; for (const voiceId of voiceIds) { const voiceDoc = await this.blameVoiceDbService.findById( String(voiceId), ); if (voiceDoc?.path) { voiceUrls.push(buildFileLink(voiceDoc.path)); } } evidence.voiceUrls = voiceUrls; } delete evidence.voices; } return { role: party.role, participants, participantRoles: party.participantRoles, person: safePerson, carBodyFirstForm: party.carBodyFirstForm, location: party.location, vehicle, insurance: party.insurance, statement: party.statement, evidence, confirmation: party.confirmation, }; } private async resolveBlameExpertDisplayNameFromPlain( expert: Record, ): Promise { const decision = expert.decision as Record | undefined; const decided = decision?.decidedByExpertId; const assigned = expert.assignedExpertId; const raw = decided ?? assigned; if (raw == null || raw === "") return undefined; const sid = String(raw); if (!Types.ObjectId.isValid(sid)) return undefined; const doc = await this.expertDbService.findOne({ _id: new Types.ObjectId(sid), }); if (!doc) return undefined; return `${doc.firstName || ""} ${doc.lastName || ""}`.trim() || undefined; } /** Expert subdocument for user view: string ids, trimmed snapshot (name only), plus `expertName`. */ private async buildExpertForBlameUserView( expertRaw: any, ): Promise | undefined> { if (!expertRaw || typeof expertRaw !== "object") return undefined; const out = JSON.parse(JSON.stringify(expertRaw)) as Record< string, unknown >; const decision = out.decision as Record | undefined; let expertName: string | undefined; if (decision) { if (decision.guiltyPartyId != null) { decision.guiltyPartyId = String(decision.guiltyPartyId); } if (decision.decidedByExpertId != null) { decision.decidedByExpertId = String(decision.decidedByExpertId); } const snap = decision.expertProfileSnapshot as | Record | undefined; if (snap) { expertName = `${snap.firstName ?? ""} ${snap.lastName ?? ""}`.trim() || undefined; decision.expertProfileSnapshot = { firstName: snap.firstName, lastName: snap.lastName, }; } } if (out.assignedExpertId != null) { out.assignedExpertId = String(out.assignedExpertId); } if (!expertName) { expertName = await this.resolveBlameExpertDisplayNameFromPlain(out); } return { ...out, expertName }; } /** * 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 linkedUserIds = user?.role === RoleEnum.USER || !user?.role ? await resolveLinkedUserIdStrings(this.userDbService, user) : []; 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) => partyPersonMatchesUser(p?.person, user, linkedUserIds), ); 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; const phoneVariants = iranMobileLookupVariants(user?.username); for (const p of req.parties || []) { const person = p?.person; if (!person) continue; const phoneMatch = person.phoneNumber && phoneVariants.some( (v) => v === person.phoneNumber || v === normalizeIranMobile(person.phoneNumber), ); if (phoneMatch && !person?.userId) { p.person = p.person || {}; (p.person as any).userId = new Types.ObjectId(user.sub); if (user?.username) { (p.person as any).phoneNumber = normalizeIranMobile(user.username) ?? user.username; } 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) }; const allParties = Array.isArray(plain.parties) ? plain.parties : []; const visibleParties = isFieldExpertOwner || isRegistrarOwner ? allParties : allParties.filter((p: any) => partyPersonMatchesUser(p?.person, user, linkedUserIds), ); const parties = await Promise.all( visibleParties.map((p: any) => this.sanitizePartyForBlameUserView(p)), ); const expert = await this.buildExpertForBlameUserView(plain.expert); return { requestNo: plain.requestNo, publicId: plain.publicId, type: plain.type, creationMethod: plain.creationMethod, status: plain.status, blameStatus: plain.blameStatus, workflow: plain.workflow, parties, expert, carBodyInsuranceDetail: plain.carBodyInsuranceDetail, 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.", ); } await 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."); } await 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 inquirySubmission = this.normalizeInquiryInput( req.type, formData.firstPartyPlate, PartyRole.FIRST, ); this.recordInquiryParticipantContext( req, PartyRole.FIRST, inquirySubmission, ); const existingFirstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST); if (existingFirstPartyIndex !== -1) { this.applyInquiryParticipantsToParty( req.parties[existingFirstPartyIndex], inquirySubmission, ); } const firstPartyPlate = inquirySubmission.dto; let sandHubReport: any; let thirdPartyInquiry: any; try { const inquiry = inquirySubmission.participants.legacy ? await (async () => { const response = await this.sandHubService.getSandHubResponse({ plate: firstPartyPlate.plate, nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, }); const mapped = response["_doc"] || response; return { raw: mapped, mapped, plateKind: "CURRENT", attempts: [ { plateKind: "CURRENT", succeeded: true, usable: true }, ], }; })() : await this.getThirdPartyPlateInquiry(inquirySubmission); thirdPartyInquiry = inquiry; sandHubReport = inquiry.mapped; this.recordPartyCaseInquiryStatus( req, "thirdParty", PartyRole.FIRST, true, { source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY", plateKind: inquiry.plateKind, attempts: inquiry.attempts, raw: inquiry.raw, mapped: inquiry.mapped, }, ); } catch (e) { this.logger.error("SandHub error in expertCompleteCarBodyFormV2:", e); this.recordPartyCaseInquiryStatus( req, "thirdParty", PartyRole.FIRST, false, {}, e, ); await (req as any).save(); throw new InternalServerErrorException( "Failed to process plate information.", ); } const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; const companyCode = sandHubReport?.CompanyCode; const client = companyCode ? await this.clientService.findOrCreateClientByCompanyCode( companyCode, clientName, ) : await this.clientService.findOne({ clientName }); if (!client) { const error = new NotFoundException( `Client not found for company: ${clientName}`, ); this.recordPartyCaseInquiryStatus( req, "thirdParty", PartyRole.FIRST, false, { source: thirdPartyInquiry?.offline?.source ?? "TEJARAT_BLOCK_INQUIRY", plateKind: thirdPartyInquiry?.plateKind, attempts: thirdPartyInquiry?.attempts, raw: thirdPartyInquiry?.raw, mapped: thirdPartyInquiry?.mapped ?? sandHubReport, }, error, ); await this.persistBlameInquiryAudit(req); throw error; } let carBodyInquiry: any; try { carBodyInquiry = await this.getCarBodyPlateInquiry(inquirySubmission); } catch (error: any) { this.recordPartyCaseInquiryStatus( req, "carBody", PartyRole.FIRST, false, { attempts: error?.attempts ?? [] }, error, ); await (req as any).save(); this.throwCarBodyInquiryFailure(error); } const carBodyInfo = carBodyInquiry.mapped as any; this.recordPartyCaseInquiryStatus(req, "carBody", PartyRole.FIRST, true, { source: carBodyInquiry.source, plateKind: carBodyInquiry.plateKind, attempts: carBodyInquiry.attempts, raw: carBodyInquiry.raw, mapped: carBodyInfo, }); const carBodyClientId = await this.resolveCarBodyPolicyClientId(carBodyInfo); if (!inquirySubmission.participants.legacy) { const options = carBodyClientId ? { clientId: String(carBodyClientId) } : undefined; await this.runParticipantPersonalInquiries( req, PartyRole.FIRST, inquirySubmission, options, ); await this.runVehicleOwnershipInquiry( req, PartyRole.FIRST, inquirySubmission, options, ); await this.runRoleCompleteDriverLicenceInquiry( req, PartyRole.FIRST, inquirySubmission, options, ); } // Gate stale CAR_BODY submissions against the body-policy insurer, not // the unrelated third-party insurer resolved above. if (formData?.expertDescription?.accidentDate) { await this.assertCarBodyAccidentWithinWindow({ clientId: carBodyClientId, accidentDate: formData.expertDescription.accidentDate, accidentTime: formData.expertDescription.accidentTime, }); } const firstParty: any = { role: PartyRole.FIRST, participants: inquirySubmission.participants.participants, participantRoles: inquirySubmission.participants.roles, person: { userId: firstPartyUserId, phoneNumber: normalizeIranMobile(formData.firstPartyPhoneNumber) ?? formData.firstPartyPhoneNumber, clientId: carBodyClientId, 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, registrationState: inquirySubmission.vehicle?.registrationState, previousPlateId: inquirySubmission.vehicle?.previousPlate ? this.plateToPlateIdString(inquirySubmission.vehicle.previousPlate) : undefined, vin: inquirySubmission.vehicle?.vin, inquiry: { ...sandHubReport, carBody: { source: carBodyInquiry.source, raw: carBodyInquiry.raw, mapped: carBodyInfo, }, }, }, 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, raw: carBodyInquiry.raw, source: carBodyInquiry.source, }, }, 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: this.resolveBlameHistoryActorType(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."); } await this.verifyExpertAccessForBlameV2(req, expert); if (!formData.secondParty || !formData.guiltyPartyPhoneNumber) { throw new BadRequestException( "secondParty and guiltyPartyPhoneNumber are required.", ); } if (formData.guiltyPartyPhoneNumber !== formData.firstPartyPhoneNumber) { throw new BadRequestException( "The first party is the guilty party in this flow.", ); } 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 inquirySubmission = this.normalizeInquiryInput( req.type, plateDto, role, ); this.recordInquiryParticipantContext(req, role, inquirySubmission); const existingPartyIndex = this.getPartyIndex(req, role); if (existingPartyIndex !== -1) { this.applyInquiryParticipantsToParty( req.parties[existingPartyIndex], inquirySubmission, ); } plateDto = inquirySubmission.dto; const policyholderUnknown = inquirySubmission.thirdPartyPolicyholder.unknown === true; let inquiry: any; try { if (inquirySubmission.participants.legacy) { const response = await this.sandHubService.getSandHubResponse({ plate: plateDto.plate, nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer, }); const mapped = response["_doc"] || response; inquiry = { raw: mapped, mapped, plateKind: "CURRENT", attempts: [{ plateKind: "CURRENT", succeeded: true, usable: true }], }; } else { inquiry = await this.getThirdPartyPlateInquiry(inquirySubmission); } } catch (err: any) { this.recordPartyCaseInquiryStatus( req, "thirdParty", role, false, {}, err, ); await (req as any).save(); throw err; } const sandHubReport = inquiry.mapped as any; if (role === PartyRole.FIRST) { this.sandHubService.assertInsuranceMatchesDeployment( sandHubReport, "THIRD_PARTY", ); } this.recordPartyCaseInquiryStatus( req, "thirdParty", role, !inquiry.skipped, { source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY", plateKind: inquiry.plateKind, attempts: inquiry.attempts, raw: inquiry.raw, mapped: inquiry.mapped, ...(inquiry.skipped ? { skipped: true, reason: "Third-party policyholder is unknown", } : {}), }, ); const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; const companyCode = sandHubReport?.CompanyCode; const client = clientName ? companyCode ? await this.clientService.findOrCreateClientByCompanyCode( companyCode, clientName, ) : await this.clientService.findOne({ clientName }) : null; if (!client && !policyholderUnknown) { const error = new NotFoundException( `Client not found for company: ${clientName}`, ); this.recordPartyCaseInquiryStatus( req, "thirdParty", role, false, { source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY", plateKind: inquiry.plateKind, attempts: inquiry.attempts, raw: inquiry.raw, mapped: inquiry.mapped, }, error, ); await this.persistBlameInquiryAudit(req); throw error; } const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; if (!inquirySubmission.participants.legacy) { const options = resolvedClientId ? { clientId: String(resolvedClientId) } : undefined; await this.runParticipantPersonalInquiries( req, role, inquirySubmission, options, ); await this.runVehicleOwnershipInquiry( req, role, inquirySubmission, options, ); await this.runRoleCompleteDriverLicenceInquiry( req, role, inquirySubmission, options, ); } return { role, participants: inquirySubmission.participants.participants, participantRoles: inquirySubmission.participants.roles, person: { userId, phoneNumber: normalizeIranMobile(phoneNumber) ?? phoneNumber, ...(resolvedClientId ? { clientId: resolvedClientId } : {}), 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, registrationState: inquirySubmission.vehicle?.registrationState, previousPlateId: inquirySubmission.vehicle?.previousPlate ? this.plateToPlateIdString(inquirySubmission.vehicle.previousPlate) : undefined, vin: inquirySubmission.vehicle?.vin, 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: this.resolveBlameHistoryActorType(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.", ); } await 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: this.resolveBlameHistoryActorType(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.", ); } await 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: this.resolveBlameHistoryActorType(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.", ); } await 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: this.resolveBlameHistoryActorType(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.", ); } await 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, }, }); } } else if ( // THIRD_PARTY IN_PERSON: first party just signed — advance workflow to // FIRST_INVITE_SECOND so the frontend knows to proceed to the second-party // registration step. Status stays WAITING_FOR_SIGNATURES. req.type === BlameRequestType.THIRD_PARTY && req.creationMethod === CreationMethod.IN_PERSON && (req.expertInitiated || req.registrarInitiated) && partyRole === PartyRole.FIRST ) { await this.blameRequestDbService.findByIdAndUpdate(requestId, { $set: { "workflow.currentStep": WorkflowStep.FIRST_INVITE_SECOND as any, "workflow.nextStep": WorkflowStep.SECOND_INITIAL_FORM as any, }, }); message = "First party signature recorded. Proceed to register the second party."; } 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 && !req.registrarInitiated) { throw new BadRequestException( "This endpoint is only for expert- or registrar-initiated files.", ); } await this.verifyExpertAccessForBlameV2(req, expert); // In the V2 IN_PERSON mirror flow guilt is resolved automatically after both // parties complete their narrative steps (addDescriptionV2). The sign step for // both parties must happen BEFORE accident-fields is called — calling it earlier // would overwrite the workflow and cause the frontend to skip the sign page. if ( req.creationMethod === CreationMethod.IN_PERSON && req.status !== CaseStatus.COMPLETED ) { throw new BadRequestException( `accident-fields can only be submitted after both parties have signed (current status: ${req.status}). Complete both signatures first.`, ); } if (!req.expert) req.expert = {} as any; if (!req.expert.decision) req.expert.decision = {} as any; // Ensure guilty party is recorded (first party is always guilty in IN_PERSON flow) const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const guiltyUserId = req.parties?.[firstIdx]?.person?.userId; const guiltyPhone = (req.parties?.[firstIdx]?.person as any)?.phoneNumber; const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); const damagedPhone = (req.parties?.[secondIdx]?.person as any)?.phoneNumber; (req.expert as any).decision = { ...(req.expert as any).decision, guiltyPartyId: guiltyUserId ? new Types.ObjectId(String(guiltyUserId)) : (req.expert as any).decision?.guiltyPartyId, description: (req.expert as any).decision?.description || `مقصر توسط کارشناس در محل حادثه مشخص شد. کاربر ${guiltyPhone ?? ""} مقصر و کاربر ${damagedPhone ?? ""} زیان دیده می‌باشد.`, decidedAt: (req.expert as any).decision?.decidedAt || new Date(), decidedByExpertId: new Types.ObjectId(String(expert.sub)), fields: { accidentWay: fields.accidentWay, accidentReason: fields.accidentReason, accidentType: fields.accidentType, }, }; // IN_PERSON V2 mirror: accident-fields is a post-sign supplemental step called // only after COMPLETED (both parties have signed). Record WAITING_FOR_GUILT_DECISION // in completedSteps here — it was intentionally omitted from description so the // frontend does not route to this page before signatures are collected. if (req.creationMethod === CreationMethod.IN_PERSON) { const completed = Array.isArray(req.workflow?.completedSteps) ? req.workflow.completedSteps : []; if (!completed.includes(WorkflowStep.WAITING_FOR_GUILT_DECISION)) { completed.push(WorkflowStep.WAITING_FOR_GUILT_DECISION); } if (req.workflow) { req.workflow.completedSteps = completed; } if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "ACCIDENT_FIELDS_SAVED", actor: { actorId: new Types.ObjectId(String(expert.sub)), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(expert), }, metadata: { accidentWay: fields.accidentWay, }, } as any); } await (req as any).save(); return { requestId: req._id, workflow: req.workflow, status: req.status }; } /** * 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", ); } // The first party is the guilty party in every supported THIRD_PARTY flow. if (request.type === "THIRD_PARTY") { if (formData.guiltyPartyPhoneNumber !== formData.firstPartyPhoneNumber) { throw new BadRequestException( "The first party is the guilty party in this flow.", ); } } 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 firstInquirySubmission = this.normalizeInquiryInput( BlameRequestType.THIRD_PARTY, formData.firstPartyPlate, PartyRole.FIRST, ); const firstPartyPlate = firstInquirySubmission.dto; let firstPolicyInquiry: any; try { firstPolicyInquiry = firstInquirySubmission.participants.legacy ? await (async () => { const response = await this.sandHubService.getSandHubResponse({ plate: firstPartyPlate.plate, nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, }); return { raw: response, mapped: response["_doc"] || response, plateKind: "CURRENT", attempts: [ { plateKind: "CURRENT", succeeded: true, usable: true }, ], }; })() : await this.getThirdPartyPlateInquiry(firstInquirySubmission); const sandHubReport = firstPolicyInquiry.mapped; await this.persistLegacyInquiryAudit( requestId, "firstPartyDetails", firstInquirySubmission, firstPolicyInquiry, ); this.sandHubService.assertInsuranceMatchesDeployment( sandHubReport, "THIRD_PARTY", ); 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.findOrCreateClientByCompanyCode( companyCode, clientName, ) : await this.clientService.findOne({ clientName: clientName }); if (!client) { const error = new NotFoundException( `Client not found for company: ${clientName}`, ); await this.persistLegacyInquiryAudit( requestId, "firstPartyDetails", firstInquirySubmission, { ...(firstPolicyInquiry ?? {}), failed: true, error: this.normalizeInquiryError(error), }, ); throw error; } // 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, }; if (!firstInquirySubmission.participants.legacy) { firstPartyDetails.participants = firstInquirySubmission.participants.participants; firstPartyDetails.participantRoles = firstInquirySubmission.participants.roles; firstPartyDetails.participantInquiries = await this.collectLegacyParticipantInquiries( firstInquirySubmission, ); firstPartyDetails.policyInquiry = firstPolicyInquiry; firstPartyDetails.registrationState = firstInquirySubmission.vehicle?.registrationState; firstPartyDetails.previousPlateId = firstInquirySubmission.vehicle ?.previousPlate ? this.plateToPlateIdString( firstInquirySubmission.vehicle.previousPlate, ) : undefined; firstPartyDetails.vehicleVin = firstInquirySubmission.vehicle?.vin; } // Update the firstPartyDetails in the payload with all plate data updatePayload.$set.firstPartyDetails = firstPartyDetails; stepsToAdd.push(StepsEnum.F_addPlate); } catch (plateError) { await this.persistLegacyInquiryAudit( requestId, "firstPartyDetails", firstInquirySubmission, { ...(firstPolicyInquiry ?? {}), failed: true, attempts: (plateError as any)?.attempts ?? [], error: this.normalizeInquiryError(plateError), }, ); this.logger.error("Error processing first party plate:", plateError); if (plateError instanceof HttpException) throw plateError; throw new InternalServerErrorException( "Failed to process first party plate information", ); } // Process second party plate const secondInquirySubmission = this.normalizeInquiryInput( BlameRequestType.THIRD_PARTY, formData.secondParty.plate, PartyRole.SECOND, ); let secondPolicyInquiry: any; try { const secondPartyPlate = secondInquirySubmission.dto; secondPolicyInquiry = secondInquirySubmission.participants.legacy ? await (async () => { const response = await this.sandHubService.getSandHubResponse({ plate: secondPartyPlate.plate, nationalCodeOfInsurer: secondPartyPlate.nationalCodeOfInsurer, }); return { raw: response, mapped: response["_doc"] || response, plateKind: "CURRENT", attempts: [ { plateKind: "CURRENT", succeeded: true, usable: true }, ], }; })() : await this.getThirdPartyPlateInquiry(secondInquirySubmission); const sandHubReport = secondPolicyInquiry.mapped; await this.persistLegacyInquiryAudit( requestId, "secondPartyDetails", secondInquirySubmission, secondPolicyInquiry, ); const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; const companyCode = sandHubReport?.CompanyCode; // Try to find client by company code first (more reliable) const policyholderUnknown = secondInquirySubmission.thirdPartyPolicyholder.unknown === true; const client = clientName ? companyCode ? await this.clientService.findOrCreateClientByCompanyCode( companyCode, clientName, ) : await this.clientService.findOne({ clientName: clientName }) : null; if (!client && !policyholderUnknown) { const error = new NotFoundException( `Client not found for company: ${clientName}${companyCode ? ` (code: ${companyCode})` : ""}`, ); await this.persistLegacyInquiryAudit( requestId, "secondPartyDetails", secondInquirySubmission, { ...(secondPolicyInquiry ?? {}), failed: true, error: this.normalizeInquiryError(error), }, ); throw error; } // 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, }; if (client) { 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, }; if (!secondInquirySubmission.participants.legacy) { secondPartyDetails.participants = secondInquirySubmission.participants.participants; secondPartyDetails.participantRoles = secondInquirySubmission.participants.roles; secondPartyDetails.participantInquiries = await this.collectLegacyParticipantInquiries( secondInquirySubmission, ); secondPartyDetails.policyInquiry = secondPolicyInquiry; secondPartyDetails.registrationState = secondInquirySubmission.vehicle?.registrationState; secondPartyDetails.previousPlateId = secondInquirySubmission.vehicle ?.previousPlate ? this.plateToPlateIdString( secondInquirySubmission.vehicle.previousPlate, ) : undefined; secondPartyDetails.vehicleVin = secondInquirySubmission.vehicle?.vin; } // Update the secondPartyDetails in the payload updatePayload.$set.secondPartyDetails = secondPartyDetails; stepsToAdd.push(StepsEnum.S_addPlate); } catch (plateError) { await this.persistLegacyInquiryAudit( requestId, "secondPartyDetails", secondInquirySubmission, { ...(secondPolicyInquiry ?? {}), failed: true, attempts: (plateError as any)?.attempts ?? [], error: this.normalizeInquiryError(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 firstInquirySubmission = this.normalizeInquiryInput( BlameRequestType.CAR_BODY, formData.firstPartyPlate, PartyRole.FIRST, ); const firstPartyPlate = firstInquirySubmission.dto; let thirdPartyPolicyInquiry: any; let carBodyInquiry: any; try { thirdPartyPolicyInquiry = firstInquirySubmission.participants.legacy ? await (async () => { const response = await this.sandHubService.getSandHubResponse({ plate: firstPartyPlate.plate, nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, }); return { raw: response, mapped: response["_doc"] || response, plateKind: "CURRENT", attempts: [ { plateKind: "CURRENT", succeeded: true, usable: true }, ], }; })() : await this.getThirdPartyPlateInquiry(firstInquirySubmission); const sandHubReport = thirdPartyPolicyInquiry.mapped; await this.persistLegacyInquiryAudit( requestId, "firstPartyDetails", firstInquirySubmission, { thirdParty: thirdPartyPolicyInquiry }, ); 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.findOrCreateClientByCompanyCode( companyCode, clientName, ) : await this.clientService.findOne({ clientName: clientName }); if (!client) { const error = new NotFoundException( `Client not found for company: ${clientName}`, ); await this.persistLegacyInquiryAudit( requestId, "firstPartyDetails", firstInquirySubmission, { thirdParty: thirdPartyPolicyInquiry, failed: true, error: this.normalizeInquiryError(error), }, ); throw error; } // 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 carBodyInquiry = firstInquirySubmission.participants.legacy ? await this.sandHubService.getCarBodyInquiry({ nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, plate: firstPartyPlate.plate, } as any) : await this.getCarBodyPlateInquiry(firstInquirySubmission); const carBodyInfo = carBodyInquiry.mapped as any; firstPartyDetails.firstPartyCarBodyInsuranceDetail = { policyNumber: carBodyInfo.policyNumber, startDate: carBodyInfo.startDate, endDate: carBodyInfo.endDate, insurerCompany: carBodyInfo.insurerCompany, coverages: carBodyInfo.coverages, raw: carBodyInquiry.raw, source: carBodyInquiry.source, }; if (!firstInquirySubmission.participants.legacy) { firstPartyDetails.participants = firstInquirySubmission.participants.participants; firstPartyDetails.participantRoles = firstInquirySubmission.participants.roles; firstPartyDetails.participantInquiries = await this.collectLegacyParticipantInquiries( firstInquirySubmission, ); firstPartyDetails.policyInquiry = { thirdParty: thirdPartyPolicyInquiry, carBody: { source: carBodyInquiry.source, plateKind: carBodyInquiry.plateKind, attempts: carBodyInquiry.attempts, raw: carBodyInquiry.raw, mapped: carBodyInquiry.mapped, }, }; firstPartyDetails.registrationState = firstInquirySubmission.vehicle?.registrationState; firstPartyDetails.previousPlateId = firstInquirySubmission.vehicle ?.previousPlate ? this.plateToPlateIdString( firstInquirySubmission.vehicle.previousPlate, ) : undefined; firstPartyDetails.vehicleVin = firstInquirySubmission.vehicle?.vin; } firstPartyDetails.firstPartyClient.clientId = await this.resolveCarBodyPolicyClientId(carBodyInfo); // Update the firstPartyDetails in the payload with all plate data updatePayload.$set.firstPartyDetails = firstPartyDetails; stepsToAdd.push(StepsEnum.F_addPlate); } catch (plateError) { await this.persistLegacyInquiryAudit( requestId, "firstPartyDetails", firstInquirySubmission, { ...(thirdPartyPolicyInquiry ? { thirdParty: thirdPartyPolicyInquiry } : {}), ...(carBodyInquiry ? { carBody: { source: carBodyInquiry.source, plateKind: carBodyInquiry.plateKind, attempts: carBodyInquiry.attempts, raw: carBodyInquiry.raw, mapped: carBodyInquiry.mapped, }, } : {}), failed: true, attempts: (plateError as any)?.attempts ?? [], error: this.normalizeInquiryError(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 { const canonical = normalizeIranMobile(phoneNumber) ?? phoneNumber.trim(); let user = await this.userDbService.findOne( buildUserLookupByPhone(canonical), ); if (user) { return new Types.ObjectId((user as any)._id); } const newUser = await this.userDbService.createUser({ mobile: canonical, username: canonical, 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 = normalizeResendRequestedItemsList( userResendRequest.requestedItems as string[], ); const row = userResendRequest as any; const merged = { uploadedDocuments: cloneResendUploadedDocuments(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: items, 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 = normalizeResendRequestedItemsList( userResendRequest.requestedItems as string[], ); if (requestedItems.length === 0) { throw new BadRequestException( "No valid requested items are configured for your party resend. Please contact support.", ); } const row = userResendRequest as any; const mergedRow = { uploadedDocuments: cloneResendUploadedDocuments(row.uploadedDocuments), resendVoiceId: row.resendVoiceId, resendVideoId: row.resendVideoId, userTextDescription: row.userTextDescription, }; const safeFiles = files || {}; const hasAnyFile = Object.keys(safeFiles).some((fieldName) => { const fileArray = safeFiles[fieldName as keyof typeof safeFiles]; 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 file for a requested item and/or the text description when the expert asked for a description.", ); } 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 of Object.keys(safeFiles)) { const fileArray = safeFiles[fieldName as keyof typeof safeFiles]; if (!fileArray || fileArray.length === 0) continue; const file = fileArray[0]; const canonKey = normalizeResendRequestedItemKey(fieldName); if (!canonKey || !requestedItems.includes(canonKey)) { throw new BadRequestException( `${fieldName} was not requested or is not a valid resend field. Requested items: ${requestedItems.join(", ")}`, ); } if (canonKey === ResendItemType.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(canonKey); // } else if (canonKey === ResendItemType.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(canonKey); } else { const docType = canonKey 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.${canonKey}` ] = (doc as any)._id; mergedRow.uploadedDocuments[canonKey] = (doc as any)._id; uploadedItems.push(canonKey); } } const allItemsCompleted = requestedItems.length > 0 && requestedItems.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", }, }); if ( updatedRequest.type === BlameRequestType.THIRD_PARTY || updatedRequest.type === BlameRequestType.CAR_BODY ) { let targetPhone: string | undefined; if (updatedRequest.type === BlameRequestType.THIRD_PARTY) { const guiltyPartyId = updatedRequest.expert?.decision?.guiltyPartyId ? String(updatedRequest.expert.decision.guiltyPartyId) : null; if (guiltyPartyId) { const damagedParty = updatedRequest.parties?.find( (p) => p.person?.userId && String(p.person.userId) !== guiltyPartyId, ); targetPhone = damagedParty?.person?.phoneNumber?.trim(); } } else { // CAR_BODY has a single owner/damaged party: notify that user too. const ownerParty = updatedRequest.parties?.[partyIndex] ?? updatedRequest.parties?.[0]; targetPhone = ownerParty?.person?.phoneNumber?.trim(); } if (targetPhone) { const publicId = String(updatedRequest.publicId); const link = this.smsOrchestrationService.buildBlamePartyLink( requestId, targetPhone === request?.parties[0]?.person.phoneNumber ? "FIRST" : "SECOND", "v1", ); await this.smsOrchestrationService.sendThirdPartyDamagedPartyClaimLinkNotice( { receptor: targetPhone, publicId, link, }, ); } } } 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, }; } // --------------------------------------------------------------------------- // V3 IN-PERSON SEQUENTIAL FLOW (field expert, one party at a time) // --------------------------------------------------------------------------- private assertBlameV3ExpertInPerson(req: any): void { if (req.creationMethod !== CreationMethod.IN_PERSON) { throw new BadRequestException("V3 flow is only for IN_PERSON files."); } if (!req.expertInitiated) { throw new BadRequestException( "V3 flow is only for expert-initiated files.", ); } } /** Resolve auto-created claim for V3 blame (created on guilty-party run-inquiries). */ async getV3LinkedClaimForExpert( expert: any, blameRequestId: string, ): Promise<{ blameRequestId: string; claimRequestId: string; publicId: string; }> { const req = await this.blameRequestDbService.findById(blameRequestId); if (!req) throw new NotFoundException("Blame request not found"); this.assertBlameV3ExpertInPerson(req); await this.verifyExpertAccessForBlameV2(req, expert); const claim = await this.claimCaseDbService.findOne({ blameRequestId: (req as any)._id, }); if (!claim) { throw new NotFoundException( "No claim linked to this blame file yet. Complete guilty-party run-inquiries first.", ); } return { blameRequestId: String(req._id), claimRequestId: String(claim._id), publicId: claim.publicId ?? req.publicId, }; } private hasPartyInquiriesComplete(req: any, role: PartyRole): boolean { const step = role === PartyRole.FIRST ? WorkflowStep.FIRST_INITIAL_FORM : WorkflowStep.SECOND_INITIAL_FORM; return (req.workflow?.completedSteps ?? []).includes(step); } private partyHasSigned(req: any, role: PartyRole): boolean { const idx = this.getPartyIndex(req, role); return idx !== -1 && req.parties?.[idx]?.confirmation != null; } private resolveV3InquiryPartyRole(req: any): PartyRole | null { if (!this.hasPartyInquiriesComplete(req, PartyRole.FIRST)) { return PartyRole.FIRST; } if ( req.type === BlameRequestType.THIRD_PARTY && !this.hasPartyInquiriesComplete(req, PartyRole.SECOND) ) { return PartyRole.SECOND; } return null; } private pushWorkflowSteps(req: any, steps: WorkflowStep[]): void { if (!req.workflow) req.workflow = {} as any; const completed = Array.isArray(req.workflow.completedSteps) ? [...req.workflow.completedSteps] : []; for (const step of steps) { if (!completed.includes(step)) completed.push(step); } req.workflow.completedSteps = completed; } private async runPartyInquiriesV3Internal( req: any, partyData: RunInquiriesV3Dto, partyRole: PartyRole, party: any, ): Promise<{ clientId?: string; subjects: InquirySubjects }> { const inquirySubmission = this.normalizeInquiryInput( req.type, partyData, partyRole, ); partyData = inquirySubmission.dto as RunInquiriesV3Dto; const subjects = resolveInquirySubjects(inquirySubmission); this.applyInquiryParticipantsToParty(party, inquirySubmission); const policyholderUnknown = inquirySubmission.thirdPartyPolicyholder.unknown === true; const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND"; let clientId: string | undefined; const inquiryOptions = (cid?: string) => this.policyInquiryOptions(req.type, partyRole, cid); let inquiryRaw: any; let inquiryMapped: any; let inquirySource = "TEJARAT_BLOCK_INQUIRY"; let inquiryPlateKind = "CURRENT"; let inquiryAttempts: any[] = []; try { const inquiry = await this.getThirdPartyPlateInquiry( inquirySubmission, inquiryOptions(party?.person?.clientId?.toString()), ); inquiryRaw = inquiry.raw; inquiryMapped = inquiry.mapped; inquiryPlateKind = inquiry.plateKind; inquiryAttempts = inquiry.attempts; if (inquiry.offline?.source) { inquirySource = inquiry.offline.source; } this.recordPartyCaseInquiryStatus( req, "thirdParty", partyRole, !inquiry.skipped, { source: inquirySource, plateKind: inquiry.plateKind, attempts: inquiry.attempts, raw: inquiryRaw, mapped: inquiryMapped, ...(inquiry.skipped ? { skipped: true, reason: "Third-party policyholder is unknown", } : {}), }, ); if (inquiry.offline?.fanavaranDriverId != null) { if (!party.person) party.person = {} as any; party.person.fanavaranDriverId = inquiry.offline.fanavaranDriverId; } } catch (err: any) { this.logger.error( `[V3] plate inquiry failed for ${roleLabel} party (request=${req._id}): ${err?.message || err}`, ); this.recordPartyCaseInquiryStatus( req, "thirdParty", partyRole, false, {}, err, ); await this.persistBlameInquiryAudit(req); throw new BadRequestException( `${roleLabel} party plate inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, ); } if (inquiryMapped?.Error) { const error = new BadRequestException( inquiryMapped.Error.Message || `${roleLabel} party plate inquiry returned an error`, ); this.recordPartyCaseInquiryStatus( req, "thirdParty", partyRole, false, { source: inquirySource, plateKind: inquiryPlateKind, attempts: inquiryAttempts, raw: inquiryRaw, mapped: inquiryMapped, }, error, ); await this.persistBlameInquiryAudit(req); throw error; } const clientName = inquiryMapped?.CompanyName; const companyCode = inquiryMapped?.CompanyCode; if (!clientName && !policyholderUnknown) { const error = new BadRequestException( `CompanyName missing from ${roleLabel} party inquiry response`, ); this.recordPartyCaseInquiryStatus( req, "thirdParty", partyRole, false, { source: inquirySource, plateKind: inquiryPlateKind, attempts: inquiryAttempts, raw: inquiryRaw, mapped: inquiryMapped, }, error, ); await this.persistBlameInquiryAudit(req); throw error; } const client = clientName ? await this.clientService.findOrCreateClientByCompanyCode( companyCode, clientName, ) : null; if (clientName && !client) { const error = new BadRequestException( `CompanyCode missing or invalid in ${roleLabel} party inquiry response`, ); this.recordPartyCaseInquiryStatus( req, "thirdParty", partyRole, false, { source: inquirySource, plateKind: inquiryPlateKind, attempts: inquiryAttempts, raw: inquiryRaw, mapped: inquiryMapped, }, error, ); await this.persistBlameInquiryAudit(req); throw error; } const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; clientId = resolvedClientId ? String(resolvedClientId) : undefined; if (!party.person) party.person = {} as any; if (resolvedClientId) party.person.clientId = resolvedClientId; party.person.nationalCodeOfInsurer = partyData.nationalCodeOfInsurer; party.person.nationalCodeOfDriver = partyData.nationalCodeOfDriver; party.person.driverIsInsurer = partyData.driverIsInsurer; party.person.insurerBirthday = partyData.insurerBirthday; party.person.driverBirthday = partyData.driverBirthday ?? (partyData.driverIsInsurer ? String(partyData.insurerBirthday) : null); if (partyData.insurerLicense) party.person.insurerLicense = partyData.insurerLicense; if (partyData.driverLicense) party.person.driverLicense = partyData.driverLicense; if ((partyData as any).licenseType) (party.person as any).licenseType = (partyData as any).licenseType; if (!party.vehicle) party.vehicle = {} as any; party.vehicle.plateId = this.plateToPlateIdString(partyData.plate); party.vehicle.name = inquiryMapped?.MapTypNam; party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`; party.vehicle.inquiry = { source: inquirySource, plateKind: inquiryPlateKind, attempts: inquiryAttempts, raw: inquiryRaw, mapped: inquiryMapped, }; if (!party.insurance) party.insurance = {} as any; party.insurance.policyNumber = inquiryMapped?.PrntCmpDocNo || inquiryMapped?.PrntPlcyCmpDocNo || inquiryMapped?.printNumber || inquiryMapped?.insuranceNumber; party.insurance.company = clientName; party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; party.insurance.startDate = inquiryMapped?.StartDate || inquiryMapped?.HBgnDte || inquiryMapped?.persianStartDate || inquiryMapped?.SatrtDate || inquiryMapped?.IssueDate; party.insurance.endDate = inquiryMapped?.EndDate || inquiryMapped?.HEndDte || inquiryMapped?.persianEndDate; if ( req.type === BlameRequestType.CAR_BODY && partyRole === PartyRole.FIRST ) { try { const carBodyInfo = await this.getCarBodyPlateInquiry(inquirySubmission); this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, true, { source: carBodyInfo.source, plateKind: carBodyInfo.plateKind, attempts: carBodyInfo.attempts, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }); party.vehicle.inquiry = { ...party.vehicle.inquiry, carBody: { source: carBodyInfo.source, plateKind: carBodyInfo.plateKind, attempts: carBodyInfo.attempts, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }, }; const m = carBodyInfo.mapped; (party.insurance as any).carBodyInsurance = { policyNumber: m.policyNumber ?? null, policyId: m.policyId ?? null, contractId: m.contractId ?? null, companyId: m.companyId ?? null, companyName: m.CompanyName ?? null, insurerName: m.insurerName ?? null, insurerNationalCode: m.insurerNationalCode ?? null, ownerNationalCode: m.ownerNationalCode ?? null, ownerName: m.ownerName ?? null, customerName: m.customerName ?? null, customerLastName: m.customerLastName ?? null, customerFatherName: m.customerFatherName ?? null, customerMobile: m.customerMobile ?? null, customerAddress: m.customerAddress ?? null, customerPostalCode: m.customerPostalCode ?? null, chassisNumber: m.ChassisNumberField ?? null, vin: m.VinNumberField ?? null, motorNumber: m.EngineNumberField ?? null, vehicleGroup: m.vehicleGroupTitle ?? null, vehicleSystem: m.vehicleSystemTitle ?? null, vehicleKind: m.vehicleKind ?? null, builtYear: m.builtYear ?? null, cylinderCount: m.cylinderCount ?? null, passengerCount: m.passengerCount ?? null, usage: m.usage ?? null, startDate: m.StartDate ?? null, endDate: m.EndDate ?? null, issueDate: m.IssueDate ?? null, vehicleValue: m.vehicleValue ?? null, totalPremium: m.totalPremium ?? null, noLossYearsCount: m.noLossYearsCount ?? null, lossDocuments: m.lossDocuments ?? [], hasEndorsement: m.hasEndorsement ?? null, }; const carBodyClientId = await this.resolveCarBodyPolicyClientId(m); party.person.clientId = carBodyClientId; clientId = String(carBodyClientId); } catch (err: any) { this.logger.error( `[V3] car body inquiry failed for request=${req._id}: ${err?.message || err}`, ); this.recordPartyCaseInquiryStatus( req, "carBody", partyRole, false, {}, err, ); await this.persistBlameInquiryAudit(req); this.throwCarBodyInquiryFailure(err); } } await this.runParticipantPersonalInquiries( req, partyRole, inquirySubmission, clientId ? { clientId } : undefined, ); await this.runVehicleOwnershipInquiry( req, partyRole, inquirySubmission, clientId ? { clientId } : undefined, ); const licenseNationalCode = partyData.driverIsInsurer ? partyData.nationalCodeOfInsurer : partyData.nationalCodeOfDriver; const licenseNumber = partyData.driverIsInsurer ? partyData.insurerLicense : partyData.driverLicense; if ( !inquirySubmission.participants.legacy && inquirySubmission.driver.hasDrivingLicense === false ) { this.recordPartyCaseInquiryStatus( req, "drivingLicence", partyRole, true, { participantId: inquirySubmission.driver.participantId, skipped: true, reason: "Driver declared no driving licence", }, ); } else if (!licenseNumber) { this.recordPartyCaseInquiryStatus( req, "drivingLicence", partyRole, false, { skipped: true, reason: "No license number provided" }, ); } else { try { const licenseInquiry = await this.sandHubService.getDrivingLicenseInfo( licenseNationalCode, licenseNumber, clientId ? { clientId } : undefined, ); this.recordPartyCaseInquiryStatus( req, "drivingLicence", partyRole, true, licenseInquiry, ); } catch (err: any) { this.logger.error( `[V3] driving license inquiry failed for ${roleLabel} party request=${req._id}: ${err?.message || err}`, ); this.recordPartyCaseInquiryStatus( req, "drivingLicence", partyRole, false, {}, err, ); await this.persistBlameInquiryAudit(req); throw new BadRequestException( `${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, ); } } return { clientId, subjects }; } private async validateShebaV3( sheba: string | undefined, vehicleOwnerNationalCode: string, clientId?: string, ): Promise { if (!sheba) return; await this.sandHubService.getShebaValidation( vehicleOwnerNationalCode, sheba, clientId ? { clientId } : undefined, ); } private markPartyInquiriesCompleteOnBlame( req: any, partyRole: PartyRole, actor: any, ): void { // V4/V5 FileMaker files skip the location step — it is auto-completed here. const skipLocation = !!(req as any).isMadeByFileMaker; if (partyRole === PartyRole.FIRST) { const completedSteps = skipLocation ? [ WorkflowStep.CREATED, WorkflowStep.FIRST_INITIAL_FORM, WorkflowStep.FIRST_LOCATION, ] : [WorkflowStep.CREATED, WorkflowStep.FIRST_INITIAL_FORM]; this.pushWorkflowSteps(req, completedSteps); req.workflow.currentStep = skipLocation ? WorkflowStep.FIRST_VOICE : WorkflowStep.FIRST_LOCATION; req.workflow.nextStep = skipLocation ? WorkflowStep.FIRST_DESCRIPTION : WorkflowStep.FIRST_VOICE; const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const guiltyUserId = req.parties?.[firstIdx]?.person?.userId; if (!req.expert) req.expert = {} as any; if (!req.expert.decision) req.expert.decision = {} as any; if (guiltyUserId) { (req.expert as any).decision.guiltyPartyId = new Types.ObjectId( String(guiltyUserId), ); } if (typeof (req as any).markModified === "function") { (req as any).markModified("expert"); } } else { const completedSteps = skipLocation ? [WorkflowStep.SECOND_INITIAL_FORM, WorkflowStep.SECOND_LOCATION] : [WorkflowStep.SECOND_INITIAL_FORM]; this.pushWorkflowSteps(req, completedSteps); req.workflow.currentStep = skipLocation ? WorkflowStep.SECOND_VOICE : WorkflowStep.SECOND_LOCATION; req.workflow.nextStep = skipLocation ? WorkflowStep.SECOND_DESCRIPTION : WorkflowStep.SECOND_VOICE; } } private async createV3ClaimFromBlame( req: any, actor: any, options: { sheba?: string; nationalCode?: string; interimThirdParty?: boolean; } = {}, ) { const requestId = String(req._id); const existing = await this.claimCaseDbService.findOne({ blameRequestId: (req as any)._id, }); if (existing) { throw new ConflictException( "A claim for this blame file already exists.", ); } const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const firstParty = req.parties?.[firstIdx]; const guiltyUserId = firstParty?.person?.userId; const guiltyClientId = firstParty?.person?.clientId; let ownerUserId: string; let ownerRole: string | undefined; let payingClientId: string | undefined; if (req.type === BlameRequestType.CAR_BODY || !options.interimThirdParty) { const ownerFields = resolveClaimOwnerFieldsFromBlame(req); if (!ownerFields) { throw new BadRequestException( "Could not resolve claim owner from blame parties.", ); } ownerUserId = ownerFields.userId; ownerRole = ownerFields.userRole; payingClientId = ownerFields.clientId ?? guiltyClientId?.toString(); } else { if (!guiltyUserId) { throw new BadRequestException( "First party must be verified before creating claim.", ); } ownerUserId = String(guiltyUserId); ownerRole = PartyRole.FIRST; payingClientId = guiltyClientId ? String(guiltyClientId) : undefined; } const claimNo = `CL${Math.floor(10000 + Math.random() * 90000)}`; const newClaimData: any = { requestNo: claimNo, publicId: req.publicId, blameRequestId: (req as any)._id, blameRequestNo: req.requestNo, initiatedByFieldExpertId: new Types.ObjectId(actor.sub), status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, claimStatus: ClaimStatus.PENDING, inquiries: (req as any).inquiries ?? {}, workflow: { currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, nextStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, completedSteps: [ClaimWorkflowStep.CLAIM_CREATED], locked: false, }, ...(options.interimThirdParty ? {} : { damagedPartyUserId: new Types.ObjectId(ownerUserId) }), owner: { userId: new Types.ObjectId(ownerUserId), userRole: ownerRole as any, ...(payingClientId ? { clientId: new Types.ObjectId(payingClientId), userClientKey: payingClientId, } : {}), }, ...(options.sheba ? { money: { sheba: options.sheba, nationalCodeOfInsurer: options.nationalCode ?? "", }, } : {}), // V5 only: propagate the approval gate from the blame record to the claim. // isMadeByFileMaker is true for both V4 and V5; only V5 blame records also // carry requiresFileMakerApproval=true (set at create time by the V5 controller). ...((req as any).requiresFileMakerApproval ? { requiresFileMakerApproval: true, fileMakerApprovalActorId: new Types.ObjectId(actor.sub), } : {}), history: [ { type: "CLAIM_CREATED", actor: { actorId: new Types.ObjectId(actor.sub), actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(actor), }, timestamp: new Date(), metadata: { blameRequestId: requestId, blamePublicId: req.publicId, createdByV3InPerson: true, interimThirdParty: !!options.interimThirdParty, }, }, ], }; return this.claimCaseDbService.create(newClaimData); } private ensureV3GuiltyPartyDecision(req: any): void { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const guiltyUserId = req.parties?.[firstIdx]?.person?.userId; if (!guiltyUserId) return; if (!req.expert) req.expert = {} as any; if (!req.expert.decision) req.expert.decision = {} as any; (req.expert as any).decision.guiltyPartyId = new Types.ObjectId( String(guiltyUserId), ); if (typeof (req as any).markModified === "function") { (req as any).markModified("expert"); } } /** THIRD_PARTY: damaged party is always SECOND after OTP + inquiries. */ private resolveV3DamagedPartyForClaimSync(req: any): { userId: string; userRole?: string; clientId?: string; } | null { if (req.type === BlameRequestType.CAR_BODY) { return resolveClaimOwnerFieldsFromBlame(req); } const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); if (secondIdx === -1) return null; const damagedPerson = req.parties[secondIdx]?.person; if (damagedPerson?.userId == null) return null; const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const guiltyPerson = firstIdx !== -1 ? req.parties[firstIdx]?.person : null; const damagedUserId = String(damagedPerson.userId); const guiltyUserId = guiltyPerson?.userId ? String(guiltyPerson.userId) : null; // Throws — caller should not catch; distinct accounts are required. if (guiltyUserId && damagedUserId === guiltyUserId) { throw new BadRequestException( "Guilty and damaged parties are bound to the same user account. " + "Re-verify the second party OTP using a different phone number.", ); } const payingClientId = guiltyPerson?.clientId != null ? String(guiltyPerson.clientId) : undefined; return { userId: damagedUserId, userRole: PartyRole.SECOND, ...(payingClientId ? { clientId: payingClientId } : {}), }; } private async syncV3ClaimDamagedPartyFromBlame( req: any, partyData: RunInquiriesV3Dto, vehicleOwnerNationalCode: string, ): Promise { const claim = await this.claimCaseDbService.findOne({ blameRequestId: (req as any)._id, }); if (!claim) { throw new NotFoundException("Claim not found for this blame file."); } this.ensureV3GuiltyPartyDecision(req); const ownerFields = this.resolveV3DamagedPartyForClaimSync(req); if (!ownerFields) { throw new BadRequestException( "Could not resolve damaged party after second-party inquiries. " + "Ensure the second party OTP is verified and inquiry data was saved.", ); } await this.claimCaseDbService.findByIdAndUpdate(String(claim._id), { $set: { damagedPartyUserId: new Types.ObjectId(ownerFields.userId), "owner.userId": new Types.ObjectId(ownerFields.userId), "owner.userRole": ownerFields.userRole, ...(ownerFields.clientId ? { "owner.clientId": new Types.ObjectId(ownerFields.clientId), "owner.userClientKey": ownerFields.clientId, } : {}), ...(partyData.sheba ? { "money.sheba": partyData.sheba, "money.nationalCodeOfInsurer": vehicleOwnerNationalCode, } : {}), }, }); } /** * Unified inquiries: 1st call = guilty party (+ auto claim), 2nd call = damaged (THIRD_PARTY). * Uses standard workflow steps (FIRST_INITIAL_FORM / SECOND_INITIAL_FORM). */ async runInquiriesV3( requestId: string, actor: any, dto: RunInquiriesV3Dto, ): Promise<{ blameRequestId: string; partyRole: PartyRole; claimRequestId?: string; claimPublicId?: string; message: string; }> { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Blame request not found"); this.assertBlameV3ExpertInPerson(req); await this.verifyExpertAccessForBlameV2(req, actor); const partyRole = this.resolveV3InquiryPartyRole(req); if (!partyRole) { throw new ConflictException("All party inquiries are already complete."); } if (partyRole === PartyRole.FIRST) { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; if (!firstParty?.person?.userId) { throw new BadRequestException( "Guilty party OTP must be verified before running inquiries.", ); } if (req.type === BlameRequestType.CAR_BODY) { const form = firstParty.carBodyFirstForm as any; if (form?.car == null && form?.object == null) { throw new BadRequestException( "Submit car-body-form before running inquiries (CAR_BODY).", ); } } const inquiryResult = await this.runPartyInquiriesV3Internal( req, dto, PartyRole.FIRST, firstParty, ); if (req.type === BlameRequestType.CAR_BODY) { await this.validateShebaV3( dto.sheba, inquiryResult.subjects.shebaNationalCode, firstParty.person?.clientId?.toString(), ); } this.markPartyInquiriesCompleteOnBlame(req, PartyRole.FIRST, actor); await (req as any).save(); const newClaim = await this.createV3ClaimFromBlame(req, actor, { sheba: req.type === BlameRequestType.CAR_BODY ? dto.sheba : undefined, nationalCode: req.type === BlameRequestType.CAR_BODY ? inquiryResult.subjects.shebaNationalCode : undefined, interimThirdParty: req.type === BlameRequestType.THIRD_PARTY, }); return { blameRequestId: requestId, partyRole: PartyRole.FIRST, claimRequestId: String(newClaim._id), claimPublicId: req.publicId, message: "Guilty-party inquiries complete and claim created. Proceed with location, description, voice, and signature.", }; } // Damaged party (THIRD_PARTY only) if (!this.partyHasSigned(req, PartyRole.FIRST)) { throw new BadRequestException( "Guilty party must sign before running damaged-party inquiries.", ); } const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); if (secondIdx === -1 || !req.parties[secondIdx]?.person?.userId) { throw new BadRequestException( "Damaged party OTP must be verified before running inquiries.", ); } const secondParty = req.parties[secondIdx]; const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const firstUserId = firstIdx !== -1 ? req.parties[firstIdx]?.person?.userId : null; const secondUserId = secondParty.person?.userId; if ( firstUserId && secondUserId && String(firstUserId) === String(secondUserId) ) { throw new BadRequestException( "Guilty and damaged parties are bound to the same user account. " + "Re-verify the second party OTP using a different phone number.", ); } const inquiryResult = await this.runPartyInquiriesV3Internal( req, dto, PartyRole.SECOND, secondParty, ); await this.validateShebaV3( dto.sheba, inquiryResult.subjects.shebaNationalCode, secondParty.person?.clientId?.toString(), ); this.ensureV3GuiltyPartyDecision(req); if (typeof (req as any).markModified === "function") { (req as any).markModified("parties"); } await (req as any).save(); await this.syncV3ClaimDamagedPartyFromBlame( req, dto, inquiryResult.subjects.shebaNationalCode, ); this.markPartyInquiriesCompleteOnBlame(req, PartyRole.SECOND, actor); await (req as any).save(); return { blameRequestId: requestId, partyRole: PartyRole.SECOND, message: "Damaged-party inquiries complete. Proceed with location, description, voice, and signature.", }; } /** * VIN variant of `runInquiriesV3`. * Identical flow, but calls `getPolicyByChassisInquiry` instead of * `getTejaratBlockInquiry` for the primary policy lookup. */ async runInquiriesVinV3( requestId: string, actor: any, dto: RunInquiriesVinV3Dto, ): Promise<{ blameRequestId: string; partyRole: PartyRole; claimRequestId?: string; claimPublicId?: string; message: string; }> { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Blame request not found"); this.assertBlameV3ExpertInPerson(req); await this.verifyExpertAccessForBlameV2(req, actor); const partyRole = this.resolveV3InquiryPartyRole(req); if (!partyRole) { throw new ConflictException("All party inquiries are already complete."); } if (partyRole === PartyRole.FIRST) { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; if (!firstParty?.person?.userId) { throw new BadRequestException( "Guilty party OTP must be verified before running inquiries.", ); } if (req.type === BlameRequestType.CAR_BODY) { const form = firstParty.carBodyFirstForm as any; if (form?.car == null && form?.object == null) { throw new BadRequestException( "Submit car-body-form before running inquiries (CAR_BODY).", ); } } const inquiryResult = await this.runPartyInquiriesVinV3Internal( req, dto, PartyRole.FIRST, firstParty, ); if (req.type === BlameRequestType.CAR_BODY) { await this.validateShebaV3( dto.sheba, inquiryResult.subjects.shebaNationalCode, firstParty.person?.clientId?.toString(), ); } this.markPartyInquiriesCompleteOnBlame(req, PartyRole.FIRST, actor); await (req as any).save(); const newClaim = await this.createV3ClaimFromBlame(req, actor, { sheba: req.type === BlameRequestType.CAR_BODY ? dto.sheba : undefined, nationalCode: req.type === BlameRequestType.CAR_BODY ? inquiryResult.subjects.shebaNationalCode : undefined, interimThirdParty: req.type === BlameRequestType.THIRD_PARTY, }); return { blameRequestId: requestId, partyRole: PartyRole.FIRST, claimRequestId: String(newClaim._id), claimPublicId: req.publicId, message: "Guilty-party inquiries complete and claim created. Proceed with location, description, voice, and signature.", }; } // Damaged party (THIRD_PARTY only) if (!this.partyHasSigned(req, PartyRole.FIRST)) { throw new BadRequestException( "Guilty party must sign before running damaged-party inquiries.", ); } const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); if (secondIdx === -1 || !req.parties[secondIdx]?.person?.userId) { throw new BadRequestException( "Damaged party OTP must be verified before running inquiries.", ); } const secondParty = req.parties[secondIdx]; const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const firstUserId = firstIdx !== -1 ? req.parties[firstIdx]?.person?.userId : null; const secondUserId = secondParty.person?.userId; if ( firstUserId && secondUserId && String(firstUserId) === String(secondUserId) ) { throw new BadRequestException( "Guilty and damaged parties are bound to the same user account. " + "Re-verify the second party OTP using a different phone number.", ); } const inquiryResult = await this.runPartyInquiriesVinV3Internal( req, dto, PartyRole.SECOND, secondParty, ); await this.validateShebaV3( dto.sheba, inquiryResult.subjects.shebaNationalCode, secondParty.person?.clientId?.toString(), ); this.ensureV3GuiltyPartyDecision(req); if (typeof (req as any).markModified === "function") { (req as any).markModified("parties"); } await (req as any).save(); await this.syncV3ClaimDamagedPartyFromBlame( req, dto as any, inquiryResult.subjects.shebaNationalCode, ); this.markPartyInquiriesCompleteOnBlame(req, PartyRole.SECOND, actor); await (req as any).save(); return { blameRequestId: requestId, partyRole: PartyRole.SECOND, message: "Damaged-party inquiries complete. Proceed with location, description, voice, and signature.", }; } /** * VIN variant of `runPartyInquiriesV3Internal`. * Calls `getPolicyByChassisInquiry` (ESG chassis lookup) instead of * `getTejaratBlockInquiry` (plate-based). All other inquiries (personal, * driving licence, car-body for CAR_BODY) are unchanged. */ private async runPartyInquiriesVinV3Internal( req: any, partyData: RunInquiriesVinV3Dto, partyRole: PartyRole, party: any, ): Promise<{ clientId?: string; subjects: InquirySubjects }> { const inquirySubmission = this.normalizeInquiryInput( req.type, partyData, partyRole, ); partyData = inquirySubmission.dto as RunInquiriesVinV3Dto; const subjects = resolveInquirySubjects(inquirySubmission); this.applyInquiryParticipantsToParty(party, inquirySubmission); const policyholderUnknown = inquirySubmission.thirdPartyPolicyholder.unknown === true; const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND"; let clientId: string | undefined; const inquiryOptions = (cid?: string) => this.policyInquiryOptions(req.type, partyRole, cid); let inquiryRaw: any; let inquiryMapped: any; try { const inquiry = await this.getThirdPartyVinInquiry( inquirySubmission, inquiryOptions(party?.person?.clientId?.toString()), ); inquiryRaw = inquiry.raw; inquiryMapped = inquiry.mapped; this.recordPartyCaseInquiryStatus( req, "thirdParty", partyRole, !inquiry.skipped, { source: "ESG_VIN_INQUIRY", raw: inquiryRaw, mapped: inquiryMapped, ...(inquiry.skipped ? { skipped: true, reason: "Third-party policyholder is unknown", } : {}), }, ); } catch (err: any) { this.logger.error( `[V3-VIN] vin inquiry failed for ${roleLabel} party (request=${req._id}): ${err?.message || err}`, ); this.recordPartyCaseInquiryStatus( req, "thirdParty", partyRole, false, {}, err, ); await this.persistBlameInquiryAudit(req); throw new BadRequestException( `${roleLabel} party VIN inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, ); } if (inquiryMapped?.Error) { const error = new BadRequestException( inquiryMapped.Error.Message || `${roleLabel} party VIN inquiry returned an error`, ); this.recordPartyCaseInquiryStatus( req, "thirdParty", partyRole, false, { source: "ESG_VIN_INQUIRY", raw: inquiryRaw, mapped: inquiryMapped, }, error, ); await this.persistBlameInquiryAudit(req); throw error; } const clientName = inquiryMapped?.CompanyName; const companyCode = inquiryMapped?.CompanyCode; if (!clientName && !policyholderUnknown) { const error = new BadRequestException( `CompanyName missing from ${roleLabel} party VIN inquiry response`, ); this.recordPartyCaseInquiryStatus( req, "thirdParty", partyRole, false, { source: "ESG_VIN_INQUIRY", raw: inquiryRaw, mapped: inquiryMapped, }, error, ); await this.persistBlameInquiryAudit(req); throw error; } const client = clientName ? await this.clientService.findOrCreateClientByCompanyCode( companyCode, clientName, ) : null; if (clientName && !client) { const error = new BadRequestException( `CompanyCode missing or invalid in ${roleLabel} party VIN inquiry response`, ); this.recordPartyCaseInquiryStatus( req, "thirdParty", partyRole, false, { source: "ESG_VIN_INQUIRY", raw: inquiryRaw, mapped: inquiryMapped, }, error, ); await this.persistBlameInquiryAudit(req); throw error; } const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; clientId = resolvedClientId ? String(resolvedClientId) : undefined; if (!party.person) party.person = {} as any; if (resolvedClientId) party.person.clientId = resolvedClientId; party.person.nationalCodeOfInsurer = partyData.nationalCodeOfInsurer; party.person.nationalCodeOfDriver = partyData.nationalCodeOfDriver; party.person.driverIsInsurer = partyData.driverIsInsurer; party.person.insurerBirthday = partyData.insurerBirthday; party.person.driverBirthday = partyData.driverBirthday ?? (partyData.driverIsInsurer ? String(partyData.insurerBirthday) : null); if (partyData.insurerLicense) party.person.insurerLicense = partyData.insurerLicense; if (partyData.driverLicense) party.person.driverLicense = partyData.driverLicense; if ((partyData as any).licenseType) (party.person as any).licenseType = (partyData as any).licenseType; if (!party.vehicle) party.vehicle = {} as any; // Derive plateId from the plate parts returned by the VIN inquiry. // Plk2 may be a numeric letter code (e.g. 5 → "د") or already a Persian // letter string — resolvePlk2Letter handles both forms. // Falls back to the submitted VIN so plateId is never empty. party.vehicle.plateId = inquirySubmission.vehicle?.currentPlate ? this.plateToPlateIdString(inquirySubmission.vehicle.currentPlate) : this.plateToPlateIdString({ ir: inquiryMapped?.PlkSrl, leftDigits: inquiryMapped?.Plk1, centerAlphabet: this.resolvePlk2Letter( inquiryMapped?.Plk2 ?? inquiryMapped?.plateLetterid, ), centerDigits: inquiryMapped?.Plk3, }) || partyData.vin; party.vehicle.name = inquiryMapped?.MapTypNam; party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`; party.vehicle.inquiry = { source: "ESG_VIN_INQUIRY", raw: inquiryRaw, mapped: inquiryMapped, }; if (!party.insurance) party.insurance = {} as any; party.insurance.policyNumber = inquiryMapped?.PrntCmpDocNo || inquiryMapped?.PrntPlcyCmpDocNo || inquiryMapped?.printNumber || inquiryMapped?.insuranceNumber; party.insurance.company = clientName; party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; party.insurance.startDate = inquiryMapped?.StartDate || inquiryMapped?.HBgnDte || inquiryMapped?.persianStartDate || inquiryMapped?.SatrtDate || inquiryMapped?.IssueDate; party.insurance.endDate = inquiryMapped?.EndDate || inquiryMapped?.HEndDte || inquiryMapped?.persianEndDate; if ( req.type === BlameRequestType.CAR_BODY && partyRole === PartyRole.FIRST ) { try { const carBodyInfo = await this.sandHubService.getCarBodyInquiry({ nationalCodeOfInsurer: subjects.carBodyPolicyNationalCode!, plate: partyData.vin as any, // VIN used as identifier for CAR_BODY }); this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, true, { source: carBodyInfo.source, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }); party.vehicle.inquiry = { ...party.vehicle.inquiry, carBody: { source: carBodyInfo.source, raw: carBodyInfo.raw, mapped: carBodyInfo.mapped, }, }; const m = carBodyInfo.mapped; (party.insurance as any).carBodyInsurance = { policyNumber: m.policyNumber ?? null, policyId: m.policyId ?? null, contractId: m.contractId ?? null, companyId: m.companyId ?? null, companyName: m.CompanyName ?? null, insurerName: m.insurerName ?? null, insurerNationalCode: m.insurerNationalCode ?? null, ownerNationalCode: m.ownerNationalCode ?? null, ownerName: m.ownerName ?? null, customerName: m.customerName ?? null, customerLastName: m.customerLastName ?? null, customerFatherName: m.customerFatherName ?? null, customerMobile: m.customerMobile ?? null, customerAddress: m.customerAddress ?? null, customerPostalCode: m.customerPostalCode ?? null, chassisNumber: m.ChassisNumberField ?? null, vin: m.VinNumberField ?? null, motorNumber: m.EngineNumberField ?? null, vehicleGroup: m.vehicleGroupTitle ?? null, vehicleSystem: m.vehicleSystemTitle ?? null, vehicleKind: m.vehicleKind ?? null, builtYear: m.builtYear ?? null, cylinderCount: m.cylinderCount ?? null, passengerCount: m.passengerCount ?? null, usage: m.usage ?? null, startDate: m.StartDate ?? null, endDate: m.EndDate ?? null, issueDate: m.IssueDate ?? null, vehicleValue: m.vehicleValue ?? null, totalPremium: m.totalPremium ?? null, noLossYearsCount: m.noLossYearsCount ?? null, lossDocuments: m.lossDocuments ?? [], hasEndorsement: m.hasEndorsement ?? null, }; const carBodyClientId = await this.resolveCarBodyPolicyClientId(m); party.person.clientId = carBodyClientId; clientId = String(carBodyClientId); } catch (err: any) { this.logger.error( `[V3-VIN] car body inquiry failed for request=${req._id}: ${err?.message || err}`, ); this.recordPartyCaseInquiryStatus( req, "carBody", partyRole, false, {}, err, ); await this.persistBlameInquiryAudit(req); this.throwCarBodyInquiryFailure(err); } } await this.runParticipantPersonalInquiries( req, partyRole, inquirySubmission, clientId ? { clientId } : undefined, ); await this.runVehicleOwnershipInquiry( req, partyRole, inquirySubmission, clientId ? { clientId } : undefined, ); const licenseNationalCode = partyData.driverIsInsurer ? partyData.nationalCodeOfInsurer : partyData.nationalCodeOfDriver; const licenseNumber = partyData.driverIsInsurer ? partyData.insurerLicense : partyData.driverLicense; if ( !inquirySubmission.participants.legacy && inquirySubmission.driver.hasDrivingLicense === false ) { this.recordPartyCaseInquiryStatus( req, "drivingLicence", partyRole, true, { participantId: inquirySubmission.driver.participantId, skipped: true, reason: "Driver declared no driving licence", }, ); } else if (!licenseNumber) { this.recordPartyCaseInquiryStatus( req, "drivingLicence", partyRole, false, { skipped: true, reason: "No license number provided" }, ); } else { try { const licenseInquiry = await this.sandHubService.getDrivingLicenseInfo( licenseNationalCode, licenseNumber, clientId ? { clientId } : undefined, ); this.recordPartyCaseInquiryStatus( req, "drivingLicence", partyRole, true, licenseInquiry, ); } catch (err: any) { this.logger.error( `[V3-VIN] driving license inquiry failed for ${roleLabel} party request=${req._id}: ${err?.message || err}`, ); this.recordPartyCaseInquiryStatus( req, "drivingLicence", partyRole, false, {}, err, ); await this.persistBlameInquiryAudit(req); throw new BadRequestException( `${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, ); } } return { clientId, subjects }; } private assertBlameV3PartyDetailPhase(req: any, partyRole: PartyRole): void { this.assertBlameV3ExpertInPerson(req); if (partyRole === PartyRole.FIRST) { if (!this.hasPartyInquiriesComplete(req, PartyRole.FIRST)) { throw new BadRequestException( "Complete guilty-party inquiries before collecting details.", ); } if (this.partyHasSigned(req, PartyRole.FIRST)) { throw new BadRequestException("Guilty party has already signed."); } return; } if (req.type !== BlameRequestType.THIRD_PARTY) { throw new BadRequestException("CAR_BODY has only one party."); } if (!this.isV3SecondPartyDetailPhase(req)) { throw new BadRequestException( "Complete damaged-party inquiries before collecting details.", ); } if (this.partyHasSigned(req, PartyRole.SECOND)) { throw new BadRequestException("Damaged party has already signed."); } } private assertBlameV3AccidentFieldsPhase(req: any): void { this.assertBlameV3ExpertInPerson(req); const requiredSigs = req.type === BlameRequestType.CAR_BODY ? 1 : 2; const signed = (req.parties ?? []).filter( (p: any) => p?.confirmation != null, ); if (signed.length < requiredSigs) { throw new BadRequestException( "All required party signatures must be collected before accident fields.", ); } } /** * V3 signature — no accident-fields prerequisite; does not auto-complete blame * until finalize-for-field is called after part selection. */ async expertUploadPartySignatureV3( 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"); this.assertBlameV3ExpertInPerson(req); await this.verifyExpertAccessForBlameV2(req, expert); if (partyRole === PartyRole.FIRST) { if (!this.hasPartyInquiriesComplete(req, PartyRole.FIRST)) { throw new BadRequestException( "Complete guilty-party inquiries before signing.", ); } if (this.partyHasSigned(req, PartyRole.FIRST)) { throw new BadRequestException("Guilty party has already signed."); } if (this.hasPartyInquiriesComplete(req, PartyRole.SECOND)) { throw new BadRequestException( "Guilty party must sign before damaged-party inquiries.", ); } } else { if (!this.hasPartyInquiriesComplete(req, PartyRole.SECOND)) { throw new BadRequestException( "Complete damaged-party inquiries before signing.", ); } } 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, }; await this.blameRequestDbService.findByIdAndUpdate(requestId, { $set: { [`parties.${partyIndex}.confirmation`]: { partyRole: party.role, accepted: isAccept, signature: signatureData, }, }, }); const fresh = await this.blameRequestDbService.findById(requestId); if (fresh) { const isFileMakerActor = expert?.role === RoleEnum.FILE_MAKER; if (partyRole === PartyRole.FIRST) { this.pushWorkflowSteps(fresh, [ WorkflowStep.FIRST_LOCATION, WorkflowStep.FIRST_VOICE, WorkflowStep.FIRST_DESCRIPTION, WorkflowStep.FIRST_SIGN, WorkflowStep.FIRST_COMPLETED, ]); if (fresh.type === BlameRequestType.THIRD_PARTY) { fresh.workflow.currentStep = WorkflowStep.FIRST_INVITE_SECOND; fresh.workflow.nextStep = WorkflowStep.SECOND_INITIAL_FORM; } else { // CAR_BODY: FileMaker's narrative is done — move to document-upload step. // blame.status transitions to WAITING_FOR_FILE_REVIEWER only after the // FileMaker completes all document uploads, not here at sign time. fresh.workflow.currentStep = WorkflowStep.FIRST_COMPLETED; fresh.workflow.nextStep = WorkflowStep.WAITING_FOR_GUILT_DECISION; } } else { this.pushWorkflowSteps(fresh, [ WorkflowStep.SECOND_LOCATION, WorkflowStep.SECOND_VOICE, WorkflowStep.SECOND_DESCRIPTION, WorkflowStep.SECOND_SIGN, WorkflowStep.SECOND_COMPLETED, ]); fresh.workflow.currentStep = WorkflowStep.SECOND_COMPLETED; fresh.workflow.nextStep = WorkflowStep.WAITING_FOR_GUILT_DECISION; // blame.status transitions to WAITING_FOR_FILE_REVIEWER only after the // FileMaker completes all document uploads, not here at sign time. } await (fresh as any).save(); } if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "V3_PARTY_SIGNATURE_RECORDED", actor: { actorId: new Types.ObjectId(expert.sub), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(expert), }, metadata: { partyRole, accepted: isAccept }, } as any); await (req as any).save(); return { requestId, accepted: isAccept, status: req.status ?? CaseStatus.OPEN, message: `Signature recorded for ${partyRole} party.`, }; } /** Saves accident fields without advancing blame to WAITING_FOR_SIGNATURES (V3 order). */ async expertAddAccidentFieldsForBlameV3( expert: any, requestId: string, fields: ExpertAccidentFieldsDto, ) { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); this.assertBlameV3ExpertInPerson(req); await this.verifyExpertAccessForBlameV2(req, expert); this.assertBlameV3AccidentFieldsPhase(req); if (!req.expert) req.expert = {} as any; if (!req.expert.decision) req.expert.decision = {} as any; const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const guiltyUserId = req.parties?.[firstIdx]?.person?.userId; const guiltyPhone = (req.parties?.[firstIdx]?.person as any)?.phoneNumber; const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); const damagedPhone = (req.parties?.[secondIdx]?.person as any)?.phoneNumber; (req.expert as any).decision = { ...(req.expert as any).decision, guiltyPartyId: guiltyUserId ? new Types.ObjectId(String(guiltyUserId)) : (req.expert as any).decision?.guiltyPartyId, description: (req.expert as any).decision?.description || `مقصر توسط کارشناس در محل حادثه مشخص شد. کاربر ${guiltyPhone ?? ""} مقصر و کاربر ${damagedPhone ?? ""} زیان دیده می‌باشد.`, decidedAt: (req.expert as any).decision?.decidedAt || new Date(), decidedByExpertId: new Types.ObjectId(String(expert.sub)), fields: { accidentWay: fields.accidentWay, accidentReason: fields.accidentReason, accidentType: fields.accidentType, }, }; if (typeof (req as any).markModified === "function") { (req as any).markModified("expert"); } if (!Array.isArray(req.history)) req.history = []; // Avoid duplicate history entries on idempotent retries. const alreadyRecorded = (req.history as any[]).some( (e: any) => e?.type === "V3_ACCIDENT_FIELDS_SAVED", ); if (!alreadyRecorded) { req.history.push({ type: "V3_ACCIDENT_FIELDS_SAVED", actor: { actorId: new Types.ObjectId(String(expert.sub)), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(expert), }, metadata: { accidentWay: fields.accidentWay }, } as any); } await (req as any).save(); return { requestId: req._id, publicId: req.publicId, message: "Accident fields saved. Proceed to required documents, part selection, capture, walk-around video, then blame accident video.", }; } /** * V3 final field step: blame accident video. Moves file to WAITING_FOR_EXPERT * after claim capture and walk-around video are complete. */ async expertUploadBlameVideoV3( expert: any, requestId: string, file: Express.Multer.File | undefined, ): Promise<{ requestId: string; publicId: string; videoId: string | undefined; status: string; message: string; }> { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); this.assertBlameV3ExpertInPerson(req); await this.verifyExpertAccessForBlameV2(req, expert); // Idempotent: if blame is already completed (car-capture auto-completed it // for V4/V5, or a previous call already ran), return success immediately. if ((req as any).status === CaseStatus.COMPLETED) { return { requestId: String(req._id), publicId: req.publicId, videoId: undefined, status: (req as any).status, message: "File already completed.", }; } if (!(req.expert?.decision as any)?.fields?.accidentWay) { throw new BadRequestException( "Submit accident fields before uploading the blame accident video.", ); } const requiredSigs = req.type === BlameRequestType.CAR_BODY ? 1 : 2; const signed = (req.parties ?? []).filter( (p: any) => p?.confirmation != null, ); if (signed.length < requiredSigs) { throw new BadRequestException( "All required party signatures must be collected before the blame accident video.", ); } const claim = await this.claimCaseDbService.findOne({ blameRequestId: (req as any)._id, }); if (!claim) { throw new BadRequestException( "Claim not found. Complete guilty-party inquiries first.", ); } if (!claim.media?.videoCaptureId) { throw new BadRequestException( "Upload the claim walk-around video (car-capture) before the blame accident video.", ); } if ( claim.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && claim.workflow?.currentStep !== ClaimWorkflowStep.USER_SUBMISSION_COMPLETE ) { throw new BadRequestException( "Complete capture-part (parts, angles, and capture-phase documents) before the blame accident video.", ); } const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; // Store video if provided; skip silently for V4/V5 where it is optional. let videoId: string | undefined; if (file) { if (firstParty?.evidence?.videoId) { throw new ConflictException("Blame accident video already uploaded"); } 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); videoId = firstParty.evidence.videoId; } this.pushWorkflowSteps(req, [ WorkflowStep.FIRST_VIDEO, WorkflowStep.WAITING_FOR_GUILT_DECISION, ]); req.workflow.currentStep = WorkflowStep.WAITING_FOR_GUILT_DECISION; req.workflow.nextStep = WorkflowStep.WAITING_FOR_SIGNATURES; req.status = CaseStatus.COMPLETED; if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "V5_BLAME_ACCIDENT_VIDEO_UPLOADED", actor: { actorId: new Types.ObjectId(expert.sub), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(expert), }, metadata: { videoId: videoId ?? null }, } as any); if (typeof (req as any).markModified === "function") { (req as any).markModified("parties"); } await (req as any).save(); await this.claimCaseDbService.findByIdAndUpdate(String(claim._id), { $set: { status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, claimStatus: ClaimStatus.PENDING, "workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, }, $push: { "workflow.completedSteps": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, history: { type: "STEP_COMPLETED", actor: { actorId: new Types.ObjectId(expert.sub), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(expert), }, timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, description: "V3 field workflow complete. Claim ready for damage expert review.", v3InPersonFlow: true, }, }, }, }); return { requestId: String(req._id), publicId: req.publicId, videoId, status: req.status, message: file ? "Blame accident video uploaded. File is now waiting for expert review." : "File completed. Claim is now ready for damage expert review.", }; } /** * @deprecated V3 blame completes via expertUploadBlameVideoV3 (WAITING_FOR_EXPERT). */ async tryAutoCompleteBlameV3ForField(_req: any): Promise { return; } private partyHasInquiryData(req: any, role: PartyRole): boolean { const roleKey = role === PartyRole.FIRST ? "FIRST" : "SECOND"; if (req.inquiries?.thirdParty?.data?.[roleKey]) return true; const idx = this.getPartyIndex(req, role); return idx !== -1 && req.parties?.[idx]?.vehicle?.inquiry != null; } private isV3SecondPartyDetailPhase(req: any): boolean { if (req.type !== BlameRequestType.THIRD_PARTY) return false; if (!this.partyHasSigned(req, PartyRole.FIRST)) return false; const step = String(req.workflow?.currentStep ?? ""); if (step.startsWith("SECOND_")) return true; return ( this.hasPartyInquiriesComplete(req, PartyRole.SECOND) || this.partyHasInquiryData(req, PartyRole.SECOND) ); } /** When partyRole is omitted, infer from workflow / party progress (sequential V3 flow). */ private resolvePartyRoleV3(req: any, partyRole?: string): PartyRole { if (partyRole === "SECOND" || partyRole === PartyRole.SECOND) { return PartyRole.SECOND; } if (partyRole === "FIRST" || partyRole === PartyRole.FIRST) { return PartyRole.FIRST; } const currentStep = String(req.workflow?.currentStep ?? ""); if (currentStep.startsWith("SECOND_")) return PartyRole.SECOND; if (currentStep.startsWith("FIRST_")) return PartyRole.FIRST; if ( this.hasPartyInquiriesComplete(req, PartyRole.FIRST) && !this.partyHasSigned(req, PartyRole.FIRST) ) { return PartyRole.FIRST; } if ( req.type === BlameRequestType.THIRD_PARTY && this.partyHasSigned(req, PartyRole.FIRST) && !this.partyHasSigned(req, PartyRole.SECOND) ) { return PartyRole.SECOND; } return PartyRole.FIRST; } async addPartyVoiceV3( requestId: string, actor: any, voice: Express.Multer.File, partyRole?: string, ) { if (!voice) throw new BadRequestException("File is required"); const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); await this.verifyExpertAccessForBlameV2(req, actor); const role = this.resolvePartyRoleV3(req, partyRole); this.assertBlameV3PartyDetailPhase(req, role); const idx = this.getPartyIndex(req, role); if (idx === -1) throw new BadRequestException(`${role} party not found`); const voiceDoc = await this.blameVoiceDbService.create({ fileName: voice.filename, path: voice.path, requestId: new Types.ObjectId(req._id), context: UploadContext.INITIAL, } as any); const party = req.parties[idx]; 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)); if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "V3_PARTY_VOICE_UPLOADED", actor: { actorId: new Types.ObjectId(actor.sub), actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(actor), }, metadata: { partyRole: role, voiceId: String((voiceDoc as any)._id) }, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId, partyRole: role }; } async addPartyLocationV3( requestId: string, actor: any, body: LocationDto, partyRole?: string, ) { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); await this.verifyExpertAccessForBlameV2(req, actor); // V4/V5: location step is skipped — store whatever is sent but do not // validate against workflow phase (the step was auto-completed at inquiry time). if ((req as any).isMadeByFileMaker) { const role = this.resolvePartyRoleV3(req, partyRole); const idx = this.getPartyIndex(req, role); if (idx !== -1 && body) req.parties[idx].location = body as any; await (req as any).save(); return { requestId: req._id, publicId: req.publicId, partyRole: role }; } const role = this.resolvePartyRoleV3(req, partyRole); this.assertBlameV3PartyDetailPhase(req, role); const idx = this.getPartyIndex(req, role); if (idx === -1) throw new BadRequestException(`${role} party not found`); req.parties[idx].location = body as any; if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "V3_PARTY_LOCATION_SAVED", actor: { actorId: new Types.ObjectId(actor.sub), actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(actor), }, metadata: { partyRole: role, location: body }, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId, partyRole: role }; } async addPartyDescriptionV3( requestId: string, actor: any, body: DescriptionDto, partyRole?: string, ) { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); await this.verifyExpertAccessForBlameV2(req, actor); const role = this.resolvePartyRoleV3(req, partyRole); this.assertBlameV3PartyDetailPhase(req, role); const idx = this.getPartyIndex(req, role); if (idx === -1) throw new BadRequestException(`${role} party not found`); const party = req.parties[idx]; if (!party.statement) party.statement = {} as any; party.statement.description = body.desc; if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "V3_PARTY_DESCRIPTION_SAVED", actor: { actorId: new Types.ObjectId(actor.sub), actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(actor), }, metadata: { partyRole: role }, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId, partyRole: role }; } /** * V4 description step. * * Identical to {@link addPartyDescriptionV3} but additionally requires * `accidentDate` and `accidentTime` for all blame types (THIRD_PARTY and * CAR_BODY alike) and persists them on `party.statement`. This lets the * front-end display the accident time consistently for every V4 file without * branching on blame type. */ async addPartyDescriptionV4( requestId: string, actor: any, body: DescriptionV4Dto, partyRole?: string, ) { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); await this.verifyExpertAccessForBlameV2(req, actor); const role = this.resolvePartyRoleV3(req, partyRole); this.assertBlameV3PartyDetailPhase(req, role); // accidentDate and accidentTime are only required for the first (guilty) // party — the second (damaged) party can skip them. if (role === PartyRole.FIRST) { if (body.accidentDate == null || body.accidentDate === ("" as any)) { throw new BadRequestException( "accidentDate is required for the first party in V4 files.", ); } if (!body.accidentTime || !String(body.accidentTime).trim()) { throw new BadRequestException( "accidentTime is required for the first party in V4 files.", ); } } const idx = this.getPartyIndex(req, role); if (idx === -1) throw new BadRequestException(`${role} party not found`); const party = req.parties[idx]; if (!party.statement) party.statement = {} as any; party.statement.description = body.desc; if (body.accidentDate != null) party.statement.accidentDate = body.accidentDate as any; if (body.accidentTime) party.statement.accidentTime = body.accidentTime; if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "V4_PARTY_DESCRIPTION_SAVED", actor: { actorId: new Types.ObjectId(actor.sub), actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(actor), }, metadata: { partyRole: role, accidentDate: body.accidentDate, accidentTime: body.accidentTime, }, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId, partyRole: role }; } async carBodyAccidentTypeFormV3( requestId: string, body: CarBodyFormDto, actor: 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( "CAR_BODY accident type is only for CAR_BODY files.", ); } await this.verifyExpertAccessForBlameV2(req, actor); this.assertBlameV3ExpertInPerson(req); const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; if (!firstParty?.person?.userId) { throw new BadRequestException( "Party OTP must be verified before the car-body form.", ); } if (this.hasPartyInquiriesComplete(req, PartyRole.FIRST)) { throw new BadRequestException( "Car-body form must be submitted before running inquiries.", ); } if (!firstParty.carBodyFirstForm) firstParty.carBodyFirstForm = {} as any; firstParty.carBodyFirstForm.car = body.car; firstParty.carBodyFirstForm.object = body.object; this.pushWorkflowSteps(req, [WorkflowStep.CAR_BODY_ACCIDENT_TYPE]); req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE; req.workflow.nextStep = WorkflowStep.FIRST_INITIAL_FORM; if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "V3_CAR_BODY_ACCIDENT_TYPE_SAVED", actor: { actorId: new Types.ObjectId(actor.sub), actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: this.resolveBlameHistoryActorType(actor), }, metadata: { car: body.car, object: body.object }, } as any); await (req as any).save(); return { requestId: req._id, publicId: req.publicId }; } /** * V5 variant of expertUploadBlameVideoV3. * Same flow as V3/V4 but additionally marks the linked claim with * `requiresFileMakerApproval: true` so that after expert work completes, * the claim is held at WAITING_FOR_FILE_MAKER_APPROVAL. FileMaker approval * completes the claim; Fanavaran submission is manual. */ async expertUploadBlameVideoV5( expert: any, requestId: string, file: Express.Multer.File | undefined, ): Promise<{ requestId: string; publicId: string; videoId: string | undefined; status: string; message: string; }> { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); this.assertBlameV3ExpertInPerson(req); await this.verifyExpertAccessForBlameV2(req, expert); // Idempotent: blame already completed (car-capture auto-completed it for // V4/V5, or a previous call already ran). if ((req as any).status === CaseStatus.COMPLETED) { return { requestId: String(req._id), publicId: req.publicId, videoId: undefined, status: (req as any).status, message: "File already completed.", }; } if (!(req.expert?.decision as any)?.fields?.accidentWay) { throw new BadRequestException( "Submit accident fields before uploading the blame accident video.", ); } const requiredSigs = req.type === BlameRequestType.CAR_BODY ? 1 : 2; const signed = (req.parties ?? []).filter( (p: any) => p?.confirmation != null, ); if (signed.length < requiredSigs) { throw new BadRequestException( "All required party signatures must be collected before the blame accident video.", ); } const claim = await this.claimCaseDbService.findOne({ blameRequestId: (req as any)._id, }); if (!claim) { throw new BadRequestException( "Claim not found. Complete guilty-party inquiries first.", ); } if (!claim.media?.videoCaptureId) { throw new BadRequestException( "Upload the claim walk-around video (car-capture) before the blame accident video.", ); } if ( claim.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS ) { throw new BadRequestException( "Complete capture-part (parts, angles, and capture-phase documents) before the blame accident video.", ); } const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; // Store video if provided; skip silently for V4/V5 where it is optional. let videoId: string | undefined; if (file) { if (firstParty?.evidence?.videoId) { throw new ConflictException("Blame accident video already uploaded"); } 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); videoId = firstParty.evidence.videoId; } this.pushWorkflowSteps(req, [ WorkflowStep.FIRST_VIDEO, WorkflowStep.WAITING_FOR_GUILT_DECISION, ]); req.workflow.currentStep = WorkflowStep.WAITING_FOR_GUILT_DECISION; req.workflow.nextStep = WorkflowStep.WAITING_FOR_SIGNATURES; req.status = CaseStatus.COMPLETED; if (!Array.isArray(req.history)) req.history = []; req.history.push({ type: "V5_BLAME_ACCIDENT_VIDEO_UPLOADED", actor: { actorId: new Types.ObjectId(expert.sub), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "file_reviewer", }, metadata: { videoId: videoId ?? null }, } as any); if (typeof (req as any).markModified === "function") { (req as any).markModified("parties"); } await (req as any).save(); // Advance the claim into the damage-expert queue. // requiresFileMakerApproval / fileMakerApprovalActorId were already set at // claim creation time (createV3ClaimFromBlame) so no need to repeat here. await this.claimCaseDbService.findByIdAndUpdate(String(claim._id), { $set: { status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, claimStatus: ClaimStatus.PENDING, "workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, }, $push: { "workflow.completedSteps": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, history: { type: "V5_BLAME_ACCIDENT_VIDEO_UPLOADED", actor: { actorId: new Types.ObjectId(expert.sub), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "file_reviewer", }, timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, description: "V5 field workflow complete. Claim ready for damage expert review (FileMaker approval required before fanavaran).", v5InPersonFlow: true, }, }, }, }); return { requestId: String(req._id), publicId: req.publicId, videoId, status: req.status, message: file ? "Blame accident video uploaded. File is now in expert review queue. After the full claim flow completes, the FileMaker must approve before manual Fanavaran submission." : "File completed. Claim is now ready for damage expert review.", }; } /** * V5: FileMaker approves the completed claim. * * Preconditions: * - claim.requiresFileMakerApproval === true * - claim.status === WAITING_FOR_FILE_MAKER_APPROVAL * - actor is FILE_MAKER and is the original creator of the linked blame file * * On approval: completes the claim. Fanavaran submission is manual. */ async fileMakerApproveV5( fileMaker: any, claimRequestId: string, ): Promise<{ claimRequestId: string; publicId: string; status: string; message: string; }> { const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) throw new NotFoundException("Claim not found"); this.assertFileMakerApprovalAccess(claim, fileMaker); // After 2 rejections the claim is in WAITING_FOR_DAMAGE_EXPERT (forced-approve // path); any other status is still an error. const rejectionCount = (claim as any).fileMakerRejectionCount ?? 0; const forcedApprove = rejectionCount >= 2 && claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; if ( claim.status !== ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL && !forcedApprove ) { throw new BadRequestException( `Claim must be in WAITING_FOR_FILE_MAKER_APPROVAL status to approve. Current: ${claim.status}`, ); } const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim(); const historyEntry = { type: "V5_FILE_MAKER_APPROVED", actor: { actorId: new Types.ObjectId(fileMaker.sub), actorName, actorType: "file_maker", }, timestamp: new Date(), metadata: {}, }; // V5 approval is the final V5 gate. The damaged-party final signature is // no longer required, and Fanavaran submission is intentionally manual. await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { status: ClaimCaseStatus.COMPLETED, claimStatus: ClaimStatus.APPROVED, requiresFileMakerApproval: false, "workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED, "workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED, }, $push: { history: historyEntry, }, }); return { claimRequestId, publicId: claim.publicId, status: ClaimCaseStatus.COMPLETED, message: "Claim approved by FileMaker and completed. Submit it to Fanavaran manually when ready.", }; } /** * V5: FileMaker rejects the completed claim back to FileReviewer for correction. * * Allowed at most 2 times per claim lifetime. On the 3rd attempt the endpoint * returns 422 FILE_MAKER_REJECTION_LIMIT_EXCEEDED and the FileMaker must approve. * * Transition (both blame + claim are updated atomically): * claim: WAITING_FOR_FILE_MAKER_APPROVAL → WAITING_FOR_DAMAGE_EXPERT * blame: (any status) → WAITING_FOR_FILE_REVIEWER */ async fileMakerRejectV5( fileMaker: any, claimRequestId: string, reason?: string, ): Promise<{ claimRequestId: string; publicId: string; status: string; fileMakerRejectionCount: number; message: string; }> { const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) throw new NotFoundException("Claim not found"); this.assertFileMakerApprovalAccess(claim, fileMaker); if (claim.status !== ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL) { throw new BadRequestException( `Claim must be in WAITING_FOR_FILE_MAKER_APPROVAL status to reject. Current: ${claim.status}`, ); } const currentRejections = (claim as any).fileMakerRejectionCount ?? 0; if (currentRejections >= 2) { throw new BusinessRuleException( BusinessErrorCode.FILE_MAKER_REJECTION_LIMIT_EXCEEDED, "این پرونده قبلاً دو بار رد شده است. امکان رد مجدد وجود ندارد — باید پرونده را تأیید کنید.", ); } const newRejectionCount = currentRejections + 1; const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim(); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, claimStatus: ClaimStatus.NEEDS_REVISION, fileMakerRejectionCount: newRejectionCount, fileMakerRejectionReason: reason ?? null, "workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, "workflow.locked": false, }, // Clear stale lock fields and the owner-cycle evaluation data. // evaluation.damageExpertReply is intentionally preserved so the FileReviewer // sees their previous prices / daghi / severity pre-populated in the claim // detail and can adjust them. submitExpertReplyV2 overwrites it with $set. // - assignedForReviewBy intentionally kept so the same reviewer // is still scoped to this file on re-lock. $unset: { "workflow.lockedAt": "", "workflow.expiredAt": "", "workflow.lockedBy": "", "workflow.preLockQueueSnapshot": "", "evaluation.damageExpertResend": "", "evaluation.ownerInsurerApproval": "", "evaluation.ownerPricedPartsApproval": "", }, $push: { history: { type: "V5_FILE_MAKER_REJECTED", actor: { actorId: new Types.ObjectId(fileMaker.sub), actorName, actorType: "file_maker", }, timestamp: new Date(), metadata: { reason: reason ?? null, rejectionCount: newRejectionCount, }, }, }, }); // Reset blame back to WAITING_FOR_FILE_REVIEWER so the reviewer's list // query (which looks for that status + assignedFileReviewerId) surfaces it. if (claim.blameRequestId) { await this.blameRequestDbService.findByIdAndUpdate( String(claim.blameRequestId), { $set: { status: CaseStatus.WAITING_FOR_FILE_REVIEWER }, $push: { history: { type: "V5_FILE_MAKER_REJECTED_BLAME_RESET", actor: { actorId: new Types.ObjectId(fileMaker.sub), actorName, actorType: "file_maker", }, timestamp: new Date(), metadata: { claimRequestId, reason: reason ?? null }, }, }, }, ); } return { claimRequestId, publicId: claim.publicId, status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, fileMakerRejectionCount: newRejectionCount, message: newRejectionCount >= 2 ? "Claim rejected by FileMaker (2nd rejection — next action must be approval). FileReviewer can re-lock and adjust the assessment." : "Claim rejected by FileMaker. FileReviewer can re-lock the claim and adjust the damage assessment or restart the user interaction.", }; } /** Guard for V5 FileMaker approval endpoints. */ private assertFileMakerApprovalAccess(claim: any, fileMaker: any): void { if (fileMaker?.role !== RoleEnum.FILE_MAKER) { throw new ForbiddenException("Only FileMakers can perform this action."); } if (!claim.requiresFileMakerApproval) { throw new ForbiddenException( "This claim does not require FileMaker approval (not a V5 file).", ); } // Scope to the FileMaker who created the linked blame file if ( claim.fileMakerApprovalActorId && String(claim.fileMakerApprovalActorId) !== String(fileMaker.sub) ) { throw new ForbiddenException( "Only the FileMaker who created this file can approve or reject it.", ); } } // ── V6 call-center flow ──────────────────────────────────────────────── /** * V6: Create a LINK blame file initiated by a call-center agent. * Always LINK + CUSTOMER-filled (user fills the form after receiving the SMS). * `skipInitialFormStep` is set so the user-side page skips the inquiry step * (the agent already ran it via runCallCenterInquiryV6). */ async createCallCenterInitiatedBlameV6( agent: any, dto: { type: "THIRD_PARTY" | "CAR_BODY" }, ): Promise<{ requestId: string; publicId: string }> { if (agent?.role !== RoleEnum.CALL_CENTER) { throw new ForbiddenException( "Only call-center agents can use this endpoint.", ); } const agentId = new Types.ObjectId(agent.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 created = await this.blameRequestDbService.create({ publicId, requestNo: publicId, type, parties: [{ role: PartyRole.FIRST, person: {} }], workflow: { currentStep: firstStep.stepKey as WorkflowStep, nextStep, completedSteps: [firstStep.stepKey as WorkflowStep], locked: false, }, history: [], callCenterInitiated: true, initiatedByCallCenterId: agentId, creationMethod: CreationMethod.LINK, filledBy: FilledBy.CUSTOMER, // User-side page should skip the inquiry/initial-form step skipInitialFormStep: true, }); 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_CALL_CENTER", actor: { actorId: agentId, actorName: `${agent.firstName || ""} ${agent.lastName || ""}`.trim(), actorType: RoleEnum.CALL_CENTER, }, metadata: { creationMethod: CreationMethod.LINK, type: dto.type }, }); await (created as any).save(); return { requestId, publicId: (created as any).publicId }; } /** * V6: Run plate + personal inquiry for the guilty party on behalf of the caller. * Stores the inquiry results on the blame's first party (same as V3 guilty-party path) * but does NOT create a claim — the user will do that after filling the form via the link. * Note: sheba is intentionally absent — the user provides their own IBAN later. */ async runCallCenterInquiryV6( agent: any, requestId: string, dto: Omit, ): Promise> { if (agent?.role !== RoleEnum.CALL_CENTER) { throw new ForbiddenException( "Only call-center agents can use this endpoint.", ); } const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Blame request not found"); if ( !req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub) ) { throw new ForbiddenException( "You can only access files that you have initiated.", ); } const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; await this.runPartyInquiriesV3Internal( req, dto, PartyRole.FIRST, firstParty, ); // V6 call-center guard: the guilty party's insurance must belong to THIS // deployment's insurer company. Reject the file early so the agent cannot // initiate a blame flow for a third-party insurance holder. const resolvedClientId = req.parties[firstIdx]?.person?.clientId; if (resolvedClientId && process.env.CLIENT_ID) { const resolvedClient = await this.clientService.findOne({ _id: new Types.ObjectId(String(resolvedClientId)), }); if ( resolvedClient && String(resolvedClient.clientCode) !== String(process.env.CLIENT_ID) ) { throw new ForbiddenException( "بیمه‌نامه طرف مقصر متعلق به شرکت بیمه این سامانه نیست. لینک تقصیر فقط برای بیمه‌گذاران همین شرکت قابل ارسال است.", ); } } // Do NOT call markPartyInquiriesCompleteOnBlame here: that helper is for V3/V4/V5 // FileMaker flows and would advance the workflow to FIRST_LOCATION prematurely. // In V6 the workflow must stay at CREATED until send-link advances it to FIRST_VIDEO. if (!Array.isArray((req as any).history)) (req as any).history = []; (req as any).history.push({ type: "CALL_CENTER_INQUIRY_COMPLETED", actor: { actorId: new Types.ObjectId(agent.sub), actorName: `${agent.firstName || ""} ${agent.lastName || ""}`.trim(), actorType: RoleEnum.CALL_CENTER, }, metadata: { partyRole: PartyRole.FIRST }, }); await (req as any).save(); // Return the inquiry-populated data so the agent can review before sending the link. const firstPartyAfter = req.parties[firstIdx]; return { blameRequestId: requestId, publicId: (req as any).publicId, type: (req as any).type, status: (req as any).status, message: "استعلام طرف مقصر با موفقیت انجام شد. اکنون می‌توانید لینک تقصیر را برای کاربر ارسال کنید.", guiltyParty: { participants: firstPartyAfter?.participants, participantRoles: firstPartyAfter?.participantRoles, vehicle: firstPartyAfter?.vehicle ? { plateId: firstPartyAfter.vehicle.plateId, previousPlateId: firstPartyAfter.vehicle.previousPlateId, registrationState: firstPartyAfter.vehicle.registrationState, vin: firstPartyAfter.vehicle.vin, name: firstPartyAfter.vehicle.name, type: firstPartyAfter.vehicle.type, } : undefined, insurance: firstPartyAfter?.insurance ? { company: firstPartyAfter.insurance.company, policyNumber: firstPartyAfter.insurance.policyNumber, startDate: firstPartyAfter.insurance.startDate, endDate: firstPartyAfter.insurance.endDate, financialCeiling: (firstPartyAfter.insurance as any) .financialCeiling, } : undefined, person: firstPartyAfter?.person ? { nationalCodeOfInsurer: firstPartyAfter.person.nationalCodeOfInsurer, nationalCodeOfDriver: firstPartyAfter.person.nationalCodeOfDriver, driverIsInsurer: firstPartyAfter.person.driverIsInsurer, insurerBirthday: formatJalaliCompact( firstPartyAfter.person.insurerBirthday, ), driverBirthday: formatJalaliCompact( firstPartyAfter.person.driverBirthday, ), } : undefined, }, }; } /** * V6 VIN variant: Run VIN/chassis inquiry for the guilty party on behalf of the caller. * Identical to `runCallCenterInquiryV6` but calls `runPartyInquiriesVinV3Internal` * (ESG chassis lookup) instead of the plate-based path. * Returns `vehicle.vin` in the guiltyParty payload instead of `vehicle.plateId`. */ async runCallCenterInquiryVinV6( agent: any, requestId: string, dto: Omit, ): Promise> { if (agent?.role !== RoleEnum.CALL_CENTER) { throw new ForbiddenException( "Only call-center agents can use this endpoint.", ); } const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Blame request not found"); if ( !req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub) ) { throw new ForbiddenException( "You can only access files that you have initiated.", ); } const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; await this.runPartyInquiriesVinV3Internal( req, dto as RunInquiriesVinV3Dto, PartyRole.FIRST, firstParty, ); // Same insurer-company guard as the plate variant. const resolvedClientId = req.parties[firstIdx]?.person?.clientId; if (resolvedClientId && process.env.CLIENT_ID) { const resolvedClient = await this.clientService.findOne({ _id: new Types.ObjectId(String(resolvedClientId)), }); if ( resolvedClient && String(resolvedClient.clientCode) !== String(process.env.CLIENT_ID) ) { throw new ForbiddenException( "بیمه‌نامه طرف مقصر متعلق به شرکت بیمه این سامانه نیست. لینک تقصیر فقط برای بیمه‌گذاران همین شرکت قابل ارسال است.", ); } } if (!Array.isArray((req as any).history)) (req as any).history = []; (req as any).history.push({ type: "CALL_CENTER_INQUIRY_COMPLETED", actor: { actorId: new Types.ObjectId(agent.sub), actorName: `${agent.firstName || ""} ${agent.lastName || ""}`.trim(), actorType: RoleEnum.CALL_CENTER, }, metadata: { partyRole: PartyRole.FIRST, inquiryType: "VIN" }, }); await (req as any).save(); const firstPartyAfter = req.parties[firstIdx]; return { blameRequestId: requestId, publicId: (req as any).publicId, type: (req as any).type, status: (req as any).status, message: "استعلام VIN طرف مقصر با موفقیت انجام شد. اکنون می‌توانید لینک تقصیر را برای کاربر ارسال کنید.", guiltyParty: { participants: firstPartyAfter?.participants, participantRoles: firstPartyAfter?.participantRoles, vehicle: firstPartyAfter?.vehicle ? { vin: firstPartyAfter.vehicle.vin, plateId: firstPartyAfter.vehicle.plateId, previousPlateId: firstPartyAfter.vehicle.previousPlateId, registrationState: firstPartyAfter.vehicle.registrationState, name: firstPartyAfter.vehicle.name, type: firstPartyAfter.vehicle.type, } : undefined, insurance: firstPartyAfter?.insurance ? { company: firstPartyAfter.insurance.company, policyNumber: firstPartyAfter.insurance.policyNumber, startDate: firstPartyAfter.insurance.startDate, endDate: firstPartyAfter.insurance.endDate, financialCeiling: (firstPartyAfter.insurance as any) .financialCeiling, } : undefined, person: firstPartyAfter?.person ? { nationalCodeOfInsurer: firstPartyAfter.person.nationalCodeOfInsurer, nationalCodeOfDriver: firstPartyAfter.person.nationalCodeOfDriver, driverIsInsurer: firstPartyAfter.person.driverIsInsurer, insurerBirthday: formatJalaliCompact( firstPartyAfter.person.insurerBirthday, ), driverBirthday: formatJalaliCompact( firstPartyAfter.person.driverBirthday, ), } : undefined, }, }; } /** * V6: Send the blame link to the guilty party's phone number. * Registers/looks up the user by phone and stores them as the first party, then * sends the SMS link so the user can fill in the rest of the form via the v2 flow. */ async sendCallCenterLinkV6( agent: any, requestId: string, dto: { phoneNumber: string }, ): Promise<{ sent: boolean; sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: string; }[]; }> { if (agent?.role !== RoleEnum.CALL_CENTER) { throw new ForbiddenException( "Only call-center agents can use this endpoint.", ); } const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if ( !req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub) ) { throw new ForbiddenException( "You can only access files that you have initiated.", ); } if (!process.env.URL) { throw new InternalServerErrorException( "URL environment variable is not configured", ); } const phone = (dto?.phoneNumber || "").trim(); if (!phone) throw new BadRequestException("phoneNumber is required"); const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found on request"); // Safeguard: verify that run-inquiry was completed and that the resolved // insurance company belongs to this deployment before proceeding. const storedClientId = req.parties[firstIdx]?.person?.clientId; if (!storedClientId) { throw new BadRequestException( "استعلام بیمه طرف مقصر انجام نشده است. ابتدا run-inquiry را فراخوانی کنید.", ); } if (process.env.CLIENT_ID) { const storedClient = await this.clientService.findOne({ _id: new Types.ObjectId(String(storedClientId)), }); if ( storedClient && String(storedClient.clientCode) !== String(process.env.CLIENT_ID) ) { throw new ForbiddenException( "بیمه‌نامه طرف مقصر متعلق به شرکت بیمه این سامانه نیست. لینک تقصیر فقط برای بیمه‌گذاران همین شرکت قابل ارسال است.", ); } } const userId = await this.getOrCreateUserByPhoneNumber(phone); if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any; req.parties[firstIdx].person.phoneNumber = normalizeIranMobile(phone) ?? phone; req.parties[firstIdx].person.userId = userId; // V6 call-center flow: the first party is always the guilty party. // Auto-skip FIRST_BLAME_CONFESSION, set blameStatus=AGREED, and advance // the workflow to FIRST_VIDEO — exactly the same as the IN_PERSON expert flow. if (req.workflow?.currentStep === WorkflowStep.CREATED) { const step2 = await this.getWorkflowStep({ stepNumber: 2 }); // FIRST_BLAME_CONFESSION const step2Key = step2.stepKey as WorkflowStep; if (req.type === BlameRequestType.CAR_BODY) { // CAR_BODY: advance CREATED → CAR_BODY_ACCIDENT_TYPE const carBodyStepDoc = await this.getWorkflowStep({ stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE, }); const afterCarBody = (carBodyStepDoc?.nextPossibleSteps?.[0] as WorkflowStep) ?? WorkflowStep.FIRST_VIDEO; const completedSteps = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; req.workflow.completedSteps = completedSteps; req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE; req.workflow.nextStep = afterCarBody; } else { // THIRD_PARTY: skip confession — first party is always guilty const step3 = await this.getWorkflowStep({ stepNumber: 3 }); // FIRST_VIDEO const step3Key = step3.stepKey as WorkflowStep; const nextAfterVideo = (step3.nextPossibleSteps?.[0] as WorkflowStep) ?? WorkflowStep.FIRST_INITIAL_FORM; const completedSteps = Array.isArray(req.workflow.completedSteps) ? req.workflow.completedSteps : []; if (!completedSteps.includes(step2Key)) completedSteps.push(step2Key); req.workflow.completedSteps = completedSteps; req.workflow.currentStep = step3Key; req.workflow.nextStep = nextAfterVideo; } // Auto-guilt: first party is always the guilty party in call-center v6 flow if (!req.parties[firstIdx].statement) req.parties[firstIdx].statement = {} as any; req.parties[firstIdx].statement.admitsGuilt = true; req.parties[firstIdx].statement.claimsDamage = false; req.parties[firstIdx].statement.acceptsExpertOpinion = false; req.blameStatus = BlameStatus.AGREED; if (!Array.isArray((req as any).history)) (req as any).history = []; (req as any).history.push({ type: "AUTO_CONFESSION_SKIPPED", actor: { actorId: new Types.ObjectId(agent.sub), actorName: `${agent.firstName || ""} ${agent.lastName || ""}`.trim(), actorType: RoleEnum.CALL_CENTER, }, metadata: { reason: "V6 call-center initiated: first party is always guilty; confession auto-resolved", advancedTo: req.workflow.currentStep, }, }); } const expertName = `${agent?.lastName || ""}`.trim() || "اپراتور"; const firstLink = this.smsOrchestrationService.buildBlamePartyLink( requestId, "FIRST", "v6", ); const smsSent = await this.smsOrchestrationService.sendFieldExpertLink({ receptor: phone, type: req.type, expertLastName: expertName, link: firstLink, }); const sentTo = [ { role: PartyRole.FIRST, phoneNumber: phone, smsSent, linkUrl: firstLink, }, ]; if (!Array.isArray((req as any).history)) (req as any).history = []; (req as any).history.push({ type: "CALL_CENTER_LINK_SENT", actor: { actorId: new Types.ObjectId(agent.sub), actorName: `${agent.firstName || ""} ${agent.lastName || ""}`.trim(), actorType: RoleEnum.CALL_CENTER, }, metadata: { sentTo }, }); await (req as any).save(); return { sent: smsSent, sentTo }; } /** * V6: Get a single blame file detail for the call-center agent who created it. */ async getCallCenterBlameDetailV6( agent: any, requestId: string, ): Promise { if (agent?.role !== RoleEnum.CALL_CENTER) { throw new ForbiddenException( "Only call-center agents can use this endpoint.", ); } const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Blame request not found"); if ( !req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub) ) { throw new ForbiddenException( "You can only access files that you have initiated.", ); } return { _id: (req as any)._id, publicId: (req as any).publicId, requestNo: (req as any).requestNo, type: (req as any).type, status: (req as any).status, blameStatus: (req as any).blameStatus, workflow: { currentStep: (req as any).workflow?.currentStep, nextStep: (req as any).workflow?.nextStep, completedSteps: (req as any).workflow?.completedSteps, }, skipInitialFormStep: (req as any).skipInitialFormStep, parties: ((req as any).parties ?? []).map((p: any) => ({ role: p.role, participants: p.participants, participantRoles: p.participantRoles, person: { phoneNumber: p.person?.phoneNumber, nationalCodeOfInsurer: p.person?.nationalCodeOfInsurer, nationalCodeOfDriver: p.person?.nationalCodeOfDriver, driverIsInsurer: p.person?.driverIsInsurer, insurerBirthday: p.person?.insurerBirthday, driverBirthday: p.person?.driverBirthday, clientId: p.person?.clientId ? String(p.person.clientId) : undefined, licenseType: p.person?.licenseType ?? undefined, }, insurance: p.insurance ? { company: p.insurance.company, policyNumber: p.insurance.policyNumber, startDate: p.insurance.startDate, endDate: p.insurance.endDate, financialCeiling: p.insurance.financialCeiling, } : undefined, vehicle: p.vehicle ? { plateId: p.vehicle.plateId, previousPlateId: p.vehicle.previousPlateId, registrationState: p.vehicle.registrationState, vin: p.vehicle.vin, name: p.vehicle.name, type: p.vehicle.type, } : undefined, inquiryComplete: !!p.person?.inquiriesCompleted, })), createdAt: (req as any).createdAt, updatedAt: (req as any).updatedAt, }; } /** * V6: List blame files created by this call-center agent. */ // ─── FileMaker file list / detail (V4 + V5) ──────────────────────────────── async getMyFileMakerFiles(fileMaker: any): Promise { if (fileMaker?.role !== RoleEnum.FILE_MAKER) { throw new ForbiddenException("Only FileMakers can use this endpoint."); } const makerId = new Types.ObjectId(fileMaker.sub); const files = await this.blameRequestDbService.find({ isMadeByFileMaker: true, initiatedByFieldExpertId: makerId, }); return (files || []).map((f: any) => ({ _id: f._id, publicId: f.publicId, requestNo: f.requestNo, type: f.type, status: f.status, blameStatus: f.blameStatus, workflow: { currentStep: f.workflow?.currentStep, nextStep: f.workflow?.nextStep, completedSteps: f.workflow?.completedSteps, }, requiresFileMakerApproval: f.requiresFileMakerApproval, createdAt: f.createdAt, updatedAt: f.updatedAt, })); } async getMyFileMakerFileDetail( fileMaker: any, requestId: string, ): Promise { if (fileMaker?.role !== RoleEnum.FILE_MAKER) { throw new ForbiddenException("Only FileMakers can use this endpoint."); } const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Blame request not found"); if ( !req.isMadeByFileMaker || String((req as any).initiatedByFieldExpertId) !== String(fileMaker.sub) ) { throw new ForbiddenException( "You can only access files that you have created.", ); } const plain = typeof (req as any).toObject === "function" ? (req as any).toObject({ versionKey: false }) : { ...(req as any) }; // Attach linked claim ID if present const claim = await this.claimCaseDbService.findOne({ blameRequestId: (req as any)._id, }); const claimPlain = claim && typeof (claim as any).toObject === "function" ? (claim as any).toObject({ versionKey: false }) : claim ? { ...(claim as any) } : null; return { _id: plain._id, publicId: plain.publicId, requestNo: plain.requestNo, type: plain.type, status: plain.status, blameStatus: plain.blameStatus, workflow: plain.workflow, requiresFileMakerApproval: plain.requiresFileMakerApproval, parties: (plain.parties ?? []).map((p: any) => ({ role: p.role, participants: p.participants, participantRoles: p.participantRoles, person: { fullName: p.person?.fullName, phoneNumber: p.person?.phoneNumber, nationalCodeOfInsurer: p.person?.nationalCodeOfInsurer, nationalCodeOfDriver: p.person?.nationalCodeOfDriver, driverIsInsurer: p.person?.driverIsInsurer, insurerBirthday: p.person?.insurerBirthday, driverBirthday: p.person?.driverBirthday, insurerLicense: p.person?.insurerLicense, driverLicense: p.person?.driverLicense, userId: p.person?.userId ? String(p.person.userId) : undefined, clientId: p.person?.clientId ? String(p.person.clientId) : undefined, licenseType: p.person?.licenseType ?? undefined, }, insurance: p.insurance ? { company: p.insurance.company, policyNumber: p.insurance.policyNumber, startDate: p.insurance.startDate, endDate: p.insurance.endDate, financialCeiling: p.insurance.financialCeiling, } : undefined, vehicle: p.vehicle ? { plateId: p.vehicle.plateId, previousPlateId: p.vehicle.previousPlateId, registrationState: p.vehicle.registrationState, vin: p.vehicle.vin, name: p.vehicle.name, type: p.vehicle.type, } : undefined, inquiryComplete: !!p.person?.inquiriesCompleted, hasSigned: p.confirmation != null, })), linkedClaimId: claimPlain ? String(claimPlain._id) : null, ...(claimPlain ? { claimStatus: claimPlain.status, claimWorkflow: claimPlain.workflow, money: claimPlain.money ?? null, fanavaran: fanavaranClaimReferences(claimPlain), fileMakerRejectionCount: claimPlain.fileMakerRejectionCount ?? 0, fileMakerRejectionReason: claimPlain.fileMakerRejectionReason ?? null, evaluation: claimPlain.evaluation ? { damageExpertReply: claimPlain.evaluation.damageExpertReply ?? null, damageExpertReplyFinal: claimPlain.evaluation.damageExpertReplyFinal ?? null, } : null, } : {}), createdAt: plain.createdAt, updatedAt: plain.updatedAt, }; } // ─── FileReviewer file list / detail (V4 + V5) ───────────────────────────── async getMyFileReviewerFiles(fileReviewer: any): Promise { if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) { throw new ForbiddenException("Only FileReviewers can use this endpoint."); } const clientKey = requireActorClientKey(fileReviewer); const reviewerId = new Types.ObjectId(fileReviewer.sub); const files = await this.blameRequestDbService.find({ isMadeByFileMaker: true, expertInitiated: true, creationMethod: CreationMethod.IN_PERSON, $or: [ // A FileMaker-sealed file must be discoverable before a reviewer can // claim it. Once another reviewer takes it, only that reviewer sees it. { status: CaseStatus.WAITING_FOR_FILE_REVIEWER, $or: [ { assignedFileReviewerId: { $exists: false } }, { assignedFileReviewerId: null }, ], }, { assignedFileReviewerId: reviewerId }, ], }); const visibleFiles = (files || []).filter((file: any) => { const assignedReviewerId = file.assignedFileReviewerId ? String(file.assignedFileReviewerId) : null; const isOpen = file.status === CaseStatus.WAITING_FOR_FILE_REVIEWER && !assignedReviewerId; const isAssignedToReviewer = assignedReviewerId === String(fileReviewer.sub); return ( blameCaseTouchesClient(file, clientKey) && (isOpen || isAssignedToReviewer) ); }); return visibleFiles.map((f: any) => ({ _id: f._id, publicId: f.publicId, requestNo: f.requestNo, type: f.type, status: f.status, blameStatus: f.blameStatus, workflow: { currentStep: f.workflow?.currentStep, nextStep: f.workflow?.nextStep, completedSteps: f.workflow?.completedSteps, }, requiresFileMakerApproval: f.requiresFileMakerApproval, createdAt: f.createdAt, updatedAt: f.updatedAt, })); } async getMyFileReviewerFileDetail( fileReviewer: any, requestId: string, ): Promise { if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) { throw new ForbiddenException("Only FileReviewers can use this endpoint."); } const clientKey = requireActorClientKey(fileReviewer); const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Blame request not found"); if ( !req.isMadeByFileMaker || !req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON ) { throw new ForbiddenException( "FileReviewer can only access V4/V5 FileMaker files.", ); } if (!blameCaseTouchesClient(req, clientKey)) { throw new ForbiddenException( "This file does not belong to your organization.", ); } const assignedId = (req as any).assignedFileReviewerId ? String((req as any).assignedFileReviewerId) : null; if (assignedId && assignedId !== String(fileReviewer.sub)) { throw new ForbiddenException( "This file has been taken by another FileReviewer.", ); } if (!assignedId && req.status !== CaseStatus.WAITING_FOR_FILE_REVIEWER) { throw new ForbiddenException("This file is not available for review."); } const plain = typeof (req as any).toObject === "function" ? (req as any).toObject({ versionKey: false }) : { ...(req as any) }; const claim = await this.claimCaseDbService.findOne({ blameRequestId: (req as any)._id, }); const claimPlain = claim && typeof (claim as any).toObject === "function" ? (claim as any).toObject({ versionKey: false }) : claim ? { ...(claim as any) } : null; return { _id: plain._id, publicId: plain.publicId, requestNo: plain.requestNo, type: plain.type, status: plain.status, blameStatus: plain.blameStatus, workflow: plain.workflow, requiresFileMakerApproval: plain.requiresFileMakerApproval, assignedFileReviewerId: plain.assignedFileReviewerId ? String(plain.assignedFileReviewerId) : null, parties: (plain.parties ?? []).map((p: any) => ({ role: p.role, participants: p.participants, participantRoles: p.participantRoles, person: { fullName: p.person?.fullName, phoneNumber: p.person?.phoneNumber, nationalCodeOfInsurer: p.person?.nationalCodeOfInsurer, nationalCodeOfDriver: p.person?.nationalCodeOfDriver, driverIsInsurer: p.person?.driverIsInsurer, insurerBirthday: p.person?.insurerBirthday, driverBirthday: p.person?.driverBirthday, insurerLicense: p.person?.insurerLicense, driverLicense: p.person?.driverLicense, userId: p.person?.userId ? String(p.person.userId) : undefined, clientId: p.person?.clientId ? String(p.person.clientId) : undefined, licenseType: p.person?.licenseType ?? undefined, }, insurance: p.insurance ? { company: p.insurance.company, policyNumber: p.insurance.policyNumber, startDate: p.insurance.startDate, endDate: p.insurance.endDate, financialCeiling: p.insurance.financialCeiling, } : undefined, vehicle: p.vehicle ? { plateId: p.vehicle.plateId, previousPlateId: p.vehicle.previousPlateId, registrationState: p.vehicle.registrationState, vin: p.vehicle.vin, name: p.vehicle.name, type: p.vehicle.type, } : undefined, inquiryComplete: !!p.person?.inquiriesCompleted, hasSigned: p.confirmation != null, })), expert: plain.expert ? { decision: plain.expert.decision ? { guiltyPartyId: plain.expert.decision.guiltyPartyId ? String(plain.expert.decision.guiltyPartyId) : undefined, description: plain.expert.decision.description, decidedAt: plain.expert.decision.decidedAt, } : undefined, accidentFields: plain.expert.accidentFields, } : undefined, linkedClaimId: claimPlain ? String(claimPlain._id) : null, ...(claimPlain ? { claimStatus: claimPlain.status, claimWorkflow: claimPlain.workflow, money: claimPlain.money ?? null, fanavaran: fanavaranClaimReferences(claimPlain), fileMakerRejectionCount: claimPlain.fileMakerRejectionCount ?? 0, fileMakerRejectionReason: claimPlain.fileMakerRejectionReason ?? null, evaluation: claimPlain.evaluation ? { damageExpertReply: claimPlain.evaluation.damageExpertReply ?? null, damageExpertReplyFinal: claimPlain.evaluation.damageExpertReplyFinal ?? null, } : null, } : {}), createdAt: plain.createdAt, updatedAt: plain.updatedAt, }; } async getMyCallCenterFilesV6(agent: any): Promise { if (agent?.role !== RoleEnum.CALL_CENTER) { throw new ForbiddenException( "Only call-center agents can use this endpoint.", ); } const agentId = new Types.ObjectId(agent.sub); const files = await this.blameRequestDbService.find({ callCenterInitiated: true, initiatedByCallCenterId: agentId, }); return (files || []).map((f: any) => ({ _id: f._id, publicId: f.publicId, requestNo: f.requestNo, type: f.type, status: f.status, blameStatus: f.blameStatus, workflow: f.workflow, skipInitialFormStep: f.skipInitialFormStep, createdAt: f.createdAt, })); } }