diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index b142f01..2b1fb7c 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -378,7 +378,10 @@ export class ExpertClaimService { return this.blameRequestDbService.findById(id); } - private async assertExpertActorOnClaim(claim: any, actor: any): Promise { + private async assertExpertActorOnClaim( + claim: any, + actor: any, + ): Promise { const blame = await this.loadBlameForClaim(claim); assertClaimCaseForExpertActor(claim, actor, blame); } @@ -2462,9 +2465,15 @@ export class ExpertClaimService { */ async assignClaimForReviewV2( claimRequestId: string, - actor: { sub: string; fullName?: string; clientKey?: string; role?: string }, + actor: { + sub: string; + fullName?: string; + clientKey?: string; + role?: string; + }, ): Promise { - if ((actor as any).role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor); + if ((actor as any).role !== RoleEnum.FIELD_EXPERT) + requireActorClientKey(actor); await this.expireClaimWorkflowLockV2IfStale(claimRequestId); const claim = await this.claimCaseDbService.findById(claimRequestId); @@ -3003,7 +3012,9 @@ export class ExpertClaimService { if (priceCap !== null) { let totalPrice = 0; for (const part of reply.parts || []) { - const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0")); + const parsed = this.parsePersianNumber( + String(part.totalPayment ?? "0"), + ); if (!isNaN(parsed)) totalPrice += parsed; } if (totalPrice > priceCap) { @@ -3492,10 +3503,11 @@ export class ExpertClaimService { const clientKey = requireActorClientKey(actor); const expertId = String(actor.sub); const expertOid = new Types.ObjectId(expertId); - const activityEvents = await this.expertFileActivityDbService.findByExpert( - expertId, - ExpertFileKind.CLAIM, - ); + const activityEvents = + await this.expertFileActivityDbService.findByExpert( + expertId, + ExpertFileKind.CLAIM, + ); const activityFileIds = expertPortfolioFileIdsFromActivityEvents(activityEvents); const orClauses: Record[] = [ @@ -3511,7 +3523,10 @@ export class ExpertClaimService { const rows = orClauses.length === 0 ? [] - : await this.claimCaseDbService.find({ $or: orClauses }, { lean: true }); + : await this.claimCaseDbService.find( + { $or: orClauses }, + { lean: true }, + ); const seen = new Set(); for (const doc of rows as Record[]) { const id = String(doc._id ?? ""); @@ -4234,54 +4249,54 @@ export class ExpertClaimService { } // Fall through to the detail build below (skip the damage-expert gate) } else { - const isResendPending = - claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND; + const isResendPending = + claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND; - const assignedForReviewById = String( - (claim.workflow as any)?.assignedForReviewBy?.actorId ?? "", - ); - const lockEnforced = this.isClaimV2WorkflowLockCurrentlyEnforced(claim); - const lockedById = String(claim.workflow?.lockedBy?.actorId ?? ""); - const isAssignedToMe = - !!assignedForReviewById && assignedForReviewById === actorId; - - // Gate: must satisfy at least one bucket - if ( - !isDamageExpertPhase && - !isFactorValidationPending && - !isResendPending && - !isAssignedToMe - ) { - throw new ForbiddenException( - `This claim is not available for expert review. Current status: ${claim.status}`, + const assignedForReviewById = String( + (claim.workflow as any)?.assignedForReviewBy?.actorId ?? "", ); - } + const lockEnforced = this.isClaimV2WorkflowLockCurrentlyEnforced(claim); + const lockedById = String(claim.workflow?.lockedBy?.actorId ?? ""); + const isAssignedToMe = + !!assignedForReviewById && assignedForReviewById === actorId; - const isAvailable = - isDamageExpertPhase && !assignedForReviewById && !lockEnforced; - const isLockedByMe = lockEnforced && lockedById === actorId; - const isFactorValidationOpen = isFactorValidationPending; - - if ( - !isAvailable && - !isAssignedToMe && - !isLockedByMe && - !isFactorValidationOpen - ) { - if (lockEnforced && lockedById && lockedById !== actorId) { + // Gate: must satisfy at least one bucket + if ( + !isDamageExpertPhase && + !isFactorValidationPending && + !isResendPending && + !isAssignedToMe + ) { throw new ForbiddenException( - `This claim is locked by another expert: ${claim.workflow?.lockedBy?.actorName}`, + `This claim is not available for expert review. Current status: ${claim.status}`, ); } - if (assignedForReviewById && assignedForReviewById !== actorId) { + + const isAvailable = + isDamageExpertPhase && !assignedForReviewById && !lockEnforced; + const isLockedByMe = lockEnforced && lockedById === actorId; + const isFactorValidationOpen = isFactorValidationPending; + + if ( + !isAvailable && + !isAssignedToMe && + !isLockedByMe && + !isFactorValidationOpen + ) { + if (lockEnforced && lockedById && lockedById !== actorId) { + throw new ForbiddenException( + `This claim is locked by another expert: ${claim.workflow?.lockedBy?.actorName}`, + ); + } + if (assignedForReviewById && assignedForReviewById !== actorId) { + throw new ForbiddenException( + "This claim has been assigned to another expert.", + ); + } throw new ForbiddenException( - "This claim has been assigned to another expert.", + "You do not have permission to view this claim.", ); } - throw new ForbiddenException( - "You do not have permission to view this claim.", - ); - } } // end damage-expert gate (else block) // Build requiredDocuments map @@ -4513,9 +4528,10 @@ export class ExpertClaimService { carAngles, damagedParts, awaitingFactorValidation: isFactorValidationPending, - evaluation: evaluationForApi as - | ClaimDetailV2ResponseDto["evaluation"] - | undefined, + evaluation: + (evaluationForApi as + | ClaimDetailV2ResponseDto["evaluation"] + | undefined) || (enrichedEvaluation as any), videoCapture, blameCase: blameCaseForApi, blameExpertDecision, diff --git a/src/request-management/entities/db-service/blame-request.db.service.ts b/src/request-management/entities/db-service/blame-request.db.service.ts index 3c5cccf..c5de222 100644 --- a/src/request-management/entities/db-service/blame-request.db.service.ts +++ b/src/request-management/entities/db-service/blame-request.db.service.ts @@ -23,7 +23,9 @@ export class BlameRequestDbService { return this.blameRequestModel.findOne(filter); } - async findById(id: string | Types.ObjectId): Promise { + async findById( + id: string | Types.ObjectId, + ): Promise { return this.blameRequestModel.findById(id); } @@ -72,5 +74,11 @@ export class BlameRequestDbService { : this.blameRequestModel.find(filter); return query.exec() as Promise; } -} + async deleteMany( + filter: FilterQuery, + ): Promise<{ deletedCount: number }> { + const result = await this.blameRequestModel.deleteMany(filter); + return { deletedCount: result.deletedCount }; + } +} diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 8d95208..bcb4bef 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -41,9 +41,7 @@ import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatu 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, -} from "./dto/run-inquiries-v3.dto"; +import { RunInquiriesV3Dto } 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"; @@ -4369,6 +4367,17 @@ export class RequestManagementService { // 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 created = await this.blameRequestDbService.create({ publicId, requestNo: publicId, @@ -4431,7 +4440,12 @@ export class RequestManagementService { dto: { phoneNumber: string }, ): Promise<{ sent: boolean; - sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: string }[]; + sentTo: { + role: string; + phoneNumber: string; + smsSent: boolean; + linkUrl: string; + }[]; }> { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); @@ -4462,16 +4476,29 @@ export class RequestManagementService { req.parties[firstIdx].person.userId = userId; const expertName = `${expert?.lastName || ""}`.trim() || "کارشناس"; - const sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: string }[] = []; + const sentTo: { + role: string; + phoneNumber: string; + smsSent: boolean; + linkUrl: string; + }[] = []; - const firstLink = this.smsOrchestrationService.buildBlamePartyLink(requestId, "FIRST"); + const firstLink = this.smsOrchestrationService.buildBlamePartyLink( + requestId, + "FIRST", + ); 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 }); + 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({ @@ -5087,7 +5114,9 @@ export class RequestManagementService { // 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)), + ...(linkedUserIds.length > 0 + ? linkedUserIds + : [user?.sub].filter(Boolean)), ); if (idVariants.length > 0) { const snapshotFilter = @@ -5436,7 +5465,9 @@ export class RequestManagementService { const phoneMatch = person.phoneNumber && phoneVariants.some( - (v) => v === person.phoneNumber || v === normalizeIranMobile(person.phoneNumber), + (v) => + v === person.phoneNumber || + v === normalizeIranMobile(person.phoneNumber), ); if (phoneMatch && !person?.userId) { p.person = p.person || {}; @@ -5661,7 +5692,8 @@ export class RequestManagementService { role: PartyRole.FIRST, person: { userId: firstPartyUserId, - phoneNumber: normalizeIranMobile(formData.firstPartyPhoneNumber) ?? + phoneNumber: + normalizeIranMobile(formData.firstPartyPhoneNumber) ?? formData.firstPartyPhoneNumber, clientId: (client as any)?._id ?? (client as any)?._doc?._id, nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, @@ -6317,10 +6349,7 @@ export class RequestManagementService { ? req.workflow.completedSteps : []; const currentStep = req.workflow?.currentStep as WorkflowStep | undefined; - if ( - currentStep && - !completed.includes(currentStep) - ) { + if (currentStep && !completed.includes(currentStep)) { completed.push(currentStep); } if (!completed.includes(WorkflowStep.WAITING_FOR_GUILT_DECISION)) { @@ -6338,7 +6367,8 @@ export class RequestManagementService { type: "ACCIDENT_FIELDS_SAVED_ADVANCED_TO_SIGNATURES", actor: { actorId: new Types.ObjectId(String(expert.sub)), - actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), + actorName: + `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "field_expert", }, metadata: { @@ -7903,7 +7933,9 @@ export class RequestManagementService { 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."); + throw new BadRequestException( + "V3 flow is only for expert-initiated files.", + ); } } @@ -7982,7 +8014,8 @@ export class RequestManagementService { ): Promise<{ clientId?: string }> { const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND"; let clientId: string | undefined; - const inquiryOptions = (cid?: string) => (cid ? { clientId: cid } : undefined); + const inquiryOptions = (cid?: string) => + cid ? { clientId: cid } : undefined; let inquiryRaw: any; let inquiryMapped: any; @@ -8005,7 +8038,14 @@ export class RequestManagementService { this.logger.error( `[V3] plate inquiry failed for ${roleLabel} party (request=${req._id}): ${err?.message || err}`, ); - this.recordPartyCaseInquiryStatus(req, "thirdParty", partyRole, false, {}, err); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + partyRole, + false, + {}, + err, + ); throw new BadRequestException( `${roleLabel} party plate inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, ); @@ -8046,8 +8086,10 @@ export class RequestManagementService { 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.insurerLicense) + party.person.insurerLicense = partyData.insurerLicense; + if (partyData.driverLicense) + party.person.driverLicense = partyData.driverLicense; if (!party.vehicle) party.vehicle = {} as any; party.vehicle.plateId = this.plateToPlateIdString(partyData.plate); @@ -8060,13 +8102,16 @@ export class RequestManagementService { }; if (!party.insurance) party.insurance = {} as any; - party.insurance.policyNumber = inquiryMapped?.LastCompanyDocumentNumber; + party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo; party.insurance.company = clientName; party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; party.insurance.startDate = inquiryMapped?.IssueDate; party.insurance.endDate = inquiryMapped?.EndDate; - if (req.type === BlameRequestType.CAR_BODY && partyRole === PartyRole.FIRST) { + if ( + req.type === BlameRequestType.CAR_BODY && + partyRole === PartyRole.FIRST + ) { try { const carBodyInfo = await this.sandHubService.getTejaratCarBodyInquiry( { @@ -8097,11 +8142,13 @@ export class RequestManagementService { const cbCompanyCode = m.companyId ?? m.CompanyCode; const cbCompanyName = m.CompanyName ?? m.companyPersianName; if (cbCompanyCode && cbCompanyName) { - const cbClient = await this.clientService.findOrCreateClientByCompanyCode( - Number(cbCompanyCode), - String(cbCompanyName), - ); - const cbClientId = (cbClient as any)?._id ?? (cbClient as any)?._doc?._id; + const cbClient = + await this.clientService.findOrCreateClientByCompanyCode( + Number(cbCompanyCode), + String(cbCompanyName), + ); + const cbClientId = + (cbClient as any)?._id ?? (cbClient as any)?._doc?._id; if (cbClientId) { party.person.clientId = cbClientId; clientId = String(cbClientId); @@ -8111,7 +8158,14 @@ export class RequestManagementService { this.logger.error( `[V3] car body inquiry failed for request=${req._id}: ${err?.message || err}`, ); - this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, false, {}, err); + this.recordPartyCaseInquiryStatus( + req, + "carBody", + partyRole, + false, + {}, + err, + ); throw new BadRequestException( `CAR_BODY inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, ); @@ -8128,12 +8182,25 @@ export class RequestManagementService { birthDate as string | number, clientId ? { clientId } : undefined, ); - this.recordPartyCaseInquiryStatus(req, "person", partyRole, true, personalInquiry); + this.recordPartyCaseInquiryStatus( + req, + "person", + partyRole, + true, + personalInquiry, + ); } catch (err: any) { this.logger.error( `[V3] personal inquiry failed for ${roleLabel} party request=${req._id}: ${err?.message || err}`, ); - this.recordPartyCaseInquiryStatus(req, "person", partyRole, false, {}, err); + this.recordPartyCaseInquiryStatus( + req, + "person", + partyRole, + false, + {}, + err, + ); throw new BadRequestException( `${roleLabel} party personal identity inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, ); @@ -8146,34 +8213,45 @@ export class RequestManagementService { const licenseNumber = partyData.driverIsInsurer ? partyData.insurerLicense : partyData.driverLicense; - if (!licenseNationalCode || !licenseNumber) { - throw new BadRequestException( - `${roleLabel} party driving license inquiry requires national code and ${ - partyData.driverIsInsurer ? "insurerLicense" : "driverLicense" - }.`, - ); - } - try { - const licenseInquiry = await this.sandHubService.getDrivingLicenseInfo( - licenseNationalCode, - licenseNumber, - clientId ? { clientId } : undefined, - ); + + if (!licenseNumber) { 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); - throw new BadRequestException( - `${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, + 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, + ); + throw new BadRequestException( + `${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, + ); + } } return { clientId }; @@ -8265,7 +8343,9 @@ export class RequestManagementService { payingClientId = ownerFields.clientId ?? guiltyClientId?.toString(); } else { if (!guiltyUserId) { - throw new BadRequestException("First party must be verified before creating claim."); + throw new BadRequestException( + "First party must be verified before creating claim.", + ); } ownerUserId = String(guiltyUserId); ownerRole = PartyRole.FIRST; @@ -8314,7 +8394,8 @@ export class RequestManagementService { type: "CLAIM_CREATED", actor: { actorId: new Types.ObjectId(actor.sub), - actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + actorName: + `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), actorType: "field_expert", }, timestamp: new Date(), @@ -8377,7 +8458,9 @@ export class RequestManagementService { } const payingClientId = - guiltyPerson?.clientId != null ? String(guiltyPerson.clientId) : undefined; + guiltyPerson?.clientId != null + ? String(guiltyPerson.clientId) + : undefined; return { userId: damagedUserId, @@ -8458,7 +8541,8 @@ export class RequestManagementService { if (partyRole === PartyRole.FIRST) { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); - if (firstIdx === -1) throw new BadRequestException("First party not found"); + if (firstIdx === -1) + throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; if (!firstParty?.person?.userId) { throw new BadRequestException( @@ -8474,9 +8558,17 @@ export class RequestManagementService { } } - await this.runPartyInquiriesV3Internal(req, dto, PartyRole.FIRST, firstParty); + await this.runPartyInquiriesV3Internal( + req, + dto, + PartyRole.FIRST, + firstParty, + ); if (req.type === BlameRequestType.CAR_BODY) { - await this.validateShebaV3(dto, firstParty.person?.clientId?.toString()); + await this.validateShebaV3( + dto, + firstParty.person?.clientId?.toString(), + ); } this.markPartyInquiriesCompleteOnBlame(req, PartyRole.FIRST, actor); @@ -8530,7 +8622,12 @@ export class RequestManagementService { ); } - await this.runPartyInquiriesV3Internal(req, dto, PartyRole.SECOND, secondParty); + await this.runPartyInquiriesV3Internal( + req, + dto, + PartyRole.SECOND, + secondParty, + ); await this.validateShebaV3(dto, secondParty.person?.clientId?.toString()); this.ensureV3GuiltyPartyDecision(req); @@ -8581,7 +8678,9 @@ export class RequestManagementService { 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); + 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.", @@ -8632,7 +8731,9 @@ export class RequestManagementService { const partyIndex = this.getPartyIndex(req, partyRole); if (partyIndex === -1) { - throw new BadRequestException(`Party ${partyRole} not found on this request`); + throw new BadRequestException( + `Party ${partyRole} not found on this request`, + ); } if ( req.type === BlameRequestType.CAR_BODY && @@ -8813,7 +8914,9 @@ export class RequestManagementService { } const requiredSigs = req.type === BlameRequestType.CAR_BODY ? 1 : 2; - const signed = (req.parties ?? []).filter((p: any) => p?.confirmation != null); + 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.", @@ -8834,7 +8937,8 @@ export class RequestManagementService { ); } if ( - claim.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS + claim.workflow?.currentStep !== + ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS ) { throw new BadRequestException( "Complete capture-part (parts, angles, and capture-phase documents) before the blame accident video.", @@ -8894,7 +8998,8 @@ export class RequestManagementService { type: "STEP_COMPLETED", actor: { actorId: new Types.ObjectId(expert.sub), - actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), + actorName: + `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "field_expert", }, timestamp: new Date(), @@ -9091,7 +9196,9 @@ export class RequestManagementService { 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."); + throw new BadRequestException( + "CAR_BODY accident type is only for CAR_BODY files.", + ); } this.verifyExpertAccessForBlameV2(req, actor); this.assertBlameV3ExpertInPerson(req);