From 8419ec06ae2953529d28b8d1b4ee63363ee9b383 Mon Sep 17 00:00:00 2001 From: "s.yahyaee" Date: Fri, 1 May 2026 11:14:05 +0330 Subject: [PATCH 1/3] YARA-867 --- .../claim-request-management.v2.controller.ts | 2 +- src/expert-claim/dto/claim-detail-v2.dto.ts | 10 +++ src/expert-claim/expert-claim.service.ts | 68 ++++++++++++++++--- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts index 4a315b1..718ca76 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -85,7 +85,7 @@ export class ClaimRequestManagementV2Controller { @ApiOperation({ summary: "Get Claim Details (V2)", description: - "Get claim details for current actor. USER receives only minimal own-needed payload (no extra owner/security fields). FIELD_EXPERT keeps full view for expert workflows.", + "Get claim details for current actor. USER (claim owner) receives the same core claim payload including `owner` (userId, clientId, optional userClientKey). FIELD_EXPERT additionally receives unmasked money and userRating when present.", }) @ApiResponse({ status: 200, diff --git a/src/expert-claim/dto/claim-detail-v2.dto.ts b/src/expert-claim/dto/claim-detail-v2.dto.ts index 45f9caf..b02b672 100644 --- a/src/expert-claim/dto/claim-detail-v2.dto.ts +++ b/src/expert-claim/dto/claim-detail-v2.dto.ts @@ -99,6 +99,16 @@ export class ClaimDetailV2ResponseDto { damageExpertReplyFinal?: unknown; }; + @ApiPropertyOptional({ + description: + 'Owner objection to expert-priced parts (`evaluation.objection`): disputed lines + optional new parts + submission time.', + }) + objection?: { + objectionParts?: Array>; + newParts?: Array>; + submittedAt?: Date | string; + }; + @ApiPropertyOptional({ description: 'Car walk-around video from `claim-video-capture` (resolved from `media.videoCaptureId`).', diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index b65a951..d6d9c8f 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -2723,6 +2723,11 @@ export class ExpertClaimService { status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, 'workflow.locked': { $ne: true }, }, + // Expert reviewing but lock cleared (e.g. TTL expiry) — still list until status is reconciled + { + status: ClaimCaseStatus.EXPERT_REVIEWING, + 'workflow.locked': { $ne: true }, + }, // This expert's own locked/in-progress claims { 'workflow.locked': true, @@ -2919,6 +2924,34 @@ export class ExpertClaimService { return docRec; } + /** Strip bulky insurer-inquiry payloads (`raw` / `mapped`) from vehicle.inquiry. */ + private sanitizeVehicleInquiryForApi(vehicle: any) { + if (!vehicle || typeof vehicle !== "object") return vehicle; + const inquiry = vehicle.inquiry; + if (!inquiry || typeof inquiry !== "object") return vehicle; + const { raw, mapped, ...safeInquiry } = inquiry as Record; + return { + ...vehicle, + inquiry: safeInquiry, + }; + } + + /** Linked blame snapshot for damage-expert detail: keep API response lean. */ + private sanitizeBlameCaseForClaimDetailApi( + blame: Record, + ): Record { + const out = { ...blame }; + const parties = out.parties; + if (Array.isArray(parties)) { + out.parties = parties.map((p: any) => + p && typeof p === "object" + ? { ...p, vehicle: this.sanitizeVehicleInquiryForApi(p.vehicle) } + : p, + ); + } + return out; + } + /** JSON-safe `expert.decision` for API responses (ObjectIds → string). */ private serializeBlameExpertDecisionForClaimDetail( decision: unknown, @@ -3025,8 +3058,9 @@ export class ExpertClaimService { /** * Persist unlock when claim V2 workflow lock TTL has passed. - * If the expert never submitted a damage reply, restores queue fields from - * `workflow.preLockQueueSnapshot` (or defaults) so the case shows as WAITING_FOR_DAMAGE_EXPERT again. + * When the case was in {@link ClaimCaseStatus.EXPERT_REVIEWING}, restores queue fields from + * `workflow.preLockQueueSnapshot` (or defaults) and sets status back to WAITING_FOR_DAMAGE_EXPERT + * so the claim reappears in the open expert queue (same intent as blame list expiry + in-memory unlock). */ private async expireClaimWorkflowLockV2IfStale( claimRequestId: string, @@ -3047,7 +3081,6 @@ export class ExpertClaimService { ) { return false; } - if (!expiredAt && !lockedAt) return false; const lockedById = claim.workflow.lockedBy?.actorId; const filter: Record = { @@ -3059,12 +3092,10 @@ export class ExpertClaimService { } else if (lockedAt) { filter["workflow.lockedAt"] = lockedAt; } else if (lockedById) { - filter["workflow.lockedBy.actorId"] = lockedById; + filter["workflow.lockedBy.actorId"] = new Types.ObjectId(String(lockedById)); } - const needsQueueRestore = - claim.status === ClaimCaseStatus.EXPERT_REVIEWING && - !this.claimHasDamageExpertReply(claim); + const needsQueueRestore = claim.status === ClaimCaseStatus.EXPERT_REVIEWING; const snap = claim.workflow?.preLockQueueSnapshot as | { @@ -3136,6 +3167,7 @@ export class ExpertClaimService { actor: any, ): Promise { const actorId = actor.sub; + await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor); await this.expireClaimWorkflowLockV2IfStale(claimRequestId); const claim = await this.claimCaseDbService.findById(claimRequestId); @@ -3319,6 +3351,21 @@ export class ExpertClaimService { ? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT : claim.status; + const objection = (claim as any).evaluation?.objection + ? { + objectionParts: + (claim as any).evaluation.objection.objectionParts ?? [], + newParts: (claim as any).evaluation.objection.newParts ?? [], + submittedAt: (claim as any).evaluation.objection.submittedAt, + } + : undefined; + + const blameCaseForApi = blameCase + ? this.sanitizeBlameCaseForClaimDetailApi( + blameCase as Record, + ) + : undefined; + return { claimRequestId: claim._id.toString(), publicId: claim.publicId, @@ -3341,7 +3388,9 @@ export class ExpertClaimService { fullName: claim.owner.fullName, } : undefined, - vehicle: vehiclePayload, + vehicle: vehiclePayload + ? this.sanitizeVehicleInquiryForApi(vehiclePayload) + : undefined, ...blameFileContext, blameRequestId: claim.blameRequestId?.toString(), blameRequestNo: claim.blameRequestNo, @@ -3361,8 +3410,9 @@ export class ExpertClaimService { damageExpertReplyFinal: (claim as any).evaluation?.damageExpertReplyFinal, } : undefined, + objection, videoCapture, - blameCase: blameCase ?? undefined, + blameCase: blameCaseForApi, blameExpertDecision, createdAt: (claim as any).createdAt, updatedAt: (claim as any).updatedAt, From 6f120b006681ac0ddfc17fe49b32a4d2ba886b59 Mon Sep 17 00:00:00 2001 From: "s.yahyaee" Date: Fri, 1 May 2026 11:25:10 +0330 Subject: [PATCH 2/3] YARA-868 --- .../claim-request-management.service.ts | 55 ++++++++++++++++++- .../claim-request-management.v2.controller.ts | 14 ++++- .../schema/claim-case.evaluation.schema.ts | 4 ++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index ea6a7a2..228c722 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -2805,6 +2805,27 @@ export class ClaimRequestManagementService { }, 0); } + /** Branch ids referenced on expert-priced parts (`daghi` object with `branchId`). */ + private collectBranchIdsFromClaimExpertReply(reply: { + parts?: unknown[]; + }): Set { + const ids = new Set(); + const parts = reply?.parts; + if (!Array.isArray(parts)) { + return ids; + } + for (const p of parts) { + const d = (p as { daghi?: unknown })?.daghi; + if (d && typeof d === "object" && d !== null && "branchId" in d) { + const bid = (d as { branchId?: unknown }).branchId; + if (bid != null && Types.ObjectId.isValid(String(bid))) { + ids.add(String(bid)); + } + } + } + return ids; + } + /** * Fanavaran Submit - Map data from claim-request-management and request-management to third-party-car-financial-claims format */ @@ -5139,6 +5160,7 @@ export class ClaimRequestManagementService { async submitOwnerInsurerApprovalSignV2( claimRequestId: string, agree: boolean, + branchId: string, signFile: Express.Multer.File, currentUserId: string, actor?: { sub: string; role?: string; fullName?: string }, @@ -5200,6 +5222,34 @@ export class ClaimRequestManagementService { throw new BadRequestException("No expert reply is available to sign off on yet."); } + const rawBranchId = (branchId ?? "").trim(); + if (!rawBranchId || !Types.ObjectId.isValid(rawBranchId)) { + throw new BadRequestException( + "branchId is required and must be a valid MongoDB ObjectId.", + ); + } + const branchDoc = await this.branchDbService.findById(rawBranchId); + if (!branchDoc) { + throw new NotFoundException(`Branch with ID ${rawBranchId} not found.`); + } + const ownerClientId = claimCase.owner?.clientId; + if (!ownerClientId || String(branchDoc.clientKey) !== String(ownerClientId)) { + throw new ForbiddenException( + "This branch does not belong to the insurer for this claim.", + ); + } + const branchIdsOnPricing = this.collectBranchIdsFromClaimExpertReply( + activeReply as { parts?: unknown[] }, + ); + if ( + branchIdsOnPricing.size > 0 && + !branchIdsOnPricing.has(String(rawBranchId)) + ) { + throw new BadRequestException( + "branchId must match a branch used in the expert pricing for this claim.", + ); + } + const signDoc = await this.claimSignDbService.create({ fileName: signFile.filename, userId: effectiveUserId, @@ -5210,6 +5260,7 @@ export class ClaimRequestManagementService { const signedAt = new Date(); const approvalPayload = { agree, + branchId: new Types.ObjectId(rawBranchId), signDetailId: signId, signedAt, }; @@ -5233,7 +5284,7 @@ export class ClaimRequestManagementService { type: "OWNER_REJECTED_INSURER_APPROVAL_PRICING", actor: historyActor, timestamp: signedAt, - metadata: {}, + metadata: { branchId: rawBranchId }, }, }, }); @@ -5262,7 +5313,7 @@ export class ClaimRequestManagementService { type: "OWNER_SIGNED_INSURER_APPROVAL", actor: historyActor, timestamp: signedAt, - metadata: {}, + metadata: { branchId: rawBranchId }, }, }, }); diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts index 718ca76..78b650a 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -245,21 +245,27 @@ export class ClaimRequestManagementV2Controller { @ApiOperation({ summary: "Sign final claim pricing (owner)", description: - "Multipart: `sign` (signature image) and `agree` (boolean). Allowed only when `status` is WAITING_FOR_INSURER_APPROVAL, " + + "Multipart: `sign` (signature image), `agree` (boolean), and `branchId` (Mongo ObjectId of the insurer branch the file is reviewed under). " + + "Allowed only when `status` is WAITING_FOR_INSURER_APPROVAL, " + "`workflow.currentStep` is INSURER_REVIEW (not EXPERT_COST_EVALUATION), and `claimStatus` is APPROVED. " + "If `agree` is true, the case moves to COMPLETED; if false, to REJECTED.", }) @ApiBody({ - description: "Signature file and agreement", + description: "Signature file, agreement, and branch", schema: { type: "object", - required: ["sign", "agree"], + required: ["sign", "agree", "branchId"], properties: { sign: { type: "string", format: "binary", description: "Signature image" }, agree: { type: "boolean", description: "true to accept expert pricing and complete the claim", }, + branchId: { + type: "string", + description: "Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)", + example: "507f1f77bcf86cd799439011", + }, }, }, }) @@ -285,6 +291,7 @@ export class ClaimRequestManagementV2Controller { async submitOwnerInsurerApprovalSignV2( @Param("claimRequestId") claimRequestId: string, @Body("agree") agree: string | boolean, + @Body("branchId") branchId: string, @CurrentUser() user: any, @UploadedFile() sign: Express.Multer.File, ) { @@ -297,6 +304,7 @@ export class ClaimRequestManagementV2Controller { return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2( claimRequestId, agreed, + typeof branchId === "string" ? branchId : "", sign, user.sub, user, diff --git a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts index 770aea6..5e58798 100644 --- a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts @@ -82,6 +82,10 @@ export class ClaimOwnerInsurerApproval { @Prop({ type: Boolean, required: true }) agree: boolean; + /** Branch the owner is signing for (must belong to their insurer; aligned with expert daghi options when present). */ + @Prop({ type: Types.ObjectId }) + branchId?: Types.ObjectId; + @Prop({ type: Types.ObjectId }) signDetailId?: Types.ObjectId; From 6429cb0f2b1b9ec4c513a40502dc9a95da4ab2a9 Mon Sep 17 00:00:00 2001 From: "s.yahyaee" Date: Fri, 1 May 2026 11:37:24 +0330 Subject: [PATCH 3/3] Fix bugs --- src/expert-claim/expert-claim.service.ts | 126 ++++++++++++++++------- 1 file changed, 87 insertions(+), 39 deletions(-) diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index d6d9c8f..d1c3b3a 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -245,6 +245,24 @@ export class ExpertClaimService { return String(actor.clientKey ?? ""); } + /** + * `preLockQueueSnapshot` must only store {@link ClaimWorkflowStep} values. + * Legacy / mistaken data sometimes put {@link ClaimCaseStatus} (e.g. WAITING_FOR_DAMAGE_EXPERT) + * in `workflow.currentStep`, which would fail Mongoose enum validation when snapshotted. + */ + private normalizeClaimWorkflowStepForSnapshot( + value: unknown, + fallback: ClaimWorkflowStep, + ): ClaimWorkflowStep { + if ( + typeof value === "string" && + (Object.values(ClaimWorkflowStep) as string[]).includes(value) + ) { + return value as ClaimWorkflowStep; + } + return fallback; + } + /** Owner mobile: linked blame party phone when available, else `users.mobile`. */ private async resolveClaimOwnerPhone(claim: any): Promise { if (!claim?.owner?.userId) return undefined; @@ -2226,20 +2244,30 @@ export class ExpertClaimService { }, 'workflow.preLockQueueSnapshot': { claimStatus: claim.claimStatus, - currentStep: - claim.workflow?.currentStep ?? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + currentStep: this.normalizeClaimWorkflowStepForSnapshot( + claim.workflow?.currentStep, + ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + ), ...(claim.workflow?.nextStep != null - ? { nextStep: claim.workflow.nextStep } + ? { + nextStep: this.normalizeClaimWorkflowStepForSnapshot( + claim.workflow.nextStep, + ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + ), + } : {}), }, 'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, $push: { history: { - event: 'CLAIM_LOCKED', - performedBy: actor.sub, - performedByName: actor.fullName, - performedAt: new Date(), - note: `Claim locked by damage expert ${actor.fullName}`, + type: "CLAIM_LOCKED", + actor: { + actorId: new Types.ObjectId(actor.sub), + actorName: actor.fullName, + actorType: "damage_expert", + }, + timestamp: new Date(), + metadata: { note: `Claim locked by damage expert ${actor.fullName}` }, }, }, }); @@ -2657,11 +2685,16 @@ export class ExpertClaimService { }), $push: { history: { - event: 'IN_PERSON_VISIT_REQUESTED', - performedBy: actor.sub, - performedByName: actor.fullName, - performedAt: new Date(), - note: note || 'Expert requested in-person visit', + type: "IN_PERSON_VISIT_REQUESTED", + actor: { + actorId: new Types.ObjectId(actor.sub), + actorName: actor.fullName, + actorType: "damage_expert", + }, + timestamp: new Date(), + metadata: { + note: note || "Expert requested in-person visit", + }, }, }, }); @@ -2678,7 +2711,7 @@ export class ExpertClaimService { claimRequestId, status: claim.status, claimStatus: ClaimStatus.NEEDS_REVISION, - message: 'In-person visit requested. User will be notified.', + message: "In-person visit requested. User will be notified.", }; } @@ -3106,11 +3139,16 @@ export class ExpertClaimService { | undefined; const restoreClaimStatus = snap?.claimStatus ?? ClaimStatus.PENDING; - const restoreCurrent = - snap?.currentStep ?? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE; + const restoreCurrent = this.normalizeClaimWorkflowStepForSnapshot( + snap?.currentStep, + ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + ); const restoreNext = snap?.nextStep != null - ? snap.nextStep + ? this.normalizeClaimWorkflowStepForSnapshot( + snap.nextStep, + ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + ) : ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; const lockFieldsUnset = { @@ -3455,42 +3493,52 @@ export class ExpertClaimService { : []; const selectedParts = body.selectedParts as string[]; - if (!(claim as any).damage) (claim as any).damage = {}; - (claim as any).damage.selectedParts = selectedParts; + const $set: Record = { + "damage.selectedParts": selectedParts, + }; - // Keep damaged-parts captures consistent with updated selection. const damagedPartsMedia = (claim as any).media?.damagedParts; if (damagedPartsMedia) { const selectedSet = new Set(selectedParts); if (damagedPartsMedia instanceof Map) { - for (const key of Array.from(damagedPartsMedia.keys())) { - if (!selectedSet.has(key)) damagedPartsMedia.delete(key); + const m = new Map(damagedPartsMedia as Map); + for (const key of Array.from(m.keys())) { + if (!selectedSet.has(key)) m.delete(key); } + $set["media.damagedParts"] = Object.fromEntries(m.entries()); } else { - for (const key of Object.keys(damagedPartsMedia)) { - if (!selectedSet.has(key)) delete damagedPartsMedia[key]; + const o = { + ...(damagedPartsMedia as Record), + }; + for (const key of Object.keys(o)) { + if (!selectedSet.has(key)) delete o[key]; } + $set["media.damagedParts"] = o; } } - if (!Array.isArray((claim as any).history)) (claim as any).history = []; - (claim as any).history.push({ - event: "EXPERT_DAMAGED_PARTS_UPDATED", - performedBy: actor.sub, - performedByName: actor.fullName, - performedAt: new Date(), - note: "Damage expert edited selected damaged parts", - metadata: { - previousSelectedParts: previous, - selectedParts, - ...(damagedPartsEditSnapshot && { - expertProfileSnapshot: damagedPartsEditSnapshot, - }), + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + $set, + $push: { + history: { + type: "EXPERT_DAMAGED_PARTS_UPDATED", + actor: { + actorId: new Types.ObjectId(actor.sub), + actorName: actor.fullName, + actorType: "damage_expert", + }, + timestamp: new Date(), + metadata: { + previousSelectedParts: previous, + selectedParts, + ...(damagedPartsEditSnapshot && { + expertProfileSnapshot: damagedPartsEditSnapshot, + }), + }, + }, }, }); - await (claim as any).save(); - return { claimRequestId: claim._id.toString(), selectedParts,