forked from Yara724/api
Fixed images being removed, fixed v4/v5 wrong status on uploadDocument
This commit is contained in:
113
src/claim-request-management/capture-part-concurrency.spec.ts
Normal file
113
src/claim-request-management/capture-part-concurrency.spec.ts
Normal file
@@ -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.<key>`.
|
||||||
|
*/
|
||||||
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6452,7 +6452,9 @@ export class ClaimRequestManagementService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule retry for attachments
|
// Schedule retry for attachments — never let retry bookkeeping fail the
|
||||||
|
// caller: local media/docs are already persisted before this submit.
|
||||||
|
try {
|
||||||
await this.scheduleFanavaranRetry(
|
await this.scheduleFanavaranRetry(
|
||||||
claimCaseId,
|
claimCaseId,
|
||||||
"attachments",
|
"attachments",
|
||||||
@@ -6460,6 +6462,12 @@ export class ClaimRequestManagementService {
|
|||||||
logPrefix,
|
logPrefix,
|
||||||
{ error, clientKey },
|
{ error, clientKey },
|
||||||
);
|
);
|
||||||
|
} catch (retryScheduleError) {
|
||||||
|
this.logger.error(
|
||||||
|
`${logPrefix} Failed to schedule Fanavaran attachment retry`,
|
||||||
|
retryScheduleError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
attempted: true,
|
attempted: true,
|
||||||
@@ -10911,7 +10919,14 @@ export class ClaimRequestManagementService {
|
|||||||
...nextMedia[idx],
|
...nextMedia[idx],
|
||||||
...captureData,
|
...captureData,
|
||||||
};
|
};
|
||||||
|
// 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;
|
updateData["media.damagedParts"] = nextMedia;
|
||||||
|
} else {
|
||||||
|
updateData[`media.damagedParts.${idx}`] = nextMedia[idx];
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
isResendCapture &&
|
isResendCapture &&
|
||||||
nextSelected.length !== selectedBeforeNorm.length
|
nextSelected.length !== selectedBeforeNorm.length
|
||||||
@@ -10920,10 +10935,14 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
|
await this.claimCaseDbService.findByIdAndUpdate(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
updateData,
|
updateData,
|
||||||
);
|
);
|
||||||
|
// Re-read so capture-progress sees sibling concurrent part/angle writes.
|
||||||
|
const updatedClaim =
|
||||||
|
(await this.claimCaseDbService.findById(claimRequestId)) ??
|
||||||
|
claimCase;
|
||||||
|
|
||||||
if (isResendCapture) {
|
if (isResendCapture) {
|
||||||
await this.tryFinalizeExpertResendAfterUserAction(
|
await this.tryFinalizeExpertResendAfterUserAction(
|
||||||
|
|||||||
112
src/request-management/file-maker-status-resume.spec.ts
Normal file
112
src/request-management/file-maker-status-resume.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12733,6 +12733,26 @@ export class RequestManagementService {
|
|||||||
return { ...workflow, completedSteps };
|
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<any[]> {
|
async getMyFileMakerFiles(fileMaker: any): Promise<any[]> {
|
||||||
if (fileMaker?.role !== RoleEnum.FILE_MAKER) {
|
if (fileMaker?.role !== RoleEnum.FILE_MAKER) {
|
||||||
throw new ForbiddenException("Only FileMakers can use this endpoint.");
|
throw new ForbiddenException("Only FileMakers can use this endpoint.");
|
||||||
@@ -12742,14 +12762,35 @@ export class RequestManagementService {
|
|||||||
isMadeByFileMaker: true,
|
isMadeByFileMaker: true,
|
||||||
initiatedByFieldExpertId: makerId,
|
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<string, unknown>();
|
||||||
|
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) => {
|
return (files || []).map((f: any) => {
|
||||||
const workflow = this.fileMakerWorkflowProjection(f);
|
const workflow = this.fileMakerWorkflowProjection(f);
|
||||||
|
const resume = this.fileMakerStatusForResume(
|
||||||
|
f.status,
|
||||||
|
claimStatusByBlameId.get(String(f._id)),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
_id: f._id,
|
_id: f._id,
|
||||||
publicId: f.publicId,
|
publicId: f.publicId,
|
||||||
requestNo: f.requestNo,
|
requestNo: f.requestNo,
|
||||||
type: f.type,
|
type: f.type,
|
||||||
status: f.status,
|
status: resume.status,
|
||||||
|
...(resume.blameCaseStatus !== undefined
|
||||||
|
? { blameCaseStatus: resume.blameCaseStatus }
|
||||||
|
: {}),
|
||||||
blameStatus: f.blameStatus,
|
blameStatus: f.blameStatus,
|
||||||
workflow: {
|
workflow: {
|
||||||
currentStep: workflow.currentStep,
|
currentStep: workflow.currentStep,
|
||||||
@@ -12794,12 +12835,20 @@ export class RequestManagementService {
|
|||||||
: claim
|
: claim
|
||||||
? { ...(claim as any) }
|
? { ...(claim as any) }
|
||||||
: null;
|
: null;
|
||||||
|
const resume = this.fileMakerStatusForResume(
|
||||||
|
plain.status,
|
||||||
|
claimPlain?.status,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_id: plain._id,
|
_id: plain._id,
|
||||||
publicId: plain.publicId,
|
publicId: plain.publicId,
|
||||||
requestNo: plain.requestNo,
|
requestNo: plain.requestNo,
|
||||||
type: plain.type,
|
type: plain.type,
|
||||||
status: plain.status,
|
status: resume.status,
|
||||||
|
...(resume.blameCaseStatus !== undefined
|
||||||
|
? { blameCaseStatus: resume.blameCaseStatus }
|
||||||
|
: {}),
|
||||||
blameStatus: plain.blameStatus,
|
blameStatus: plain.blameStatus,
|
||||||
workflow: this.fileMakerWorkflowProjection(plain),
|
workflow: this.fileMakerWorkflowProjection(plain),
|
||||||
requiresFileMakerApproval: plain.requiresFileMakerApproval,
|
requiresFileMakerApproval: plain.requiresFileMakerApproval,
|
||||||
|
|||||||
Reference in New Issue
Block a user