diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 601aee7..453782a 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -8643,31 +8643,10 @@ export class ClaimRequestManagementService { if (allDocumentsUploaded) { if (options?.v3InPersonFlow) { - // V4 split flow: mark the claim as waiting for the FileReviewer. - // The FileReviewer calls accident-fields then select-outer-parts, at - // which point advanceV3ClaimToOuterPartsIfReady() moves the claim to - // SELECTING_OUTER_PARTS / SELECT_OUTER_PARTS. - $set["status"] = ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER; - $set["workflow.nextStep"] = ClaimWorkflowStep.SELECT_OUTER_PARTS; - - completedStepsEntries.push( - ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, - ); - historyEntries.push({ - type: "STEP_COMPLETED", - actor: { - actorId: new Types.ObjectId(currentUserId), - actorName: claimCase.owner?.fullName || "User", - actorType: "user", - }, - timestamp: new Date(), - metadata: { - stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, - description: - "FileMaker documents complete. File sealed — awaiting FileReviewer.", - v3InPersonFlow: true, - }, - }); + // V4/V5 split flow: the status transition to WAITING_FOR_FILE_REVIEWER is + // deferred to a post-write re-check (see below) to eliminate the race + // condition where two concurrent uploads each see a stale snapshot and + // neither fires the transition. We skip adding it to $set here. } else { $set["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; $set["claimStatus"] = ClaimStatus.PENDING; @@ -8734,6 +8713,77 @@ export class ClaimRequestManagementService { updatePayload, ); + // ─── V4/V5 in-person flow: post-write race-safe status transition ────── + // After the write we re-read the actual DB state. If all pre-capture + // documents are now present but the status has not yet advanced (i.e. a + // concurrent request uploaded the last doc a split-second before us and + // the stale snapshot we read earlier missed it — or vice-versa), we apply + // WAITING_FOR_FILE_REVIEWER atomically using findOneAndUpdate with a status + // condition, so exactly one writer wins regardless of concurrency. + if (options?.v3InPersonFlow && !isResendUpload && !isCapturePhaseDocUpload) { + const afterWrite = await this.claimCaseDbService.findById(claimRequestId); + if (afterWrite) { + const blameForRecheck = afterWrite.blameRequestId + ? await this.blameRequestDbService.findById( + afterWrite.blameRequestId.toString(), + ) + : null; + const isCarBodyRecheck = blameForRecheck?.type === BlameRequestType.CAR_BODY; + const allDoneNow = + afterWrite.status === ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS && + afterWrite.workflow?.currentStep === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && + this.allV3PreCaptureDocumentsComplete(afterWrite, isCarBodyRecheck); + + if (allDoneNow) { + // Atomic conditional update: only succeeds when status is still + // UPLOADING_REQUIRED_DOCUMENTS, preventing double-application. + const advanced = await this.claimCaseDbService.findOneAndUpdate( + { + _id: afterWrite._id, + status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, + }, + { + $set: { + status: ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER, + "workflow.nextStep": ClaimWorkflowStep.SELECT_OUTER_PARTS, + }, + $push: { + "workflow.completedSteps": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + history: { + type: "STEP_COMPLETED", + actor: { + actorId: new Types.ObjectId(currentUserId), + actorName: afterWrite.owner?.fullName || "User", + actorType: "user", + }, + timestamp: new Date(), + metadata: { + stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + description: + "FileMaker documents complete. File sealed — awaiting FileReviewer.", + v3InPersonFlow: true, + }, + }, + }, + }, + ); + if (advanced) { + // This request won the race — reflect the final state in the response. + allDocumentsUploaded = true; + } + } else if ( + afterWrite.status === ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER || + (afterWrite.workflow?.completedSteps ?? []).includes( + ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + ) + ) { + // Another concurrent request already advanced the status — treat + // this upload as the completing upload for response purposes. + allDocumentsUploaded = true; + } + } + } + // Auto-submit Fanavaran attachments when all documents are uploaded if (allDocumentsUploaded && !isResendUpload && !options?.v3InPersonFlow) { this.submitFanavaranAttachmentsV2(claimRequestId, resolveFanavaranClientKey()).catch( diff --git a/src/request-management/file-maker-blame-v4.controller.ts b/src/request-management/file-maker-blame-v4.controller.ts index d1bb5eb..856881d 100644 --- a/src/request-management/file-maker-blame-v4.controller.ts +++ b/src/request-management/file-maker-blame-v4.controller.ts @@ -87,7 +87,9 @@ export class FileMakerBlameV4Controller { ) { return this.requestManagementService.createExpertInitiatedBlameV2( fileMaker, - { ...dto, creationMethod: CreationMethod.IN_PERSON }, + // requiresFileMakerApproval is V5-only; explicitly exclude it from V4 so + // a client that accidentally sends the field cannot poison the blame record. + { ...dto, creationMethod: CreationMethod.IN_PERSON, requiresFileMakerApproval: false }, ); }