diff --git a/src/claim-request-management/capture-part-concurrency.spec.ts b/src/claim-request-management/capture-part-concurrency.spec.ts new file mode 100644 index 0000000..b1bec84 --- /dev/null +++ b/src/claim-request-management/capture-part-concurrency.spec.ts @@ -0,0 +1,113 @@ +/** + * Regression: concurrent capturePart writes must not clobber sibling slots. + * + * capturePartV2 used to `$set` the entire `media.damagedParts` array from a + * stale read. Parallel uploads (common while Fanavaran attachment submit keeps + * the HTTP request open) made the last writer win — Fanavaran still saw each + * file on disk and could return errors, while Mongo was missing captures. + * + * Required strategy: per-index `$set` (`media.damagedParts.N`), matching + * `media.carAngles.`. + */ +describe("capture-part media.damagedParts write strategies", () => { + type Row = { path?: string; fileName?: string; name?: string }; + type Claim = { media: { damagedParts: Row[] } }; + + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + + /** Legacy (buggy) strategy: replace the entire array from a stale read. */ + async function writeWholeArray( + store: { claim: Claim }, + index: number, + capture: Row, + readDelayMs: number, + ) { + const snapshot = structuredClone(store.claim); + await sleep(readDelayMs); + const next = snapshot.media.damagedParts.map((row) => ({ ...row })); + while (next.length <= index) next.push({}); + next[index] = { ...next[index], ...capture }; + store.claim = { + ...store.claim, + media: { ...store.claim.media, damagedParts: next }, + }; + } + + /** Required strategy: set only the target index (Mongo $set media.damagedParts.N). */ + async function writeSingleIndex( + store: { claim: Claim }, + index: number, + capture: Row, + readDelayMs: number, + ) { + await sleep(readDelayMs); + const next = store.claim.media.damagedParts.map((row) => ({ ...row })); + while (next.length <= index) next.push({}); + next[index] = { ...next[index], ...capture }; + store.claim.media.damagedParts[index] = next[index]; + } + + it("documents that whole-array replace loses a concurrent capture", async () => { + const store: { claim: Claim } = { + claim: { + media: { + damagedParts: [{ name: "hood" }, { name: "front_bumper" }], + }, + }, + }; + + await Promise.all([ + writeWholeArray( + store, + 0, + { path: "files/claim-captures/hood.jpg", fileName: "hood.jpg" }, + 30, + ), + writeWholeArray( + store, + 1, + { + path: "files/claim-captures/bumper.jpg", + fileName: "bumper.jpg", + }, + 10, + ), + ]); + + expect(store.claim.media.damagedParts.map((r) => r.path).filter(Boolean)) + .toHaveLength(1); + }); + + it("keeps both concurrent captures with per-index writes", async () => { + const store: { claim: Claim } = { + claim: { + media: { + damagedParts: [{ name: "hood" }, { name: "front_bumper" }], + }, + }, + }; + + await Promise.all([ + writeSingleIndex( + store, + 0, + { path: "files/claim-captures/hood.jpg", fileName: "hood.jpg" }, + 30, + ), + writeSingleIndex( + store, + 1, + { + path: "files/claim-captures/bumper.jpg", + fileName: "bumper.jpg", + }, + 10, + ), + ]); + + expect(store.claim.media.damagedParts.map((r) => r.path)).toEqual([ + "files/claim-captures/hood.jpg", + "files/claim-captures/bumper.jpg", + ]); + }); +}); diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 075ba6c..b1e9e59 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -6452,14 +6452,22 @@ export class ClaimRequestManagementService { ); } - // Schedule retry for attachments - await this.scheduleFanavaranRetry( - claimCaseId, - "attachments", - () => this.autoSubmitFanavaranAttachment(claimCaseId, file, options), - logPrefix, - { error, clientKey }, - ); + // Schedule retry for attachments — never let retry bookkeeping fail the + // caller: local media/docs are already persisted before this submit. + try { + await this.scheduleFanavaranRetry( + claimCaseId, + "attachments", + () => this.autoSubmitFanavaranAttachment(claimCaseId, file, options), + logPrefix, + { error, clientKey }, + ); + } catch (retryScheduleError) { + this.logger.error( + `${logPrefix} Failed to schedule Fanavaran attachment retry`, + retryScheduleError, + ); + } return { attempted: true, @@ -10911,7 +10919,14 @@ export class ClaimRequestManagementService { ...nextMedia[idx], ...captureData, }; - updateData["media.damagedParts"] = nextMedia; + // Per-index $set avoids lost updates when clients upload multiple parts + // in parallel (whole-array replace raced with Fanavaran-awaited requests). + // Legacy object maps still need a full write to migrate to array shape. + if (!Array.isArray(claimCase.media?.damagedParts)) { + updateData["media.damagedParts"] = nextMedia; + } else { + updateData[`media.damagedParts.${idx}`] = nextMedia[idx]; + } if ( isResendCapture && nextSelected.length !== selectedBeforeNorm.length @@ -10920,10 +10935,14 @@ export class ClaimRequestManagementService { } } - const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate( + await this.claimCaseDbService.findByIdAndUpdate( claimRequestId, updateData, ); + // Re-read so capture-progress sees sibling concurrent part/angle writes. + const updatedClaim = + (await this.claimCaseDbService.findById(claimRequestId)) ?? + claimCase; if (isResendCapture) { await this.tryFinalizeExpertResendAfterUserAction( diff --git a/src/request-management/file-maker-status-resume.spec.ts b/src/request-management/file-maker-status-resume.spec.ts new file mode 100644 index 0000000..9f9c459 --- /dev/null +++ b/src/request-management/file-maker-status-resume.spec.ts @@ -0,0 +1,112 @@ +import { Types } from "mongoose"; +import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; +import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { RequestManagementService } from "./request-management.service"; + +describe("FileMaker V4/V5 status resume bridge", () => { + const makerId = new Types.ObjectId(); + const blameId = new Types.ObjectId(); + + const blameFile = { + _id: blameId, + publicId: "BL-FM-001", + requestNo: "R-1", + type: "THIRD_PARTY", + status: CaseStatus.OPEN, + blameStatus: "IN_PROGRESS", + isMadeByFileMaker: true, + initiatedByFieldExpertId: makerId, + requiresFileMakerApproval: false, + parties: [], + workflow: { + currentStep: "SECOND_COMPLETED", + nextStep: "WAITING_FOR_GUILT_DECISION", + completedSteps: ["SECOND_COMPLETED"], + }, + createdAt: new Date(), + updatedAt: new Date(), + }; + + it("overlays claim UPLOADING_REQUIRED_DOCUMENTS onto detail status", async () => { + const service = + new (RequestManagementService as any)() as RequestManagementService; + (service as any).blameRequestDbService = { + findById: jest.fn().mockResolvedValue(blameFile), + }; + (service as any).claimCaseDbService = { + findOne: jest.fn().mockResolvedValue({ + _id: new Types.ObjectId(), + blameRequestId: blameId, + status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, + workflow: { + currentStep: "UPLOAD_REQUIRED_DOCUMENTS", + nextStep: "SELECT_OUTER_PARTS", + }, + }), + }; + + const detail = await service.getMyFileMakerFileDetail( + { sub: String(makerId), role: RoleEnum.FILE_MAKER }, + String(blameId), + ); + + expect(detail.status).toBe(ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS); + expect(detail.blameCaseStatus).toBe(CaseStatus.OPEN); + expect(detail.claimStatus).toBe( + ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, + ); + }); + + it("does not overlay status when claim docs phase is finished", async () => { + const service = + new (RequestManagementService as any)() as RequestManagementService; + (service as any).blameRequestDbService = { + findById: jest.fn().mockResolvedValue({ + ...blameFile, + status: CaseStatus.WAITING_FOR_FILE_REVIEWER, + }), + }; + (service as any).claimCaseDbService = { + findOne: jest.fn().mockResolvedValue({ + _id: new Types.ObjectId(), + blameRequestId: blameId, + status: ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER, + workflow: { currentStep: "SELECT_OUTER_PARTS" }, + }), + }; + + const detail = await service.getMyFileMakerFileDetail( + { sub: String(makerId), role: RoleEnum.FILE_MAKER }, + String(blameId), + ); + + expect(detail.status).toBe(CaseStatus.WAITING_FOR_FILE_REVIEWER); + expect(detail.blameCaseStatus).toBeUndefined(); + }); + + it("overlays the same bridge on my-files list rows", async () => { + const service = + new (RequestManagementService as any)() as RequestManagementService; + (service as any).blameRequestDbService = { + find: jest.fn().mockResolvedValue([blameFile]), + }; + (service as any).claimCaseDbService = { + find: jest.fn().mockResolvedValue([ + { + blameRequestId: blameId, + status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, + }, + ]), + }; + + const rows = await service.getMyFileMakerFiles({ + sub: String(makerId), + role: RoleEnum.FILE_MAKER, + }); + + expect(rows).toHaveLength(1); + expect(rows[0].status).toBe(ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS); + expect(rows[0].blameCaseStatus).toBe(CaseStatus.OPEN); + }); +}); diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index ef53df2..3ca48eb 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -12733,6 +12733,26 @@ export class RequestManagementService { return { ...workflow, completedSteps }; } + /** + * V4/V5 dirty bridge: FileMaker FE resumes from blame `status`, but pre-capture + * document upload lives on the claim (`UPLOADING_REQUIRED_DOCUMENTS`) while blame + * is still at FIRST/SECOND_COMPLETED. Mirror claim status into `status` only for + * that phase so leave/re-enter can continue; keep real blame status as + * `blameCaseStatus`. Remove once FE keys off `claimStatus` / a unified resume pointer. + */ + private fileMakerStatusForResume( + blameStatus: unknown, + claimStatus: unknown, + ): { status: unknown; blameCaseStatus?: unknown } { + if (claimStatus === ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS) { + return { + status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, + blameCaseStatus: blameStatus, + }; + } + return { status: blameStatus }; + } + async getMyFileMakerFiles(fileMaker: any): Promise { if (fileMaker?.role !== RoleEnum.FILE_MAKER) { throw new ForbiddenException("Only FileMakers can use this endpoint."); @@ -12742,14 +12762,35 @@ export class RequestManagementService { isMadeByFileMaker: true, initiatedByFieldExpertId: makerId, }); + const blameIds = (files || []).map((f: any) => f._id).filter(Boolean); + const claims = + blameIds.length > 0 + ? await this.claimCaseDbService.find( + { blameRequestId: { $in: blameIds } }, + { lean: true, select: "blameRequestId status" }, + ) + : []; + const claimStatusByBlameId = new Map(); + for (const c of claims as any[]) { + const blameId = c?.blameRequestId != null ? String(c.blameRequestId) : ""; + if (blameId) claimStatusByBlameId.set(blameId, c.status); + } + return (files || []).map((f: any) => { const workflow = this.fileMakerWorkflowProjection(f); + const resume = this.fileMakerStatusForResume( + f.status, + claimStatusByBlameId.get(String(f._id)), + ); return { _id: f._id, publicId: f.publicId, requestNo: f.requestNo, type: f.type, - status: f.status, + status: resume.status, + ...(resume.blameCaseStatus !== undefined + ? { blameCaseStatus: resume.blameCaseStatus } + : {}), blameStatus: f.blameStatus, workflow: { currentStep: workflow.currentStep, @@ -12794,12 +12835,20 @@ export class RequestManagementService { : claim ? { ...(claim as any) } : null; + const resume = this.fileMakerStatusForResume( + plain.status, + claimPlain?.status, + ); + return { _id: plain._id, publicId: plain.publicId, requestNo: plain.requestNo, type: plain.type, - status: plain.status, + status: resume.status, + ...(resume.blameCaseStatus !== undefined + ? { blameCaseStatus: resume.blameCaseStatus } + : {}), blameStatus: plain.blameStatus, workflow: this.fileMakerWorkflowProjection(plain), requiresFileMakerApproval: plain.requiresFileMakerApproval,