From 6261af8a29b88f9085941be7ad51a41148b619c0 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Mon, 1 Jun 2026 13:21:32 +0330 Subject: [PATCH] Fixed lock --- src/expert-blame/expert-blame.service.ts | 1 + src/expert-claim/expert-claim.service.ts | 118 ++++++++++++++---- .../expert-workflow-review-assignee.ts | 37 +++--- 3 files changed, 117 insertions(+), 39 deletions(-) diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 1339b4e..0a78afc 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -556,6 +556,7 @@ export class ExpertBlameService { const assigneeToPersist = blameWorkflowAssigneeToPersistOnLockExpiry( request.workflow, + request, ); const expireSet: Record = { "workflow.locked": false }; if (assigneeToPersist) { diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index d3aed24..aa14f85 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -3294,22 +3294,26 @@ export class ExpertClaimService { const claims = await this.claimCaseDbService.find({ $or: [ - // Available claims: waiting for expert, not locked + // Available: waiting, not locked { status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, "workflow.locked": { $ne: true }, + // No persistent assignee (or expired without decision) + $or: [ + { "workflow.assignedForReviewBy": { $exists: false } }, + { "workflow.assignedForReviewBy": null }, + ], }, - // 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 + // This expert's locked/in-progress claims { "workflow.locked": true, "workflow.lockedBy.actorId": new Types.ObjectId(actorId), }, - // User uploaded all factors; expert must approve/reject (unlocked queue) + // This expert's persistently assigned claims (decided or resend/objection follow-up) + { + "workflow.assignedForReviewBy.actorId": new Types.ObjectId(actorId), + }, + // Factor validation queue (open to all tenant experts — no lock needed) { $or: [ { @@ -3324,6 +3328,16 @@ export class ExpertClaimService { }, ], }, + // WAITING_FOR_USER_RESEND assigned to this expert + { + status: ClaimCaseStatus.WAITING_FOR_USER_RESEND, + "workflow.assignedForReviewBy.actorId": new Types.ObjectId(actorId), + }, + // EXPERT_REVIEWING with stale lock (reconciliation will fix status, show in meantime) + { + status: ClaimCaseStatus.EXPERT_REVIEWING, + "workflow.locked": { $ne: true }, + }, ], }); @@ -3331,10 +3345,12 @@ export class ExpertClaimService { claimCaseTouchesClient(c, clientKey), ); + // Reconcile stale locks — if no decision was made, also clear assignedForReviewBy const staleLockToReconcile = filtered.filter( (c) => c.workflow?.locked && !this.isClaimV2WorkflowLockCurrentlyEnforced(c), ); + const touchedIds = ( await Promise.all( staleLockToReconcile.map(async (c) => { @@ -3357,9 +3373,39 @@ export class ExpertClaimService { } } + // Post-filter: remove files that belong to another expert after reconciliation + const finalFiltered = filtered.filter((c) => { + const assignedId = String(c.workflow?.assignedForReviewBy?.actorId ?? ""); + const lockEnforced = + c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c); + const lockedById = String(c.workflow?.lockedBy?.actorId ?? ""); + const isFactorValidation = claimIsAwaitingExpertFactorValidationV2(c); + + // Factor validation is open to all tenant experts + if (isFactorValidation) return true; + + // Available: no assignee, not locked by someone else + const isAvailable = + c.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT && + !assignedId && + (!lockEnforced || lockedById === actorId); + + // Mine: assigned to me (covers decided, resend, objection follow-up) + const isAssignedToMe = !!assignedId && assignedId === actorId; + + // Mine: currently locked by me + const isLockedByMe = lockEnforced && lockedById === actorId; + + // Mine: resend pending and I'm the assigned expert + const isResendMine = + c.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND && isAssignedToMe; + + return isAvailable || isAssignedToMe || isLockedByMe || isResendMine; + }); + const blameIds = [ ...new Set( - filtered + finalFiltered .map((c) => c.blameRequestId?.toString()) .filter((id): id is string => !!id), ), @@ -3375,7 +3421,7 @@ export class ExpertClaimService { blames.map((b) => [String(b._id), b]), ); - const list = filtered.map((c) => { + const list = finalFiltered.map((c) => { const awaitingFactorValidation = claimIsAwaitingExpertFactorValidationV2(c); const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById); @@ -3390,6 +3436,7 @@ export class ExpertClaimService { c.status === ClaimCaseStatus.EXPERT_REVIEWING ? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT : c.status; + return { claimRequestId: c._id.toString(), publicId: c.publicId, @@ -3406,11 +3453,7 @@ export class ExpertClaimService { } : undefined, vehicle: v - ? { - carName: v.carName, - carModel: v.carModel, - carType: v.carType, - } + ? { carName: v.carName, carModel: v.carModel, carType: v.carType } : undefined, ...fileCtx, createdAt: c.createdAt, @@ -3727,6 +3770,7 @@ export class ExpertClaimService { const assigneeToPersist = claimWorkflowAssigneeToPersistOnLockExpiry( claim.workflow, + claim, ); const expireLockSet: Record = { "workflow.locked": false }; if (assigneeToPersist) { @@ -3795,21 +3839,53 @@ export class ExpertClaimService { claim.status === ClaimCaseStatus.EXPERT_REVIEWING; const isFactorValidationPending = claimIsAwaitingExpertFactorValidationV2(claim); + const isResendPending = + claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND; - if (!isDamageExpertPhase && !isFactorValidationPending) { + if ( + !isDamageExpertPhase && + !isFactorValidationPending && + !isResendPending + ) { throw new ForbiddenException( `This claim is not available for expert review. Current status: ${claim.status}`, ); } - const lockBlocksOthers = this.isClaimV2WorkflowLockCurrentlyEnforced(claim); - if (lockBlocksOthers) { - const lockerId = claim.workflow?.lockedBy?.actorId?.toString(); - if (lockerId && lockerId !== actorId) { + // Ownership access control — same 4-bucket logic as list + const assignedForReviewById = String( + (claim.workflow as any)?.assignedForReviewBy?.actorId ?? "", + ); + const lockEnforced = this.isClaimV2WorkflowLockCurrentlyEnforced(claim); + const lockedById = String(claim.workflow?.lockedBy?.actorId ?? ""); + + const isAvailable = + isDamageExpertPhase && !assignedForReviewById && !lockEnforced; + const isAssignedToMe = + !!assignedForReviewById && assignedForReviewById === actorId; + const isLockedByMe = lockEnforced && lockedById === actorId; + // Factor validation is open to all tenant experts (no individual ownership) + 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}`, + `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( + "You do not have permission to view this claim.", + ); } const hasCapture = (data: any, key: string) => diff --git a/src/helpers/expert-workflow-review-assignee.ts b/src/helpers/expert-workflow-review-assignee.ts index 81219b5..b2afc9f 100644 --- a/src/helpers/expert-workflow-review-assignee.ts +++ b/src/helpers/expert-workflow-review-assignee.ts @@ -25,9 +25,8 @@ export function resolvePersistentReviewAssigneeId( if (fromField != null && String(fromField).length > 0) { return String(fromField); } - const fromHistory = history?.find( - (h) => h.type === assignedHistoryType, - )?.actor?.actorId; + const fromHistory = history?.find((h) => h.type === assignedHistoryType) + ?.actor?.actorId; if (fromHistory != null && String(fromHistory).length > 0) { return String(fromHistory); } @@ -43,8 +42,7 @@ export function buildExpertAssignConflictForOtherReviewer( message: string; lockedBy: { actorId: string; actorName?: string; lockedAt?: string }; } { - const assignee = - workflow?.assignedForReviewBy ?? workflow?.lockedBy; + const assignee = workflow?.assignedForReviewBy ?? workflow?.lockedBy; const lockedAt = workflow?.lockedAt; return { success: false, @@ -53,25 +51,28 @@ export function buildExpertAssignConflictForOtherReviewer( lockedBy: { actorId: assigneeId, actorName: assignee?.actorName, - lockedAt: lockedAt - ? new Date(lockedAt as Date).toISOString() - : undefined, + lockedAt: lockedAt ? new Date(lockedAt as Date).toISOString() : undefined, }, }; } -/** Copy lock holder into `assignedForReviewBy` when persisting a TTL unlock. */ export function blameWorkflowAssigneeToPersistOnLockExpiry( - workflow: WorkflowAssigneeRef | undefined, -): WorkflowAssigneeRef["assignedForReviewBy"] | undefined { - if (workflow?.assignedForReviewBy) { - return undefined; - } - return workflow?.lockedBy; + workflow: any, + request: any, +) { + const hasDecision = !!request?.expert?.decision; + return hasDecision ? (workflow?.assignedForReviewBy ?? null) : null; } export function claimWorkflowAssigneeToPersistOnLockExpiry( - workflow: WorkflowAssigneeRef | undefined, -): WorkflowAssigneeRef["assignedForReviewBy"] | undefined { - return blameWorkflowAssigneeToPersistOnLockExpiry(workflow); + workflow: any, + claim: any, +): typeof workflow.assignedForReviewBy | null { + // If a decision or resend was already submitted, preserve ownership + const hasDecision = + !!claim?.evaluation?.damageExpertReply || + !!claim?.evaluation?.damageExpertReplyFinal || + !!claim?.evaluation?.damageExpertResend; + + return hasDecision ? (workflow?.assignedForReviewBy ?? null) : null; }