From 6c2d1786863420c81f12951956bd46bb429e4a6a Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 26 Apr 2026 09:16:58 +0330 Subject: [PATCH 1/3] YARA-830 --- src/expert-blame/dto/all-request.dto.ts | 3 +-- src/expert-blame/expert-blame.service.ts | 24 ++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/expert-blame/dto/all-request.dto.ts b/src/expert-blame/dto/all-request.dto.ts index 0123123..76ff171 100644 --- a/src/expert-blame/dto/all-request.dto.ts +++ b/src/expert-blame/dto/all-request.dto.ts @@ -117,8 +117,7 @@ export class AllRequestDtoV2 { type: string; blameStatus: string; partiesInitialForms: { firstParty: string; secondParty: string }; - firstPartyVehicle: string; - secondPartyVehicle?: string; + partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: string }; } export class AllRequestDtoRsV2 { diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 2a57914..414df9d 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -352,6 +352,15 @@ export class ExpertBlameService { const workflow = (doc.workflow ?? {}) as Record; const parties = (doc.parties ?? []) as Array<{ role?: string; + vehicle?: { + name?: string; + inquiry?: { + mapped?: { + MapTypNam?: string; + CarName?: string; + }; + }; + }; statement?: { admitsGuilt?: boolean; claimsDamage?: boolean; @@ -381,8 +390,19 @@ export class ExpertBlameService { firstParty: statementToFormKey(firstParty?.statement) ?? "", secondParty: statementToFormKey(secondParty?.statement) ?? "", }, - firstPartyVehicle: doc.parties[0]?.person?.vehicle?.name, - secondPartyVehicle: doc.parties[1]?.person?.vehicle?.name, + partiesVehicles: { + firstPartyVehicle: + firstParty?.vehicle?.name || + firstParty?.vehicle?.inquiry?.mapped?.MapTypNam || + firstParty?.vehicle?.inquiry?.mapped?.CarName || + "", + secondPartyVehicle: + secondParty?.vehicle?.name || + secondParty?.vehicle?.inquiry?.mapped?.MapTypNam || + secondParty?.vehicle?.inquiry?.mapped?.CarName || + "", + } + }; } From 4f8cb438830c1027c8913e5e9ad3efd12cdff50c Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 26 Apr 2026 09:31:50 +0330 Subject: [PATCH 2/3] YARA-832 --- .../schema/claim-case.workflow.schema.ts | 4 ++++ src/expert-blame/expert-blame.service.ts | 13 ++++++++++++- src/expert-claim/dto/claim-detail-v2.dto.ts | 1 + src/expert-claim/expert-claim.service.ts | 17 ++++++++++++++++- .../entities/schema/workflow.type.ts | 4 ++++ 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts b/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts index 5fdca80..1eb09ba 100644 --- a/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts @@ -40,6 +40,10 @@ export class ClaimWorkflow { @Prop({ type: Date }) lockedAt?: Date; + /** Lock expiry used by UI countdown (typically lockedAt + 15m). */ + @Prop({ type: Date }) + expiredAt?: Date; + @Prop({ type: ClaimActorLockSchema }) lockedBy?: ClaimActorLock; } diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 414df9d..798b959 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -325,6 +325,7 @@ export class ExpertBlameService { if (w) { w.locked = false; delete w.lockedAt; + delete w.expiredAt; delete w.lockedBy; } } @@ -444,7 +445,11 @@ export class ExpertBlameService { filter as any, { $set: { "workflow.locked": false }, - $unset: { "workflow.lockedAt": "", "workflow.lockedBy": "" }, + $unset: { + "workflow.lockedAt": "", + "workflow.expiredAt": "", + "workflow.lockedBy": "", + }, }, { new: false }, ); @@ -953,6 +958,9 @@ export class ExpertBlameService { $set: { "workflow.locked": true, "workflow.lockedAt": now, + "workflow.expiredAt": new Date( + now.getTime() + this.blameV2LockTtlMs, + ), "workflow.lockedBy": { actorId: new Types.ObjectId(actorDetail.sub), actorName: actorDetail.fullName || "Unknown Expert", @@ -1125,6 +1133,7 @@ export class ExpertBlameService { }, $unset: { "workflow.lockedAt": "", + "workflow.expiredAt": "", "workflow.lockedBy": "", }, }; @@ -1308,6 +1317,7 @@ export class ExpertBlameService { }, $unset: { "workflow.lockedAt": "", + "workflow.expiredAt": "", "workflow.lockedBy": "", }, }; @@ -1450,6 +1460,7 @@ export class ExpertBlameService { }, $unset: { "workflow.lockedAt": "", + "workflow.expiredAt": "", "workflow.lockedBy": "", }, }; diff --git a/src/expert-claim/dto/claim-detail-v2.dto.ts b/src/expert-claim/dto/claim-detail-v2.dto.ts index 26951b3..45f9caf 100644 --- a/src/expert-claim/dto/claim-detail-v2.dto.ts +++ b/src/expert-claim/dto/claim-detail-v2.dto.ts @@ -28,6 +28,7 @@ export class ClaimDetailV2ResponseDto { actorId: string; actorName: string; lockedAt: string; + expiredAt?: string; }; @ApiPropertyOptional() diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index d37dc20..44aa4c5 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -2104,12 +2104,15 @@ export class ExpertClaimService { } const lockSnapshot = await this.snapshotDamageExpert(actor.sub); + const lockAt = new Date(); + const expiredAt = new Date(lockAt.getTime() + this.claimV2WorkflowLockTtlMs); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { status: ClaimCaseStatus.EXPERT_REVIEWING, claimStatus: ClaimStatus.UNDER_REVIEW, 'workflow.locked': true, - 'workflow.lockedAt': new Date(), + 'workflow.lockedAt': lockAt, + 'workflow.expiredAt': expiredAt, 'workflow.lockedBy': { actorId: new Types.ObjectId(actor.sub), actorName: actor.fullName, @@ -2206,6 +2209,7 @@ export class ExpertClaimService { }, $unset: { "workflow.lockedAt": "", + "workflow.expiredAt": "", "workflow.lockedBy": "", }, $push: { @@ -2372,6 +2376,11 @@ export class ExpertClaimService { status: nextCaseStatus, claimStatus: nextClaimStatus, 'workflow.locked': false, + $unset: { + 'workflow.lockedAt': '', + 'workflow.expiredAt': '', + 'workflow.lockedBy': '', + }, 'workflow.currentStep': nextStep, 'workflow.nextStep': needsFactorUpload ? ClaimWorkflowStep.INSURER_REVIEW @@ -2486,6 +2495,11 @@ export class ExpertClaimService { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { claimStatus: ClaimStatus.NEEDS_REVISION, 'workflow.locked': false, + $unset: { + 'workflow.lockedAt': '', + 'workflow.expiredAt': '', + 'workflow.lockedBy': '', + }, ...(note ? { 'evaluation.visitLocation': note } : {}), ...(visitSnapshot && { 'evaluation.inPersonVisitExpertProfileSnapshot': visitSnapshot, @@ -2952,6 +2966,7 @@ export class ExpertClaimService { actorId: claim.workflow.lockedBy.actorId?.toString(), actorName: claim.workflow.lockedBy.actorName, lockedAt: (claim.workflow as any).lockedAt?.toISOString(), + expiredAt: (claim.workflow as any).expiredAt?.toISOString(), } : undefined, owner: claim.owner diff --git a/src/request-management/entities/schema/workflow.type.ts b/src/request-management/entities/schema/workflow.type.ts index 11333b9..a2d30bb 100644 --- a/src/request-management/entities/schema/workflow.type.ts +++ b/src/request-management/entities/schema/workflow.type.ts @@ -42,6 +42,10 @@ export class Workflow { @Prop({ type: Date }) lockedAt?: Date; + /** Lock expiry used by UI countdown (typically lockedAt + 15m). */ + @Prop({ type: Date }) + expiredAt?: Date; + @Prop({ type: ActorLockSchema }) lockedBy?: ActorLock; } From b5b3b722c6714257bfd551a779c53ca89a47e0e7 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 26 Apr 2026 11:43:17 +0330 Subject: [PATCH 3/3] YARA-725 --- src/expert-blame/expert-blame.service.ts | 155 ++++++++----- src/expert-claim/dto/claim-list-v2.dto.ts | 4 +- src/expert-claim/expert-claim.service.ts | 208 +++++++++++++----- .../request-management.controller.spec.ts | 22 -- .../request-management.service.spec.ts | 18 -- .../sms-orchestration.service.ts | 10 +- 6 files changed, 267 insertions(+), 150 deletions(-) delete mode 100644 src/request-management/request-management.controller.spec.ts delete mode 100644 src/request-management/request-management.service.spec.ts diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 798b959..03f6c1b 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -46,6 +46,7 @@ import { RequestManagementService } from "src/request-management/request-managem import { snapshotFromFieldExpert } from "src/helpers/expert-profile-snapshot"; import { ExpertModel } from "src/users/entities/schema/expert.schema"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; +import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; interface CheckedRequestEntry { CheckedRequest?: { @@ -407,6 +408,27 @@ export class ExpertBlameService { }; } + /** True while another expert holds an active workflow lock (uses `expiredAt` when set). */ + private isBlameV2WorkflowLockCurrentlyEnforced(doc: { + workflow?: { + locked?: boolean; + lockedAt?: Date; + expiredAt?: Date; + lockedBy?: { actorId?: unknown }; + }; + }): boolean { + if (!doc?.workflow?.locked) return false; + const exp = doc.workflow.expiredAt; + if (exp) { + return Date.now() < new Date(exp as Date).getTime(); + } + const la = doc.workflow.lockedAt; + if (!la) return true; + return ( + Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs + ); + } + /** * Persist unlock when blame V2 workflow lock is older than {@link blameV2LockTtlMs}. * V1 uses in-memory `scheduleUnlock`; V2 only stored `workflow.lockedAt`, so locks never cleared until now. @@ -420,6 +442,13 @@ export class ExpertBlameService { } const lockedAt = request.workflow.lockedAt; + const expiredAt = request.workflow.expiredAt; + if ( + expiredAt && + Date.now() < new Date(expiredAt as Date).getTime() + ) { + return; + } if ( lockedAt && Date.now() < @@ -433,7 +462,9 @@ export class ExpertBlameService { _id: new Types.ObjectId(requestId), "workflow.locked": true, }; - if (lockedAt) { + if (expiredAt) { + filter["workflow.expiredAt"] = expiredAt; + } else if (lockedAt) { filter["workflow.lockedAt"] = lockedAt; } else if (lockedById) { filter["workflow.lockedBy.actorId"] = new Types.ObjectId( @@ -715,6 +746,7 @@ export class ExpertBlameService { try { requireActorClientKey(actor); const actorId = actor.sub; + await this.expireBlameCaseWorkflowLockV2IfStale(requestId); const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId); if (!doc) { throw new NotFoundException("Request not found"); @@ -736,6 +768,16 @@ export class ExpertBlameService { ); } + if (this.isBlameV2WorkflowLockCurrentlyEnforced(doc)) { + const w = doc.workflow as { lockedBy?: { actorId?: unknown } } | undefined; + const lockerId = String(w?.lockedBy?.actorId ?? ""); + if (lockerId && lockerId !== actorId) { + throw new ForbiddenException( + "This request is locked by another expert.", + ); + } + } + const decision = (doc.expert as any)?.decision; const decidedByExpertId = decision?.decidedByExpertId; if (decidedByExpertId && String(decidedByExpertId) !== actorId) { @@ -924,29 +966,22 @@ export class ExpertBlameService { ); } - // Check if locked and not expired - if (request.workflow?.locked) { - const lockedAt = request.workflow.lockedAt; - if (lockedAt) { - const lockExpiryTime = - new Date(lockedAt).getTime() + this.blameV2LockTtlMs; - if (Date.now() < lockExpiryTime) { - // Lock is still valid - const lockedByActorId = String( - request.workflow.lockedBy?.actorId ?? "", - ); - if (lockedByActorId === actorDetail.sub) { - throw new BadRequestException( - "You have already locked this request", - ); - } else { - throw new BadRequestException( - "Request is currently locked by another expert", - ); - } - } - // Lock expired, allow re-locking (continue below) + // Check if locked and not expired (`expiredAt` or lockedAt + TTL) + if ( + request.workflow?.locked && + this.isBlameV2WorkflowLockCurrentlyEnforced(request) + ) { + const lockedByActorId = String( + request.workflow.lockedBy?.actorId ?? "", + ); + if (lockedByActorId === actorDetail.sub) { + throw new BadRequestException( + "You have already locked this request", + ); } + throw new BadRequestException( + "Request is currently locked by another expert", + ); } const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub); @@ -988,6 +1023,7 @@ export class ExpertBlameService { await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice( { receptor: phone, + fileKind: "blame", publicId: request.publicId, expertLastName, }, @@ -1153,30 +1189,48 @@ export class ExpertBlameService { requestId, ); - const requestIdToken = String(request._id); - const partyPhoneByUserId = new Map(); - for (const p of request.parties || []) { - const uid = p?.person?.userId ? String(p.person.userId) : ""; - const phone = p?.person?.phoneNumber; - if (uid && typeof phone === "string" && phone.length > 0) { - partyPhoneByUserId.set(uid, phone); + if (request.type === BlameRequestType.THIRD_PARTY) { + const requestIdToken = String(request._id); + const partyPhoneByUserId = new Map(); + for (const p of request.parties || []) { + const uid = p?.person?.userId ? String(p.person.userId) : ""; + const phone = p?.person?.phoneNumber; + if (uid && typeof phone === "string" && phone.length > 0) { + partyPhoneByUserId.set(uid, phone); + } + } + const partyHasResendPayload = (row: { + requestedItems?: ResendItemType[]; + description?: string; + }) => { + const n = Array.isArray(row.requestedItems) + ? row.requestedItems.length + : 0; + return n > 0 || String(row.description ?? "").trim().length > 0; + }; + for (const p of partyResendRequests) { + if (!partyHasResendPayload(p)) continue; + const uid = String((p as any).partyId); + const phone = partyPhoneByUserId.get(uid); + if (!phone) continue; + const role = + String( + request.parties?.find( + (x: any) => String(x?.person?.userId) === uid, + )?.role, + ) === "SECOND" + ? "SECOND" + : "FIRST"; + await this.smsOrchestrationService.sendResendDocumentsNotice({ + receptor: phone, + fileKind: "blame", + publicId: request.publicId, + link: this.smsOrchestrationService.buildBlamePartyLink( + requestIdToken, + role, + ), + }); } - } - for (const p of partyResendRequests) { - const uid = String((p as any).partyId); - const phone = partyPhoneByUserId.get(uid); - if (!phone) continue; - const role = - String(request.parties?.find((x: any) => String(x?.person?.userId) === uid)?.role) === - "SECOND" - ? "SECOND" - : "FIRST"; - await this.smsOrchestrationService.sendResendDocumentsNotice({ - receptor: phone, - fileKind: "blame", - publicId: request.publicId, - link: this.smsOrchestrationService.buildBlamePartyLink(requestIdToken, role), - }); } return { @@ -1333,14 +1387,17 @@ export class ExpertBlameService { ); } + // THIRD_PARTY: both parties must sign; send each their deep link (mutual-agreement uses yara-blame-agreement elsewhere). if (request.type === BlameRequestType.THIRD_PARTY) { const requestIdToken = String(request._id); const expertLastName = actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; - for (const p of request.parties || []) { + const parties = request.parties || []; + for (const partyRole of [PartyRole.FIRST, PartyRole.SECOND] as const) { + const p = parties.find((x: any) => x?.role === partyRole); const phone = p?.person?.phoneNumber; if (!phone || typeof phone !== "string") continue; - const role = String(p?.role) === "SECOND" ? "SECOND" : "FIRST"; + const linkRole = partyRole === PartyRole.SECOND ? "SECOND" : "FIRST"; await this.smsOrchestrationService.sendSignatureReviewNotice({ receptor: phone, fileKind: "blame", @@ -1348,7 +1405,7 @@ export class ExpertBlameService { expertLastName, link: this.smsOrchestrationService.buildBlamePartyLink( requestIdToken, - role, + linkRole, ), }); } diff --git a/src/expert-claim/dto/claim-list-v2.dto.ts b/src/expert-claim/dto/claim-list-v2.dto.ts index 456e6d4..ac44c47 100644 --- a/src/expert-claim/dto/claim-list-v2.dto.ts +++ b/src/expert-claim/dto/claim-list-v2.dto.ts @@ -17,10 +17,12 @@ export class ClaimListItemV2Dto { @ApiProperty({ description: 'Whether the claim is locked by another expert', example: false }) locked: boolean; - @ApiPropertyOptional({ description: 'Actor who locked this claim' }) + @ApiPropertyOptional({ description: 'Actor who locked this claim (only while lock is active)' }) lockedBy?: { actorId: string; actorName: string; + lockedAt?: string; + expiredAt?: string; }; @ApiPropertyOptional({ description: 'Vehicle info from snapshot' }) diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 44aa4c5..011c456 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -29,6 +29,7 @@ import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status. import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.enum"; import { UserType } from "src/Types&Enums/userType.enum"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; +import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto"; import { ClaimListDtoRs } from "./dto/claim-list-rs.dto"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; @@ -189,6 +190,7 @@ export class ExpertClaimService { private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService, private readonly branchDbService: BranchDbService, private readonly smsOrchestrationService: SmsOrchestrationService, + private readonly userDbService: UserDbService, ) { } /** Load immutable profile fields from `damage-expert` for the acting expert. */ @@ -197,6 +199,29 @@ export class ExpertClaimService { return snapshotFromDamageExpert(doc as DamageExpertModel); } + /** Owner mobile: linked blame party phone when available, else `users.mobile`. */ + private async resolveClaimOwnerPhone(claim: any): Promise { + if (!claim?.owner?.userId) return undefined; + const ownerUserId = String(claim.owner.userId); + if (claim.blameRequestId) { + const blame = await this.blameRequestDbService.findById( + claim.blameRequestId.toString(), + ); + const ownerParty = (blame?.parties || []).find( + (p: any) => + p?.person?.userId && String(p.person.userId) === ownerUserId, + ); + const fromParty = ownerParty?.person?.phoneNumber; + if (fromParty && typeof fromParty === "string") return fromParty; + } + const user = await this.userDbService.findOne({ + _id: new Types.ObjectId(ownerUserId), + }); + const m = user?.mobile; + if (m && typeof m === "string") return m; + return undefined; + } + /** Map blame party `Vehicle` (name/model/type) to expert panel shape. */ private expertVehicleFromPartyVehicle(v: any): { carName?: string; @@ -2009,6 +2034,19 @@ export class ExpertClaimService { }, }); + const ownerPhoneFactors = await this.resolveClaimOwnerPhone(claim); + if (ownerPhoneFactors) { + const expertLastName = + actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; + await this.smsOrchestrationService.sendSignatureReviewNotice({ + receptor: ownerPhoneFactors, + fileKind: "claim", + publicId: claim.publicId, + expertLastName, + link: this.smsOrchestrationService.buildClaimLink(String(claim._id)), + }); + } + return { message: "All factors approved. The claim proceeds to insurer review (same as non-factor expert reply).", @@ -2072,6 +2110,7 @@ export class ExpertClaimService { */ async lockClaimRequestV2(claimRequestId: string, actor: any) { requireActorClientKey(actor); + await this.expireClaimWorkflowLockV2IfStale(claimRequestId); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { @@ -2131,6 +2170,20 @@ export class ExpertClaimService { }, }); + const ownerPhone = await this.resolveClaimOwnerPhone(claim); + if (ownerPhone) { + const expertLastName = + actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; + await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice( + { + receptor: ownerPhone, + fileKind: "claim", + publicId: claim.publicId, + expertLastName, + }, + ); + } + return { claimRequestId, locked: true, @@ -2229,28 +2282,14 @@ export class ExpertClaimService { }, }); - if (claim.blameRequestId) { - const blame = await this.blameRequestDbService.findById( - claim.blameRequestId.toString(), - ); - if (blame?.type === BlameRequestType.THIRD_PARTY) { - const ownerUserId = claim.owner?.userId ? String(claim.owner.userId) : ""; - const ownerParty = (blame.parties || []).find( - (p: any) => - p?.person?.userId && String(p.person.userId) === ownerUserId, - ); - const ownerPhone = ownerParty?.person?.phoneNumber; - if (ownerPhone && typeof ownerPhone === "string") { - await this.smsOrchestrationService.sendResendDocumentsNotice({ - receptor: ownerPhone, - fileKind: "claim", - publicId: claim.publicId, - link: this.smsOrchestrationService.buildClaimLink( - String(claim._id), - ), - }); - } - } + const ownerPhoneResend = await this.resolveClaimOwnerPhone(claim); + if (ownerPhoneResend) { + await this.smsOrchestrationService.sendResendDocumentsNotice({ + receptor: ownerPhoneResend, + fileKind: "claim", + publicId: claim.publicId, + link: this.smsOrchestrationService.buildClaimLink(String(claim._id)), + }); } return { @@ -2410,30 +2449,21 @@ export class ExpertClaimService { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updatePayload); - if (!needsFactorUpload && claim.blameRequestId) { - const blame = await this.blameRequestDbService.findById( - claim.blameRequestId.toString(), - ); - if (blame?.type === BlameRequestType.THIRD_PARTY) { - const ownerUserId = claim.owner?.userId ? String(claim.owner.userId) : ""; - const ownerParty = (blame.parties || []).find( - (p: any) => - p?.person?.userId && String(p.person.userId) === ownerUserId, - ); - const ownerPhone = ownerParty?.person?.phoneNumber; - if (ownerPhone && typeof ownerPhone === "string") { - const expertLastName = - actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; - await this.smsOrchestrationService.sendSignatureReviewNotice({ - receptor: ownerPhone, - fileKind: "claim", - publicId: claim.publicId, - expertLastName, - link: this.smsOrchestrationService.buildClaimLink( - String(claim._id), - ), - }); - } + // Same effective step as WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW: owner reviews/signs (yara-signature). + if (!needsFactorUpload) { + const ownerPhone = await this.resolveClaimOwnerPhone(claim); + if (ownerPhone) { + const expertLastName = + actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; + await this.smsOrchestrationService.sendSignatureReviewNotice({ + receptor: ownerPhone, + fileKind: "claim", + publicId: claim.publicId, + expertLastName, + link: this.smsOrchestrationService.buildClaimLink( + String(claim._id), + ), + }); } } @@ -2579,6 +2609,15 @@ export class ExpertClaimService { claimCaseTouchesClient(c, clientKey), ); + for (const c of filtered) { + if ( + c.workflow?.locked && + !this.isClaimV2WorkflowLockCurrentlyEnforced(c) + ) { + await this.expireClaimWorkflowLockV2IfStale(String(c._id)); + } + } + const blameIds = [ ...new Set( filtered @@ -2607,18 +2646,23 @@ export class ExpertClaimService { ? blameById.get(c.blameRequestId.toString()) : undefined; const fileCtx = blame ? this.blameFileContextForExpert(blame) : {}; + const lockActive = + !!(c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c)); return { claimRequestId: c._id.toString(), publicId: c.publicId, status: c.status, currentStep: c.workflow?.currentStep || "", - locked: c.workflow?.locked || false, - lockedBy: c.workflow?.lockedBy - ? { - actorId: c.workflow.lockedBy.actorId?.toString(), - actorName: c.workflow.lockedBy.actorName, - } - : undefined, + locked: lockActive, + lockedBy: + lockActive && c.workflow?.lockedBy + ? { + actorId: c.workflow.lockedBy.actorId?.toString(), + actorName: c.workflow.lockedBy.actorName, + lockedAt: (c.workflow as any).lockedAt?.toISOString?.(), + expiredAt: (c.workflow as any).expiredAt?.toISOString?.(), + } + : undefined, vehicle: v ? { carName: v.carName, @@ -2749,11 +2793,15 @@ export class ExpertClaimService { } /** - * True when another expert still holds an active (non-expired) workflow lock. - * Missing `lockedAt` on old documents: treat lock as still enforced until cleared. + * True while a workflow lock is still within its window (`expiredAt` when set, else lockedAt + TTL). + * Missing both timestamps on old documents: treat lock as enforced until cleared. */ private isClaimV2WorkflowLockCurrentlyEnforced(claim: any): boolean { if (!claim?.workflow?.locked) return false; + const exp = claim.workflow?.expiredAt; + if (exp) { + return Date.now() < new Date(exp as Date).getTime(); + } const la = claim.workflow?.lockedAt as Date | string | undefined; if (!la) return true; return ( @@ -2762,6 +2810,55 @@ export class ExpertClaimService { ); } + /** Persist unlock when claim V2 workflow lock TTL has passed (matches blame V2 behaviour). */ + private async expireClaimWorkflowLockV2IfStale( + claimRequestId: string, + ): Promise { + const claim = await this.claimCaseDbService.findById(claimRequestId); + if (!claim?.workflow?.locked) return; + + const expiredAt = claim.workflow?.expiredAt; + const lockedAt = claim.workflow?.lockedAt; + const now = Date.now(); + const ttl = this.claimV2WorkflowLockTtlMs; + + if (expiredAt && now < new Date(expiredAt as Date).getTime()) return; + if ( + !expiredAt && + lockedAt && + now < new Date(lockedAt as Date).getTime() + ttl + ) { + return; + } + if (!expiredAt && !lockedAt) return; + + const lockedById = claim.workflow.lockedBy?.actorId; + const filter: Record = { + _id: new Types.ObjectId(claimRequestId), + "workflow.locked": true, + }; + if (expiredAt) { + filter["workflow.expiredAt"] = expiredAt; + } else if (lockedAt) { + filter["workflow.lockedAt"] = lockedAt; + } else if (lockedById) { + filter["workflow.lockedBy.actorId"] = lockedById; + } + + await this.claimCaseDbService.findOneAndUpdate( + filter as any, + { + $set: { "workflow.locked": false }, + $unset: { + "workflow.lockedAt": "", + "workflow.expiredAt": "", + "workflow.lockedBy": "", + }, + }, + { new: false }, + ); + } + /** * V2: Get claim detail for damage expert * @@ -2776,6 +2873,7 @@ export class ExpertClaimService { actor: any, ): Promise { const actorId = actor.sub; + await this.expireClaimWorkflowLockV2IfStale(claimRequestId); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { diff --git a/src/request-management/request-management.controller.spec.ts b/src/request-management/request-management.controller.spec.ts deleted file mode 100644 index fb8668e..0000000 --- a/src/request-management/request-management.controller.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Test, TestingModule } from "@nestjs/testing"; -import { RequestManagementController } from "./request-management.controller"; -import { RequestManagementService } from "./request-management.service"; - -describe("RequestManagementController", () => { - let controller: RequestManagementController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [RequestManagementController], - providers: [RequestManagementService], - }).compile(); - - controller = module.get( - RequestManagementController, - ); - }); - - it("should be defined", () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/request-management/request-management.service.spec.ts b/src/request-management/request-management.service.spec.ts deleted file mode 100644 index e2718ce..0000000 --- a/src/request-management/request-management.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from "@nestjs/testing"; -import { RequestManagementService } from "./request-management.service"; - -describe("RequestManagementService", () => { - let service: RequestManagementService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [RequestManagementService], - }).compile(); - - service = module.get(RequestManagementService); - }); - - it("should be defined", () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/sms-orchestration/sms-orchestration.service.ts b/src/sms-orchestration/sms-orchestration.service.ts index 23a42e7..ef13ca1 100644 --- a/src/sms-orchestration/sms-orchestration.service.ts +++ b/src/sms-orchestration/sms-orchestration.service.ts @@ -120,17 +120,17 @@ export class SmsOrchestrationService implements OnModuleInit { async sendThirdPartyExpertStartedReviewNotice( params: { receptor: string; + fileKind: "blame" | "claim"; publicId: string; expertLastName: string; }, ): Promise { return this.sendTemplate({ - template: - process.env.SMS_TEMPLATE_THIRD_PARTY_EXPERT_LOCK || - "yara-blame-expert-lock", + template: "yara-expert-lock", receptor: params.receptor, - token: params.publicId, - token2: params.expertLastName || "کارشناس", + token: params.fileKind === "blame" ? "تصادف" : "خسارت", + token2: params.publicId, + token3: params.expertLastName || "کارشناس", }); }