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 fc16851..f3169d6 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 @@ -66,6 +66,13 @@ export class ClaimWorkflow { @Prop({ type: ClaimPreLockQueueSnapshotSchema }) preLockQueueSnapshot?: ClaimPreLockQueueSnapshot; + + /** + * First damage expert who called review assign; kept after the 15m lock expires + * so no other expert can take the case while it remains in the expert queue. + */ + @Prop({ type: ClaimActorLockSchema }) + assignedForReviewBy?: ClaimActorLock; } export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow); diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 95beb42..fa96f6c 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -28,6 +28,12 @@ import { ExpertFileAssignResultDto, ExpertFileAssignStatus, } from "src/common/dto/expert-file-assign-result.dto"; +import { + BLAME_REVIEW_ASSIGNED_HISTORY_TYPE, + blameWorkflowAssigneeToPersistOnLockExpiry, + buildExpertAssignConflictForOtherReviewer, + resolvePersistentReviewAssigneeId, +} from "src/helpers/expert-workflow-review-assignee"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { @@ -541,10 +547,18 @@ export class ExpertBlameService { ); } + const assigneeToPersist = blameWorkflowAssigneeToPersistOnLockExpiry( + request.workflow, + ); + const expireSet: Record = { "workflow.locked": false }; + if (assigneeToPersist) { + expireSet["workflow.assignedForReviewBy"] = assigneeToPersist; + } + const cleared = await this.blameRequestDbService.findOneAndUpdate( filter as any, { - $set: { "workflow.locked": false }, + $set: expireSet, $unset: { "workflow.lockedAt": "", "workflow.expiredAt": "", @@ -1056,24 +1070,29 @@ export class ExpertBlameService { } const expertOid = new Types.ObjectId(actor.sub); + const reviewAssigneeId = resolvePersistentReviewAssigneeId( + request.workflow, + request.history, + BLAME_REVIEW_ASSIGNED_HISTORY_TYPE, + ); + if (reviewAssigneeId && reviewAssigneeId !== actor.sub) { + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer( + reviewAssigneeId, + request.workflow, + ), + ); + } + const lockedById = String(request.workflow?.lockedBy?.actorId ?? ""); const lockEnforced = request.workflow?.locked && this.isBlameV2WorkflowLockCurrentlyEnforced(request); if (lockEnforced && lockedById && lockedById !== actor.sub) { - throw new ConflictException({ - success: false, - status: "locked" satisfies ExpertFileAssignStatus, - message: "Request is already being processed by another expert", - lockedBy: { - actorId: lockedById, - actorName: request.workflow?.lockedBy?.actorName, - lockedAt: request.workflow?.lockedAt - ? new Date(request.workflow.lockedAt as Date).toISOString() - : undefined, - }, - }); + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer(lockedById, request.workflow), + ); } if (lockEnforced && lockedById === actor.sub) { @@ -1089,17 +1108,21 @@ export class ExpertBlameService { const now = new Date(); const lockSnapshot = await this.snapshotFieldExpert(actor.sub); - const lockUpdate = { + const lockedByPayload = { + actorId: expertOid, + actorName: actor.fullName || "Unknown Expert", + actorRole: "expert" as const, + ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), + }; + const lockUpdate: { + $set: Record; + $push: Record; + } = { $set: { "workflow.locked": true, "workflow.lockedAt": now, "workflow.expiredAt": new Date(now.getTime() + this.blameV2LockTtlMs), - "workflow.lockedBy": { - actorId: expertOid, - actorName: actor.fullName || "Unknown Expert", - actorRole: "expert", - ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), - }, + "workflow.lockedBy": lockedByPayload, }, $push: { history: { @@ -1114,16 +1137,30 @@ export class ExpertBlameService { }, }, }; + if (!request.workflow?.assignedForReviewBy) { + lockUpdate.$set["workflow.assignedForReviewBy"] = lockedByPayload; + } const assignFilter: Record = { _id: new Types.ObjectId(requestId), status: CaseStatus.WAITING_FOR_EXPERT, blameStatus: BlameStatus.DISAGREEMENT, - $or: [ - { "workflow.locked": { $ne: true } }, - { "workflow.locked": false }, - { "workflow.expiredAt": { $lte: now } }, - { "workflow.lockedBy.actorId": expertOid }, + $and: [ + { + $or: [ + { "workflow.locked": { $ne: true } }, + { "workflow.locked": false }, + { "workflow.expiredAt": { $lte: now } }, + { "workflow.lockedBy.actorId": expertOid }, + ], + }, + { + $or: [ + { "workflow.assignedForReviewBy": { $exists: false } }, + { "workflow.assignedForReviewBy": null }, + { "workflow.assignedForReviewBy.actorId": expertOid }, + ], + }, ], }; @@ -1140,6 +1177,19 @@ export class ExpertBlameService { if (!updated) { await this.expireBlameCaseWorkflowLockV2IfStale(requestId); const latest = await this.blameRequestDbService.findById(requestId); + const otherAssigneeId = resolvePersistentReviewAssigneeId( + latest?.workflow, + latest?.history, + BLAME_REVIEW_ASSIGNED_HISTORY_TYPE, + ); + if (otherAssigneeId && otherAssigneeId !== actor.sub) { + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer( + otherAssigneeId, + latest?.workflow, + ), + ); + } const otherId = String(latest?.workflow?.lockedBy?.actorId ?? ""); if ( latest?.workflow?.locked && @@ -1147,18 +1197,9 @@ export class ExpertBlameService { otherId && otherId !== actor.sub ) { - throw new ConflictException({ - success: false, - status: "locked" satisfies ExpertFileAssignStatus, - message: "Request is already being processed by another expert", - lockedBy: { - actorId: otherId, - actorName: latest.workflow?.lockedBy?.actorName, - lockedAt: latest.workflow?.lockedAt - ? new Date(latest.workflow.lockedAt as Date).toISOString() - : undefined, - }, - }); + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer(otherId, latest?.workflow), + ); } throw new BadRequestException({ success: false, diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index c9295c9..aa53f97 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -35,6 +35,12 @@ import { ExpertFileAssignResultDto, ExpertFileAssignStatus, } from "src/common/dto/expert-file-assign-result.dto"; +import { + CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE, + claimWorkflowAssigneeToPersistOnLockExpiry, + buildExpertAssignConflictForOtherReviewer, + resolvePersistentReviewAssigneeId, +} from "src/helpers/expert-workflow-review-assignee"; 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"; @@ -2375,24 +2381,30 @@ export class ExpertClaimService { }); } + const expertOid = new Types.ObjectId(actor.sub); + const reviewAssigneeId = resolvePersistentReviewAssigneeId( + claim.workflow, + claim.history, + CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE, + ); + if (reviewAssigneeId && reviewAssigneeId !== actor.sub) { + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer( + reviewAssigneeId, + claim.workflow, + ), + ); + } + const lockedById = String(claim.workflow?.lockedBy?.actorId ?? ""); const lockEnforced = claim.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(claim); if (lockEnforced && lockedById && lockedById !== actor.sub) { - throw new ConflictException({ - success: false, - status: "locked" satisfies ExpertFileAssignStatus, - message: "Request is already being processed by another expert", - lockedBy: { - actorId: lockedById, - actorName: claim.workflow?.lockedBy?.actorName, - lockedAt: claim.workflow?.lockedAt - ? new Date(claim.workflow.lockedAt as Date).toISOString() - : undefined, - }, - }); + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer(lockedById, claim.workflow), + ); } if (lockEnforced && lockedById === actor.sub) { @@ -2406,21 +2418,22 @@ export class ExpertClaimService { }; } - const expertOid = new Types.ObjectId(actor.sub); const now = new Date(); const lockSnapshot = await this.snapshotDamageExpert(actor.sub); const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs); + const lockedByPayload = { + actorId: expertOid, + actorName: actor.fullName, + actorRole: "damage_expert" as const, + ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), + }; + const baseLockUpdate: Record = { "workflow.locked": true, "workflow.lockedAt": now, "workflow.expiredAt": expiredAt, - "workflow.lockedBy": { - actorId: expertOid, - actorName: actor.fullName, - actorRole: "damage_expert", - ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), - }, + "workflow.lockedBy": lockedByPayload, "workflow.preLockQueueSnapshot": { claimStatus: claim.claimStatus, currentStep: this.normalizeClaimWorkflowStepForSnapshot( @@ -2455,6 +2468,10 @@ export class ExpertClaimService { }, }; + if (!claim.workflow?.assignedForReviewBy) { + baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload; + } + const lockUpdate: Record = isFactorValidationLock ? baseLockUpdate : { @@ -2466,11 +2483,22 @@ export class ExpertClaimService { const assignFilter: Record = { _id: new Types.ObjectId(claimRequestId), - $or: [ - { "workflow.locked": { $ne: true } }, - { "workflow.locked": false }, - { "workflow.expiredAt": { $lte: now } }, - { "workflow.lockedBy.actorId": expertOid }, + $and: [ + { + $or: [ + { "workflow.locked": { $ne: true } }, + { "workflow.locked": false }, + { "workflow.expiredAt": { $lte: now } }, + { "workflow.lockedBy.actorId": expertOid }, + ], + }, + { + $or: [ + { "workflow.assignedForReviewBy": { $exists: false } }, + { "workflow.assignedForReviewBy": null }, + { "workflow.assignedForReviewBy.actorId": expertOid }, + ], + }, ], }; @@ -2497,6 +2525,19 @@ export class ExpertClaimService { if (!updated) { await this.expireClaimWorkflowLockV2IfStale(claimRequestId); const latest = await this.claimCaseDbService.findById(claimRequestId); + const otherAssigneeId = resolvePersistentReviewAssigneeId( + latest?.workflow, + latest?.history, + CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE, + ); + if (otherAssigneeId && otherAssigneeId !== actor.sub) { + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer( + otherAssigneeId, + latest?.workflow, + ), + ); + } const otherId = String(latest?.workflow?.lockedBy?.actorId ?? ""); if ( latest?.workflow?.locked && @@ -2504,18 +2545,9 @@ export class ExpertClaimService { otherId && otherId !== actor.sub ) { - throw new ConflictException({ - success: false, - status: "locked" satisfies ExpertFileAssignStatus, - message: "Request is already being processed by another expert", - lockedBy: { - actorId: otherId, - actorName: latest.workflow?.lockedBy?.actorName, - lockedAt: latest.workflow?.lockedAt - ? new Date(latest.workflow.lockedAt as Date).toISOString() - : undefined, - }, - }); + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer(otherId, latest?.workflow), + ); } throw new BadRequestException({ success: false, @@ -3657,10 +3689,18 @@ export class ExpertClaimService { "workflow.preLockQueueSnapshot": "", }; + const assigneeToPersist = claimWorkflowAssigneeToPersistOnLockExpiry( + claim.workflow, + ); + const expireLockSet: Record = { "workflow.locked": false }; + if (assigneeToPersist) { + expireLockSet["workflow.assignedForReviewBy"] = assigneeToPersist; + } + const update = needsQueueRestore ? { $set: { - "workflow.locked": false, + ...expireLockSet, status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, claimStatus: restoreClaimStatus, "workflow.currentStep": restoreCurrent, @@ -3669,7 +3709,7 @@ export class ExpertClaimService { $unset: lockFieldsUnset, } : { - $set: { "workflow.locked": false }, + $set: expireLockSet, $unset: lockFieldsUnset, }; diff --git a/src/helpers/expert-workflow-review-assignee.ts b/src/helpers/expert-workflow-review-assignee.ts new file mode 100644 index 0000000..81219b5 --- /dev/null +++ b/src/helpers/expert-workflow-review-assignee.ts @@ -0,0 +1,77 @@ +import type { ExpertFileAssignStatus } from "src/common/dto/expert-file-assign-result.dto"; + +export const BLAME_REVIEW_ASSIGNED_HISTORY_TYPE = "BLAME_ASSIGNED"; +export const CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE = "CLAIM_ASSIGNED"; + +type WorkflowAssigneeRef = { + assignedForReviewBy?: { actorId?: unknown; actorName?: string }; + lockedBy?: { actorId?: unknown; actorName?: string }; + lockedAt?: Date | string; +}; + +type HistoryAssigneeRef = { + type?: string; + actor?: { actorId?: unknown }; + timestamp?: Date | string; +}; + +/** Expert who first assigned via the review assign endpoint (survives lock TTL expiry). */ +export function resolvePersistentReviewAssigneeId( + workflow: WorkflowAssigneeRef | undefined, + history: HistoryAssigneeRef[] | undefined, + assignedHistoryType: string, +): string { + const fromField = workflow?.assignedForReviewBy?.actorId; + if (fromField != null && String(fromField).length > 0) { + return String(fromField); + } + const fromHistory = history?.find( + (h) => h.type === assignedHistoryType, + )?.actor?.actorId; + if (fromHistory != null && String(fromHistory).length > 0) { + return String(fromHistory); + } + return ""; +} + +export function buildExpertAssignConflictForOtherReviewer( + assigneeId: string, + workflow?: WorkflowAssigneeRef, +): { + success: false; + status: ExpertFileAssignStatus; + message: string; + lockedBy: { actorId: string; actorName?: string; lockedAt?: string }; +} { + const assignee = + workflow?.assignedForReviewBy ?? workflow?.lockedBy; + const lockedAt = workflow?.lockedAt; + return { + success: false, + status: "locked", + message: "Request is already being answered by another expert", + lockedBy: { + actorId: assigneeId, + actorName: assignee?.actorName, + 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; +} + +export function claimWorkflowAssigneeToPersistOnLockExpiry( + workflow: WorkflowAssigneeRef | undefined, +): WorkflowAssigneeRef["assignedForReviewBy"] | undefined { + return blameWorkflowAssigneeToPersistOnLockExpiry(workflow); +} diff --git a/src/request-management/entities/schema/workflow.type.ts b/src/request-management/entities/schema/workflow.type.ts index a2d30bb..e1cd915 100644 --- a/src/request-management/entities/schema/workflow.type.ts +++ b/src/request-management/entities/schema/workflow.type.ts @@ -48,5 +48,12 @@ export class Workflow { @Prop({ type: ActorLockSchema }) lockedBy?: ActorLock; + + /** + * First expert who called review assign; kept after the 15m workflow lock expires + * so no other expert can take the case while it remains in the expert queue. + */ + @Prop({ type: ActorLockSchema }) + assignedForReviewBy?: ActorLock; } export const WorkflowSchema = SchemaFactory.createForClass(Workflow); \ No newline at end of file