diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 1a3331a..0a78afc 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -303,66 +303,58 @@ export class ExpertBlameService { requireActorClientKey(actor); const expertId = actor.sub; - // Fetch all DISAGREEMENT cases const allCases = await this.blameRequestDbService.find( - { - blameStatus: BlameStatus.DISAGREEMENT, - }, + { blameStatus: BlameStatus.DISAGREEMENT }, { lean: true }, ); - // Filter to show only: - // 1. Same insurance tenant (party clientId or expert-initiated by this actor) - // 2. Fresh requests (WAITING_FOR_EXPERT and no decision) - // 3. Requests decided by current expert - // 4. Expert-initiated: only the initiating field expert sees them const visibleCases = (allCases as Record[]).filter( (doc) => { - if (!blameCaseAccessibleToExpert(doc, actor)) { - return false; - } - - const expertInitiated = doc.expertInitiated === true; - const initiatedByFieldExpertId = doc.initiatedByFieldExpertId; - - if (expertInitiated && initiatedByFieldExpertId) { - if (String(initiatedByFieldExpertId) !== expertId) { - return false; // Only the initiating field expert can see this file - } - return true; // Initiating expert can see their expert-initiated file - } + // Always filter by tenant first + if (!blameCaseAccessibleToExpert(doc, actor)) return false; const status = doc.status as string; - const decision = doc.expert as any; - const decidedByExpertId = decision?.decision?.decidedByExpertId; - const hasDecision = !!decision?.decision; + const decision = (doc.expert as any)?.decision; + const decidedByExpertId = decision?.decidedByExpertId + ? String(decision.decidedByExpertId) + : null; + const lockedById = String( + (doc.workflow as any)?.lockedBy?.actorId ?? "", + ); + const lockEnforced = + (doc.workflow as any)?.locked && + this.isBlameV2WorkflowLockCurrentlyEnforced(doc as any); - // Fresh request (no decision yet) - if (status === CaseStatus.WAITING_FOR_EXPERT && !hasDecision) { - return true; + // Expert-initiated files: only the initiating expert sees them + if (doc.expertInitiated === true && doc.initiatedByFieldExpertId) { + return String(doc.initiatedByFieldExpertId) === expertId; } - // Request decided by current expert - if (decidedByExpertId && String(decidedByExpertId) === expertId) { - return true; - } - - // Locked by current expert but no decision yet - const lockedBy = - decision?.resend?.requestedByExpertId || - (doc.workflow as any)?.lockedBy?.actorId; - if ( + // Bucket 1: Available — waiting, no decision, no active lock by someone else + const isAvailable = status === CaseStatus.WAITING_FOR_EXPERT && - lockedBy && - String(lockedBy) === expertId - ) { - return true; - } + !decidedByExpertId && + (!lockEnforced || lockedById === expertId); - return false; + // Bucket 2: Mine — decided by me + const isDecidedByMe = + !!decidedByExpertId && decidedByExpertId === expertId; + + // Bucket 3: Mine — currently locked/assigned to me (in progress) + const isLockedByMe = lockEnforced && lockedById === expertId; + + // Bucket 4: Mine — persistently assigned to me for review + const assignedForReviewById = String( + (doc.workflow as any)?.assignedForReviewBy?.actorId ?? "", + ); + const isAssignedToMe = + !!assignedForReviewById && assignedForReviewById === expertId; + + return isAvailable || isDecidedByMe || isLockedByMe || isAssignedToMe; }, ); + // Reconcile stale locks in-memory (same as before) const staleIds = new Set(); for (const doc of visibleCases) { const w = doc.workflow as Record | undefined; @@ -564,6 +556,7 @@ export class ExpertBlameService { const assigneeToPersist = blameWorkflowAssigneeToPersistOnLockExpiry( request.workflow, + request, ); const expireSet: Record = { "workflow.locked": false }; if (assigneeToPersist) { @@ -854,75 +847,79 @@ export class ExpertBlameService { actor: any, ): Promise> { 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"); } - // assertBlameCaseForExpertTenant(doc, actor); - const type = doc.type as string; - if (type === BlameRequestType.CAR_BODY) { + + // Tenant check + assertBlameCaseForExpertTenant(doc, actor); + + if (doc.type === BlameRequestType.CAR_BODY) { throw new ForbiddenException( "CAR_BODY type requests are automatically handled and do not require expert review.", ); } - // Access control - const expertInitiated = doc.expertInitiated === true; - const initiatedByFieldExpertId = doc.initiatedByFieldExpertId; + // Expert-initiated: only the initiating expert if ( - expertInitiated && - initiatedByFieldExpertId && - String(initiatedByFieldExpertId) !== actorId + doc.expertInitiated === true && + doc.initiatedByFieldExpertId && + String(doc.initiatedByFieldExpertId) !== actorId ) { throw new ForbiddenException( "Only the field expert who created this file can view and review it.", ); } - if (this.isBlameV2WorkflowLockCurrentlyEnforced(doc)) { - const w = doc.workflow as - | { lockedBy?: { actorId?: unknown } } - | undefined; - const lockerId = String(w?.lockedBy?.actorId ?? ""); - if (lockerId && lockerId !== actorId) { + const decision = (doc.expert as any)?.decision; + const decidedByExpertId = decision?.decidedByExpertId + ? String(decision.decidedByExpertId) + : null; + const lockedById = String((doc.workflow as any)?.lockedBy?.actorId ?? ""); + const lockEnforced = + (doc.workflow as any)?.locked && + this.isBlameV2WorkflowLockCurrentlyEnforced(doc); + const assignedForReviewById = String( + (doc.workflow as any)?.assignedForReviewBy?.actorId ?? "", + ); + + // Access gates — must satisfy at least one bucket + const isAvailable = + doc.status === CaseStatus.WAITING_FOR_EXPERT && !decidedByExpertId; + const isDecidedByMe = decidedByExpertId === actorId; + const isLockedByMe = lockEnforced && lockedById === actorId; + const isAssignedToMe = + !!assignedForReviewById && assignedForReviewById === actorId; + + if (!isAvailable && !isDecidedByMe && !isLockedByMe && !isAssignedToMe) { + // Give a specific reason if possible + if (lockEnforced && lockedById && lockedById !== 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) { + if (decidedByExpertId && decidedByExpertId !== actorId) { + throw new ForbiddenException( + "You do not have permission to view this request. It has been handled by another expert.", + ); + } throw new ForbiddenException( - "You do not have permission to view this request. It has been handled by another expert.", + "You do not have permission to view this request.", ); } + // Build evidence URLs const parties = Array.isArray(doc.parties) ? doc.parties : []; - - const typedParties = parties as Array<{ - vehicle?: { - inquiry?: { - mapped?: any; - }; - }; - evidence?: { - videoId?: string | number; - voices?: (string | number)[]; - videoUrl?: string; - voiceUrls?: string[]; - }; - }>; - - // Evidence (videos + voices) - for (const party of typedParties) { + for (const party of parties as Array<{ + evidence?: Record; + }>) { if (!party.evidence) continue; - const evidence = party.evidence as Record; + const evidence = party.evidence; if (evidence.videoId) { const videoDoc = await this.blameVideoDbService.findById( @@ -931,7 +928,7 @@ export class ExpertBlameService { if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path); } - if (evidence.voices && Array.isArray(evidence.voices)) { + if (Array.isArray(evidence.voices)) { const voiceUrls: string[] = []; for (const voiceId of evidence.voices) { const voiceDoc = await this.blameVoiceDbService.findById( @@ -950,31 +947,31 @@ export class ExpertBlameService { const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | number) : new Date(); - const [createdDate, createdTime] = toJalaliDateAndTime(createdAt); const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt); - doc.createdAtFormatted = `${createdDate} ${createdTime}`; doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`; - // Omit heavy SandHub inquiry blob from expert response (keep other vehicle fields) - for (const party of typedParties) { - const veh = party?.vehicle as Record | undefined; - if (veh && Object.prototype.hasOwnProperty.call(veh, "inquiry")) { - delete veh.inquiry; + // Strip heavy SandHub inquiry blob + for (const party of parties as Array<{ + vehicle?: Record; + }>) { + if ( + party.vehicle && + Object.prototype.hasOwnProperty.call(party.vehicle, "inquiry") + ) { + delete party.vehicle.inquiry; } } return doc; } catch (error) { if (error instanceof HttpException) throw error; - this.logger.error( "findOneV2 failed", requestId, error instanceof Error ? error.stack : String(error), ); - throw new InternalServerErrorException( error instanceof Error ? error.message 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; }