forked from Yara724/api
Compare commits
13 Commits
0514548136
...
45a51f2c24
| Author | SHA1 | Date | |
|---|---|---|---|
| 45a51f2c24 | |||
| 5b4cc0560f | |||
| e9c02811f7 | |||
| 8ab49c9a35 | |||
| f0cd4461a8 | |||
| 3205a89611 | |||
| 14bc075521 | |||
| 1fe66b2d91 | |||
| 033a853b51 | |||
| f05891a112 | |||
| 7641c56440 | |||
| ae83100ef4 | |||
| 9e2cf9bcdf |
@@ -34,7 +34,15 @@ export enum ClaimCaseStatus {
|
|||||||
|
|
||||||
/** All required factor files are uploaded; damage expert validates factors (`UNDER_REVIEW` @ `EXPERT_COST_EVALUATION`). */
|
/** All required factor files are uploaded; damage expert validates factors (`UNDER_REVIEW` @ `EXPERT_COST_EVALUATION`). */
|
||||||
EXPERT_VALIDATING_REPAIR_FACTORS = "EXPERT_VALIDATING_REPAIR_FACTORS",
|
EXPERT_VALIDATING_REPAIR_FACTORS = "EXPERT_VALIDATING_REPAIR_FACTORS",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V4 split flow only. FileMaker has uploaded all initial required documents;
|
||||||
|
* the file is sealed and waiting for a FileReviewer to pick it up (accident fields,
|
||||||
|
* capture, and final blame video). Transitions to SELECTING_OUTER_PARTS when the
|
||||||
|
* FileReviewer calls select-outer-parts after submitting accident fields.
|
||||||
|
*/
|
||||||
|
WAITING_FOR_FILE_REVIEWER = "WAITING_FOR_FILE_REVIEWER",
|
||||||
|
|
||||||
// Final states
|
// Final states
|
||||||
COMPLETED = "COMPLETED",
|
COMPLETED = "COMPLETED",
|
||||||
CANCELLED = "CANCELLED",
|
CANCELLED = "CANCELLED",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const GLOBAL_GUARD_ROLES = new Set<string>([
|
|||||||
RoleEnum.USER,
|
RoleEnum.USER,
|
||||||
RoleEnum.FIELD_EXPERT,
|
RoleEnum.FIELD_EXPERT,
|
||||||
RoleEnum.REGISTRAR,
|
RoleEnum.REGISTRAR,
|
||||||
|
RoleEnum.FILE_MAKER,
|
||||||
|
RoleEnum.FILE_REVIEWER
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -8232,10 +8232,12 @@ export class ClaimRequestManagementService {
|
|||||||
|
|
||||||
if (allDocumentsUploaded) {
|
if (allDocumentsUploaded) {
|
||||||
if (options?.v3InPersonFlow) {
|
if (options?.v3InPersonFlow) {
|
||||||
$set["status"] = ClaimCaseStatus.SELECTING_OUTER_PARTS;
|
// V4 split flow: mark the claim as waiting for the FileReviewer.
|
||||||
$set["workflow.currentStep"] =
|
// The FileReviewer calls accident-fields then select-outer-parts, at
|
||||||
ClaimWorkflowStep.SELECT_OUTER_PARTS;
|
// which point advanceV3ClaimToOuterPartsIfReady() moves the claim to
|
||||||
$set["workflow.nextStep"] = ClaimWorkflowStep.SELECT_OTHER_PARTS;
|
// SELECTING_OUTER_PARTS / SELECT_OUTER_PARTS.
|
||||||
|
$set["status"] = ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER;
|
||||||
|
$set["workflow.nextStep"] = ClaimWorkflowStep.SELECT_OUTER_PARTS;
|
||||||
|
|
||||||
completedStepsEntries.push(
|
completedStepsEntries.push(
|
||||||
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||||
@@ -8251,7 +8253,7 @@ export class ClaimRequestManagementService {
|
|||||||
metadata: {
|
metadata: {
|
||||||
stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||||
description:
|
description:
|
||||||
"Initial required documents uploaded. Proceed to outer part selection.",
|
"FileMaker documents complete. File sealed — awaiting FileReviewer.",
|
||||||
v3InPersonFlow: true,
|
v3InPersonFlow: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -8367,7 +8369,7 @@ export class ClaimRequestManagementService {
|
|||||||
)
|
)
|
||||||
: allDocumentsUploaded
|
: allDocumentsUploaded
|
||||||
? options?.v3InPersonFlow
|
? options?.v3InPersonFlow
|
||||||
? "Initial required documents uploaded. Proceed to outer part selection."
|
? "All FileMaker documents uploaded. File is sealed and ready for FileReviewer pickup."
|
||||||
: "All documents uploaded successfully. Your claim is now ready for damage expert review."
|
: "All documents uploaded successfully. Your claim is now ready for damage expert review."
|
||||||
: `Document uploaded successfully. ${remaining} documents remaining.`;
|
: `Document uploaded successfully. ${remaining} documents remaining.`;
|
||||||
|
|
||||||
@@ -8382,7 +8384,7 @@ export class ClaimRequestManagementService {
|
|||||||
: ClaimWorkflowStep.CAPTURE_PART_DAMAGES
|
: ClaimWorkflowStep.CAPTURE_PART_DAMAGES
|
||||||
: allDocumentsUploaded
|
: allDocumentsUploaded
|
||||||
? options?.v3InPersonFlow
|
? options?.v3InPersonFlow
|
||||||
? ClaimWorkflowStep.SELECT_OUTER_PARTS
|
? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
||||||
: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
|
: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
|
||||||
: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||||
message,
|
message,
|
||||||
@@ -9964,11 +9966,6 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private v3PartsFlowNotStarted(claimCase: any): boolean {
|
|
||||||
const parts = claimCase.damage?.selectedParts;
|
|
||||||
return !Array.isArray(parts) || parts.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async advanceV3ClaimToOuterPartsIfReady(
|
private async advanceV3ClaimToOuterPartsIfReady(
|
||||||
claimCase: any,
|
claimCase: any,
|
||||||
blame: any,
|
blame: any,
|
||||||
@@ -9980,12 +9977,16 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const step = claimCase.workflow?.currentStep;
|
const step = claimCase.workflow?.currentStep;
|
||||||
const partsNotStarted = this.v3PartsFlowNotStarted(claimCase);
|
// UPLOAD_REQUIRED_DOCUMENTS: normal V3/V4 path after FileMaker docs.
|
||||||
const prematurelyCompleted =
|
// WAITING_FOR_FILE_REVIEWER (claim status): same DB step but with the new
|
||||||
step === ClaimWorkflowStep.USER_SUBMISSION_COMPLETE && partsNotStarted;
|
// V4 claim status — FileReviewer is picking up the sealed file.
|
||||||
|
// USER_SUBMISSION_COMPLETE: can occur when documents were uploaded via a
|
||||||
|
// non-V3 path (e.g. generic v2 endpoint). Safe to advance as long as
|
||||||
|
// accident fields are set (checked just below).
|
||||||
const canAdvance =
|
const canAdvance =
|
||||||
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS ||
|
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS ||
|
||||||
prematurelyCompleted;
|
step === ClaimWorkflowStep.USER_SUBMISSION_COMPLETE ||
|
||||||
|
claimCase.status === ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER;
|
||||||
|
|
||||||
if (!canAdvance) {
|
if (!canAdvance) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
|
|||||||
@@ -15,7 +15,15 @@ import {
|
|||||||
UploadedFile,
|
UploadedFile,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiParam,
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
import { FileInterceptor } from "@nestjs/platform-express";
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
import { diskStorage } from "multer";
|
import { diskStorage } from "multer";
|
||||||
import { extname } from "node:path";
|
import { extname } from "node:path";
|
||||||
@@ -33,9 +41,15 @@ import {
|
|||||||
SelectOuterPartsV2Dto,
|
SelectOuterPartsV2Dto,
|
||||||
SelectOuterPartsV2ResponseDto,
|
SelectOuterPartsV2ResponseDto,
|
||||||
} from "./dto/select-outer-parts-v2.dto";
|
} from "./dto/select-outer-parts-v2.dto";
|
||||||
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
|
import {
|
||||||
|
SelectOtherPartsV2Dto,
|
||||||
|
SelectOtherPartsV2ResponseDto,
|
||||||
|
} from "./dto/select-other-parts-v2.dto";
|
||||||
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||||||
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
|
import {
|
||||||
|
UploadRequiredDocumentV2Dto,
|
||||||
|
UploadRequiredDocumentV2ResponseDto,
|
||||||
|
} from "./dto/upload-document-v2.dto";
|
||||||
import {
|
import {
|
||||||
CapturePartV2Dto,
|
CapturePartV2Dto,
|
||||||
CapturePartV2ResponseDto,
|
CapturePartV2ResponseDto,
|
||||||
@@ -52,7 +66,13 @@ import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
|||||||
@Controller("v2/claim-request-management")
|
@Controller("v2/claim-request-management")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(GlobalGuard, RolesGuard)
|
@UseGuards(GlobalGuard, RolesGuard)
|
||||||
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR)
|
@Roles(
|
||||||
|
RoleEnum.USER,
|
||||||
|
RoleEnum.FIELD_EXPERT,
|
||||||
|
RoleEnum.REGISTRAR,
|
||||||
|
RoleEnum.FILE_MAKER,
|
||||||
|
RoleEnum.FILE_REVIEWER,
|
||||||
|
)
|
||||||
export class ClaimRequestManagementV2Controller {
|
export class ClaimRequestManagementV2Controller {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||||
@@ -142,7 +162,10 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
})
|
})
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
@ApiResponse({ status: 200, description: "Claim returned to expert queue" })
|
@ApiResponse({ status: 200, description: "Claim returned to expert queue" })
|
||||||
@ApiResponse({ status: 400, description: "Resend requires uploads or wrong step" })
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: "Resend requires uploads or wrong step",
|
||||||
|
})
|
||||||
async acknowledgeExpertResend(
|
async acknowledgeExpertResend(
|
||||||
@Param("claimRequestId") claimRequestId: string,
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
@@ -156,7 +179,9 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
error instanceof Error ? error.message : "Failed to acknowledge expert resend",
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to acknowledge expert resend",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,7 +206,10 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
})
|
})
|
||||||
@ApiBody({ type: UserObjectionV2Dto })
|
@ApiBody({ type: UserObjectionV2Dto })
|
||||||
@ApiResponse({ status: 200, description: "Objection stored" })
|
@ApiResponse({ status: 200, description: "Objection stored" })
|
||||||
@ApiResponse({ status: 400, description: "No active resend or empty payload" })
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: "No active resend or empty payload",
|
||||||
|
})
|
||||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||||
@ApiResponse({ status: 409, description: "Objection already submitted" })
|
@ApiResponse({ status: 409, description: "Objection already submitted" })
|
||||||
@@ -222,7 +250,10 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
})
|
})
|
||||||
@ApiBody({ type: UserRatingDto })
|
@ApiBody({ type: UserRatingDto })
|
||||||
@ApiResponse({ status: 200, description: "Rating saved" })
|
@ApiResponse({ status: 200, description: "Rating saved" })
|
||||||
@ApiResponse({ status: 400, description: "Claim not completed or invalid scores" })
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: "Claim not completed or invalid scores",
|
||||||
|
})
|
||||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||||
@ApiResponse({ status: 409, description: "Rating already submitted" })
|
@ApiResponse({ status: 409, description: "Rating already submitted" })
|
||||||
@@ -270,21 +301,32 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
type: "object",
|
type: "object",
|
||||||
required: ["sign", "agree", "branchId"],
|
required: ["sign", "agree", "branchId"],
|
||||||
properties: {
|
properties: {
|
||||||
sign: { type: "string", format: "binary", description: "Signature image" },
|
sign: {
|
||||||
|
type: "string",
|
||||||
|
format: "binary",
|
||||||
|
description: "Signature image",
|
||||||
|
},
|
||||||
agree: {
|
agree: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
description: "true to accept expert pricing and complete the claim",
|
description: "true to accept expert pricing and complete the claim",
|
||||||
},
|
},
|
||||||
branchId: {
|
branchId: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
|
description:
|
||||||
|
"Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
|
||||||
example: "507f1f77bcf86cd799439011",
|
example: "507f1f77bcf86cd799439011",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, description: "Signature stored; claim completed or rejected" })
|
@ApiResponse({
|
||||||
@ApiResponse({ status: 400, description: "Wrong step/status or missing file" })
|
status: 200,
|
||||||
|
description: "Signature stored; claim completed or rejected",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: "Wrong step/status or missing file",
|
||||||
|
})
|
||||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||||
@ApiResponse({ status: 409, description: "Already signed" })
|
@ApiResponse({ status: 409, description: "Already signed" })
|
||||||
@@ -314,7 +356,9 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
}
|
}
|
||||||
await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image");
|
await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image");
|
||||||
const agreed =
|
const agreed =
|
||||||
typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree);
|
typeof agree === "string"
|
||||||
|
? agree === "true" || agree === "1"
|
||||||
|
: Boolean(agree);
|
||||||
try {
|
try {
|
||||||
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
|
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
@@ -453,8 +497,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
})
|
})
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
type: SelectOuterPartsV2Dto,
|
type: SelectOuterPartsV2Dto,
|
||||||
description:
|
description: "Selected vehicle type + selected outer part IDs from catalog",
|
||||||
"Selected vehicle type + selected outer part IDs from catalog",
|
|
||||||
examples: {
|
examples: {
|
||||||
example1: {
|
example1: {
|
||||||
summary: "Sedan - minor front damage",
|
summary: "Sedan - minor front damage",
|
||||||
@@ -522,9 +565,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
error instanceof Error
|
error instanceof Error ? error.message : "Failed to select outer parts",
|
||||||
? error.message
|
|
||||||
: "Failed to select outer parts",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -638,9 +679,7 @@ Optional: upload car green card file in the same step.
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
error instanceof Error
|
error instanceof Error ? error.message : "Failed to select other parts",
|
||||||
? error.message
|
|
||||||
: "Failed to select other parts",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -862,7 +901,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
type: "string",
|
type: "string",
|
||||||
example: "front",
|
example: "front",
|
||||||
description:
|
description:
|
||||||
'For angle: front/back/left/right. For part: hood/front_bumper/etc.',
|
"For angle: front/back/left/right. For part: hood/front_bumper/etc.",
|
||||||
},
|
},
|
||||||
file: {
|
file: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -1005,7 +1044,10 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
description: "Video uploaded successfully",
|
description: "Video uploaded successfully",
|
||||||
type: VideoCaptureV2ResponseDto,
|
type: VideoCaptureV2ResponseDto,
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 400, description: "Wrong workflow step or missing file" })
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: "Wrong workflow step or missing file",
|
||||||
|
})
|
||||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||||
@ApiResponse({ status: 409, description: "Video already uploaded" })
|
@ApiResponse({ status: 409, description: "Video already uploaded" })
|
||||||
@@ -1025,9 +1067,10 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
error instanceof Error ? error.message : "Failed to upload car capture video",
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to upload car capture video",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -770,7 +770,9 @@ export class ExpertBlameService {
|
|||||||
"",
|
"",
|
||||||
},
|
},
|
||||||
needsFileReviewerCompletion:
|
needsFileReviewerCompletion:
|
||||||
String(doc.status ?? "") === CaseStatus.WAITING_FOR_FILE_REVIEWER,
|
String(doc.status ?? "") === CaseStatus.WAITING_FOR_FILE_REVIEWER ||
|
||||||
|
(!!(doc as any).isMadeByFileMaker &&
|
||||||
|
String(doc.status ?? "") === CaseStatus.WAITING_FOR_EXPERT),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import { ResendRequestDto } from "./dto/resend.dto";
|
|||||||
@Controller("v2/expert-blame")
|
@Controller("v2/expert-blame")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT, RoleEnum.FILE_REVIEWER, RoleEnum.FILE_MAKER)
|
@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT)
|
||||||
export class ExpertBlameV2Controller {
|
export class ExpertBlameV2Controller {
|
||||||
constructor(private readonly expertBlameService: ExpertBlameService) {}
|
constructor(private readonly expertBlameService: ExpertBlameService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -2477,6 +2477,104 @@ export class ExpertClaimService {
|
|||||||
throw new HttpException(error.response, error.claimStatus);
|
throw new HttpException(error.response, error.claimStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FILE_REVIEWER path: atomically assign this reviewer to the linked V4 blame
|
||||||
|
* file. Operates on the blame document (not the claim workflow lock).
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - Blame must be isMadeByFileMaker, IN_PERSON, expertInitiated.
|
||||||
|
* - Status must be WAITING_FOR_FILE_REVIEWER.
|
||||||
|
* - No other reviewer may have taken it (assignedFileReviewerId absent/null).
|
||||||
|
* - Idempotent: same reviewer calling again gets "already_assigned_to_you".
|
||||||
|
*/
|
||||||
|
private async assignFileReviewerToV4Blame(
|
||||||
|
claimRequestId: string,
|
||||||
|
claim: any,
|
||||||
|
actor: { sub: string; fullName?: string; clientKey?: string },
|
||||||
|
): Promise<ExpertFileAssignResultDto> {
|
||||||
|
if (!claim.blameRequestId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
success: false,
|
||||||
|
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||||
|
message: "This claim has no linked blame file.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const blame = await this.blameRequestDbService.findById(
|
||||||
|
String(claim.blameRequestId),
|
||||||
|
);
|
||||||
|
if (!blame) {
|
||||||
|
throw new NotFoundException("Linked blame file not found.");
|
||||||
|
}
|
||||||
|
if (!(blame as any).isMadeByFileMaker) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
success: false,
|
||||||
|
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||||
|
message: "Only V4 FileMaker files can be assigned to a FileReviewer.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ((blame as any).status !== "WAITING_FOR_FILE_REVIEWER") {
|
||||||
|
throw new BadRequestException({
|
||||||
|
success: false,
|
||||||
|
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||||
|
message: `Blame file is not ready for FileReviewer. Status: ${(blame as any).status}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = (blame as any).assignedFileReviewerId;
|
||||||
|
if (existing) {
|
||||||
|
if (String(existing) === actor.sub) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: "already_assigned_to_you",
|
||||||
|
message: "You have already taken this file.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new ConflictException({
|
||||||
|
success: false,
|
||||||
|
status: "locked" satisfies ExpertFileAssignStatus,
|
||||||
|
message: "Another FileReviewer has already taken this file.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically claim it — findOneAndUpdate with null/missing guard
|
||||||
|
const reviewerOid = new Types.ObjectId(actor.sub);
|
||||||
|
const updated = await this.blameRequestDbService.findOneAndUpdate(
|
||||||
|
{
|
||||||
|
_id: (blame as any)._id,
|
||||||
|
$or: [
|
||||||
|
{ assignedFileReviewerId: { $exists: false } },
|
||||||
|
{ assignedFileReviewerId: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ $set: { assignedFileReviewerId: reviewerOid } },
|
||||||
|
{ new: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
// Another reviewer won the race
|
||||||
|
throw new ConflictException({
|
||||||
|
success: false,
|
||||||
|
status: "locked" satisfies ExpertFileAssignStatus,
|
||||||
|
message: "Another FileReviewer has already taken this file.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
this.logger.log(
|
||||||
|
`FileReviewer ${actor.sub} assigned to V4 blame ${String((blame as any)._id)} / claim ${claimRequestId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: "assigned",
|
||||||
|
assignedAt: now.toISOString(),
|
||||||
|
message: "File assigned to you. Proceed with accident fields and field capture.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assign the current damage expert to a claim for review (V2).
|
* Assign the current damage expert to a claim for review (V2).
|
||||||
* Free cases are locked atomically; already-assigned-to-self is idempotent.
|
* Free cases are locked atomically; already-assigned-to-self is idempotent.
|
||||||
@@ -2490,7 +2588,10 @@ export class ExpertClaimService {
|
|||||||
role?: string;
|
role?: string;
|
||||||
},
|
},
|
||||||
): Promise<ExpertFileAssignResultDto> {
|
): Promise<ExpertFileAssignResultDto> {
|
||||||
if ((actor as any).role !== RoleEnum.FIELD_EXPERT)
|
if (
|
||||||
|
(actor as any).role !== RoleEnum.FIELD_EXPERT &&
|
||||||
|
(actor as any).role !== RoleEnum.FILE_REVIEWER
|
||||||
|
)
|
||||||
requireActorClientKey(actor);
|
requireActorClientKey(actor);
|
||||||
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||||||
|
|
||||||
@@ -2499,6 +2600,13 @@ export class ExpertClaimService {
|
|||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FILE_REVIEWER: assign themselves to the linked blame (V4 flow).
|
||||||
|
// This is a different path from the damage-expert lock — it operates on the
|
||||||
|
// blame document and is idempotent (same reviewer can re-assign to themselves).
|
||||||
|
if ((actor as any).role === RoleEnum.FILE_REVIEWER) {
|
||||||
|
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
|
||||||
|
}
|
||||||
|
|
||||||
await this.assertExpertActorOnClaim(claim, actor);
|
await this.assertExpertActorOnClaim(claim, actor);
|
||||||
|
|
||||||
const isFieldExpertOwner =
|
const isFieldExpertOwner =
|
||||||
@@ -3652,6 +3760,9 @@ export class ExpertClaimService {
|
|||||||
if (actor.role === RoleEnum.FILE_REVIEWER) {
|
if (actor.role === RoleEnum.FILE_REVIEWER) {
|
||||||
return this.getFileReviewerClaimListV2(actor, query);
|
return this.getFileReviewerClaimListV2(actor, query);
|
||||||
}
|
}
|
||||||
|
if (actor.role === RoleEnum.FILE_MAKER) {
|
||||||
|
return this.getFileMakerClaimListV2(actor, query);
|
||||||
|
}
|
||||||
requireActorClientKey(actor);
|
requireActorClientKey(actor);
|
||||||
const actorId = actor.sub;
|
const actorId = actor.sub;
|
||||||
const clientKey = actor.clientKey as string;
|
const clientKey = actor.clientKey as string;
|
||||||
@@ -3780,7 +3891,7 @@ export class ExpertClaimService {
|
|||||||
blameIds.length > 0
|
blameIds.length > 0
|
||||||
? ((await this.blameRequestDbService.find(
|
? ((await this.blameRequestDbService.find(
|
||||||
{ _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } },
|
{ _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } },
|
||||||
{ lean: true, select: "type parties expert.decision blameStatus" },
|
{ lean: true, select: "type parties expert.decision blameStatus status isMadeByFileMaker" },
|
||||||
)) as any[])
|
)) as any[])
|
||||||
: [];
|
: [];
|
||||||
const blameById = new Map<string, any>(
|
const blameById = new Map<string, any>(
|
||||||
@@ -3916,7 +4027,9 @@ export class ExpertClaimService {
|
|||||||
createdAt: c.createdAt,
|
createdAt: c.createdAt,
|
||||||
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
||||||
needsFileReviewerCompletion:
|
needsFileReviewerCompletion:
|
||||||
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER",
|
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" ||
|
||||||
|
(!!blame?.isMadeByFileMaker &&
|
||||||
|
String(blame?.status ?? "") === "WAITING_FOR_EXPERT"),
|
||||||
};
|
};
|
||||||
}) as ClaimListItemV2Dto[];
|
}) as ClaimListItemV2Dto[];
|
||||||
|
|
||||||
@@ -3924,51 +4037,147 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claim list for FILE_REVIEWER — returns claims linked to sealed expert-initiated
|
* Claim list for FILE_REVIEWER.
|
||||||
* IN_PERSON blame files (WAITING_FOR_FILE_REVIEWER, WAITING_FOR_EXPERT, COMPLETED)
|
*
|
||||||
* that belong to this reviewer's insurance company.
|
* Visibility rules:
|
||||||
|
* - WAITING_FOR_FILE_REVIEWER files made by a FileMaker that are either:
|
||||||
|
* (a) not yet assigned to any reviewer, OR
|
||||||
|
* (b) already assigned to THIS reviewer
|
||||||
|
* - WAITING_FOR_EXPERT files that are assigned to THIS reviewer
|
||||||
|
* (they completed the field work and the file is now in damage-expert queue)
|
||||||
|
*
|
||||||
|
* All scoped to this reviewer's insurance company via blame party clientId.
|
||||||
*/
|
*/
|
||||||
private async getFileReviewerClaimListV2(
|
private async getFileReviewerClaimListV2(
|
||||||
actor: any,
|
actor: any,
|
||||||
query: ListQueryV2Dto = {},
|
query: ListQueryV2Dto = {},
|
||||||
): Promise<GetClaimListV2ResponseDto> {
|
): Promise<GetClaimListV2ResponseDto> {
|
||||||
const clientKey = requireActorClientKey(actor);
|
const clientKey = requireActorClientKey(actor);
|
||||||
|
const reviewerOid = new Types.ObjectId(actor.sub);
|
||||||
|
|
||||||
// Find all sealed blame files (across statuses the reviewer cares about)
|
// Find V4 blame files visible to this reviewer
|
||||||
const sealedBlames = (await this.blameRequestDbService.find(
|
const blames = (await this.blameRequestDbService.find(
|
||||||
{
|
{
|
||||||
|
isMadeByFileMaker: true,
|
||||||
expertInitiated: true,
|
expertInitiated: true,
|
||||||
creationMethod: "IN_PERSON",
|
creationMethod: "IN_PERSON",
|
||||||
status: {
|
$or: [
|
||||||
$in: [
|
// Open: sealed by FileMaker, not yet taken by any reviewer
|
||||||
"WAITING_FOR_FILE_REVIEWER",
|
{
|
||||||
"WAITING_FOR_EXPERT",
|
status: "WAITING_FOR_FILE_REVIEWER",
|
||||||
"COMPLETED",
|
$or: [
|
||||||
],
|
{ assignedFileReviewerId: { $exists: false } },
|
||||||
},
|
{ assignedFileReviewerId: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Taken by this reviewer (any post-assignment status)
|
||||||
|
{ assignedFileReviewerId: reviewerOid },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lean: true,
|
||||||
|
select:
|
||||||
|
"_id type parties blameStatus status expert.decision assignedFileReviewerId",
|
||||||
},
|
},
|
||||||
{ lean: true, select: "_id type parties blameStatus status expert.decision" },
|
|
||||||
)) as any[];
|
)) as any[];
|
||||||
|
|
||||||
if (sealedBlames.length === 0) {
|
if (blames.length === 0) {
|
||||||
return this.paginateClaimListV2([], query);
|
return this.paginateClaimListV2([], query);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scope to this reviewer's insurer via blame party clientId
|
// Scope to this reviewer's insurer via blame party clientId
|
||||||
const scopedBlameIds = sealedBlames
|
const scopedBlames = blames.filter((b) =>
|
||||||
.filter((b) => blameCaseTouchesClient(b, clientKey))
|
blameCaseTouchesClient(b, clientKey),
|
||||||
.map((b) => b._id);
|
);
|
||||||
|
|
||||||
if (scopedBlameIds.length === 0) {
|
if (scopedBlames.length === 0) {
|
||||||
return this.paginateClaimListV2([], query);
|
return this.paginateClaimListV2([], query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scopedBlameIds = scopedBlames.map((b) => b._id);
|
||||||
const claims = (await this.claimCaseDbService.find({
|
const claims = (await this.claimCaseDbService.find({
|
||||||
blameRequestId: { $in: scopedBlameIds },
|
blameRequestId: { $in: scopedBlameIds },
|
||||||
})) as any[];
|
})) as any[];
|
||||||
|
|
||||||
const blameById = new Map<string, any>(
|
const blameById = new Map<string, any>(
|
||||||
sealedBlames.map((b) => [String(b._id), b]),
|
scopedBlames.map((b) => [String(b._id), b]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const list = claims.map((c) => {
|
||||||
|
const blame = c.blameRequestId
|
||||||
|
? blameById.get(c.blameRequestId.toString())
|
||||||
|
: undefined;
|
||||||
|
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
|
||||||
|
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
|
||||||
|
const lockActive = !!(
|
||||||
|
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c)
|
||||||
|
);
|
||||||
|
const assignedToMe =
|
||||||
|
blame?.assignedFileReviewerId &&
|
||||||
|
String(blame.assignedFileReviewerId) === actor.sub;
|
||||||
|
return {
|
||||||
|
claimRequestId: c._id.toString(),
|
||||||
|
publicId: c.publicId,
|
||||||
|
status: c.status,
|
||||||
|
unifiedFileStatus: resolveUnifiedFileStatus({
|
||||||
|
blameStatus: blame?.status,
|
||||||
|
claimStatus: c.status,
|
||||||
|
}),
|
||||||
|
currentStep: c.workflow?.currentStep || "",
|
||||||
|
locked: lockActive,
|
||||||
|
lockedBy:
|
||||||
|
lockActive && c.workflow?.lockedBy
|
||||||
|
? {
|
||||||
|
actorId: c.workflow.lockedBy.actorId?.toString(),
|
||||||
|
actorName: c.workflow.lockedBy.actorName,
|
||||||
|
lockedAt: (c.workflow as any).lockedAt?.toISOString?.(),
|
||||||
|
expiredAt: (c.workflow as any).expiredAt?.toISOString?.(),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
vehicle: v
|
||||||
|
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
|
||||||
|
: undefined,
|
||||||
|
...fileCtx,
|
||||||
|
blameRequestId: c.blameRequestId?.toString(),
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
||||||
|
needsFileReviewerCompletion:
|
||||||
|
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" ||
|
||||||
|
(!!blame?.isMadeByFileMaker &&
|
||||||
|
String(blame?.status ?? "") === "WAITING_FOR_EXPERT"),
|
||||||
|
assignedToMe: !!assignedToMe,
|
||||||
|
};
|
||||||
|
}) as ClaimListItemV2Dto[];
|
||||||
|
|
||||||
|
return this.paginateClaimListV2(list, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim list for FILE_MAKER — returns only the claims linked to blame files
|
||||||
|
* they personally created (V4 flow).
|
||||||
|
*/
|
||||||
|
private async getFileMakerClaimListV2(
|
||||||
|
actor: any,
|
||||||
|
query: ListQueryV2Dto = {},
|
||||||
|
): Promise<GetClaimListV2ResponseDto> {
|
||||||
|
const makerOid = new Types.ObjectId(actor.sub);
|
||||||
|
|
||||||
|
const makerBlames = (await this.blameRequestDbService.find(
|
||||||
|
{ isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid },
|
||||||
|
{ lean: true, select: "_id type parties blameStatus status expert.decision assignedFileReviewerId" },
|
||||||
|
)) as any[];
|
||||||
|
|
||||||
|
if (makerBlames.length === 0) {
|
||||||
|
return this.paginateClaimListV2([], query);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blameIds = makerBlames.map((b) => b._id);
|
||||||
|
const claims = (await this.claimCaseDbService.find({
|
||||||
|
blameRequestId: { $in: blameIds },
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
const blameById = new Map<string, any>(
|
||||||
|
makerBlames.map((b) => [String(b._id), b]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const list = claims.map((c) => {
|
const list = claims.map((c) => {
|
||||||
@@ -4007,7 +4216,12 @@ export class ExpertClaimService {
|
|||||||
createdAt: c.createdAt,
|
createdAt: c.createdAt,
|
||||||
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
||||||
needsFileReviewerCompletion:
|
needsFileReviewerCompletion:
|
||||||
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER",
|
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" ||
|
||||||
|
(!!blame?.isMadeByFileMaker &&
|
||||||
|
String(blame?.status ?? "") === "WAITING_FOR_EXPERT"),
|
||||||
|
assignedFileReviewerId: blame?.assignedFileReviewerId
|
||||||
|
? String(blame.assignedFileReviewerId)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}) as ClaimListItemV2Dto[];
|
}) as ClaimListItemV2Dto[];
|
||||||
|
|
||||||
@@ -4193,7 +4407,8 @@ export class ExpertClaimService {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (
|
if (
|
||||||
(actor as any).role === RoleEnum.FIELD_EXPERT ||
|
(actor as any).role === RoleEnum.FIELD_EXPERT ||
|
||||||
(actor as any).role === RoleEnum.FILE_REVIEWER
|
(actor as any).role === RoleEnum.FILE_REVIEWER ||
|
||||||
|
(actor as any).role === RoleEnum.FILE_MAKER
|
||||||
) return;
|
) return;
|
||||||
const clientKey = requireActorClientKey(actor);
|
const clientKey = requireActorClientKey(actor);
|
||||||
const lockTtlMs = EXPERT_WORKFLOW_LOCK_TTL_MS;
|
const lockTtlMs = EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||||
@@ -4354,7 +4569,10 @@ export class ExpertClaimService {
|
|||||||
actor: any,
|
actor: any,
|
||||||
): Promise<ClaimDetailV2ResponseDto> {
|
): Promise<ClaimDetailV2ResponseDto> {
|
||||||
const actorId = actor.sub;
|
const actorId = actor.sub;
|
||||||
if (actor.role !== RoleEnum.FIELD_EXPERT) {
|
if (
|
||||||
|
actor.role !== RoleEnum.FIELD_EXPERT &&
|
||||||
|
actor.role !== RoleEnum.FILE_MAKER
|
||||||
|
) {
|
||||||
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
|
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
|
||||||
}
|
}
|
||||||
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||||||
@@ -4374,31 +4592,51 @@ export class ExpertClaimService {
|
|||||||
const isFactorValidationPending =
|
const isFactorValidationPending =
|
||||||
claimIsAwaitingExpertFactorValidationV2(claim);
|
claimIsAwaitingExpertFactorValidationV2(claim);
|
||||||
|
|
||||||
// Field experts and FileReviewers can always view IN_PERSON expert-initiated
|
// FileMaker: can always view their own V4 files at any status.
|
||||||
// claims regardless of claim status (they work across multiple non-standard steps).
|
if (actor.role === RoleEnum.FILE_MAKER) {
|
||||||
if (
|
const isOwn = claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame);
|
||||||
|
if (!isOwn) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"FileMakers can only view files they created.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fall through to the detail build below
|
||||||
|
} else if (
|
||||||
|
// Field experts and FileReviewers can view IN_PERSON expert-initiated claims
|
||||||
|
// regardless of claim status (they work across multiple non-standard steps).
|
||||||
actor.role === RoleEnum.FIELD_EXPERT ||
|
actor.role === RoleEnum.FIELD_EXPERT ||
|
||||||
actor.role === RoleEnum.FILE_REVIEWER
|
actor.role === RoleEnum.FILE_REVIEWER
|
||||||
) {
|
) {
|
||||||
if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) {
|
if (actor.role === RoleEnum.FILE_REVIEWER) {
|
||||||
// For FILE_REVIEWER: also accept if the linked blame is sealed for them
|
// FILE_REVIEWER: must be a V4 file AND (they are the assigned reviewer OR
|
||||||
const isFileReviewerEligible =
|
// the file is still open — WAITING_FOR_FILE_REVIEWER with no reviewer yet).
|
||||||
actor.role === RoleEnum.FILE_REVIEWER &&
|
const isV4Blame =
|
||||||
|
(linkedBlame as any)?.isMadeByFileMaker &&
|
||||||
linkedBlame?.expertInitiated &&
|
linkedBlame?.expertInitiated &&
|
||||||
linkedBlame?.creationMethod === "IN_PERSON" &&
|
linkedBlame?.creationMethod === "IN_PERSON";
|
||||||
[
|
if (!isV4Blame) {
|
||||||
"WAITING_FOR_FILE_REVIEWER",
|
|
||||||
"WAITING_FOR_EXPERT",
|
|
||||||
"COMPLETED",
|
|
||||||
].includes(String(linkedBlame?.status ?? ""));
|
|
||||||
if (!isFileReviewerEligible) {
|
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"This claim is not accessible to you.",
|
"FileReviewers can only access V4 FileMaker files.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const assignedReviewerId = (linkedBlame as any)?.assignedFileReviewerId
|
||||||
|
? String((linkedBlame as any).assignedFileReviewerId)
|
||||||
|
: null;
|
||||||
|
const isAssignedToMe =
|
||||||
|
assignedReviewerId && assignedReviewerId === actorId;
|
||||||
|
const isOpen =
|
||||||
|
!assignedReviewerId &&
|
||||||
|
String(linkedBlame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER";
|
||||||
|
if (!isAssignedToMe && !isOpen) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"This file has been taken by another reviewer.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) {
|
||||||
|
throw new ForbiddenException("This claim is not accessible to you.");
|
||||||
}
|
}
|
||||||
// Fall through to the detail build below (skip the damage-expert gate)
|
// Fall through to the detail build below (skip the damage-expert gate)
|
||||||
} else {
|
} else if (actor.role !== RoleEnum.FILE_MAKER) {
|
||||||
const isResendPending =
|
const isResendPending =
|
||||||
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
||||||
|
|
||||||
|
|||||||
@@ -1519,6 +1519,7 @@ export class ExpertInsurerService {
|
|||||||
ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||||||
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
||||||
|
ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER,
|
||||||
ClaimCaseStatus.WAITING_FOR_USER_RESEND,
|
ClaimCaseStatus.WAITING_FOR_USER_RESEND,
|
||||||
ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||||||
ClaimCaseStatus.EXPERT_REVIEWING,
|
ClaimCaseStatus.EXPERT_REVIEWING,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const CLAIM_IN_PROGRESS_STATUSES = new Set<string>([
|
|||||||
ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||||||
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
||||||
|
// V4 split flow: sealed by FileMaker, pending FileReviewer pickup.
|
||||||
|
ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function blameCaseStatusToReportBucket(status: string): string {
|
export function blameCaseStatusToReportBucket(status: string): string {
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const CLAIM_USER_PHASE = new Set<string>([
|
|||||||
ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||||||
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
||||||
|
// V4 split flow: FileMaker sealed the file; FileReviewer hasn't picked it up yet.
|
||||||
|
ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const INSURER_REVIEW_CLAIM_STATUSES = new Set<string>([
|
const INSURER_REVIEW_CLAIM_STATUSES = new Set<string>([
|
||||||
|
|||||||
@@ -109,6 +109,20 @@ export class BlameRequest {
|
|||||||
/** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */
|
/** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */
|
||||||
@Prop({ type: String, enum: FilledBy })
|
@Prop({ type: String, enum: FilledBy })
|
||||||
filledBy?: FilledBy;
|
filledBy?: FilledBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this file was created by a FileMaker (V4 split flow).
|
||||||
|
* These files are sealed by the FileMaker and completed by a FileReviewer.
|
||||||
|
*/
|
||||||
|
@Prop({ default: false })
|
||||||
|
isMadeByFileMaker?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The FileReviewer who claimed this file for completion (V4 flow only).
|
||||||
|
* Set when a FileReviewer calls assign on this file; enforces one-reviewer-per-file.
|
||||||
|
*/
|
||||||
|
@Prop({ type: Types.ObjectId })
|
||||||
|
assignedFileReviewerId?: Types.ObjectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BlameRequestDocument = HydratedDocument<BlameRequest>;
|
export type BlameRequestDocument = HydratedDocument<BlameRequest>;
|
||||||
|
|||||||
@@ -1165,7 +1165,9 @@ export class RequestManagementService {
|
|||||||
{},
|
{},
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
await (req as any).save();
|
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
|
||||||
|
$set: { inquiries: req.inquiries },
|
||||||
|
});
|
||||||
throw new HttpException("Inquiry failed", HttpStatus.BAD_REQUEST);
|
throw new HttpException("Inquiry failed", HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1180,7 +1182,9 @@ export class RequestManagementService {
|
|||||||
raw: inquiryRaw,
|
raw: inquiryRaw,
|
||||||
mapped: inquiryMapped,
|
mapped: inquiryMapped,
|
||||||
});
|
});
|
||||||
await (req as any).save();
|
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
|
||||||
|
$set: { inquiries: req.inquiries },
|
||||||
|
});
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
inquiryMapped.Error.Message || "Inquiry returned error",
|
inquiryMapped.Error.Message || "Inquiry returned error",
|
||||||
HttpStatus.BAD_REQUEST,
|
HttpStatus.BAD_REQUEST,
|
||||||
@@ -1232,7 +1236,9 @@ export class RequestManagementService {
|
|||||||
`[SANDHUB] personal inquiry failed request=${req._id} nationalCode=${personalNationalCode}: ${err?.message || err}`,
|
`[SANDHUB] personal inquiry failed request=${req._id} nationalCode=${personalNationalCode}: ${err?.message || err}`,
|
||||||
);
|
);
|
||||||
this.recordPartyCaseInquiryStatus(req, "person", role, false, {}, err);
|
this.recordPartyCaseInquiryStatus(req, "person", role, false, {}, err);
|
||||||
await (req as any).save();
|
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
|
||||||
|
$set: { inquiries: req.inquiries },
|
||||||
|
});
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
"Personal identity inquiry failed",
|
"Personal identity inquiry failed",
|
||||||
HttpStatus.BAD_REQUEST,
|
HttpStatus.BAD_REQUEST,
|
||||||
@@ -1353,7 +1359,9 @@ export class RequestManagementService {
|
|||||||
{},
|
{},
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
await (req as any).save();
|
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
|
||||||
|
$set: { inquiries: req.inquiries },
|
||||||
|
});
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
"Car body inquiry failed",
|
"Car body inquiry failed",
|
||||||
HttpStatus.BAD_REQUEST,
|
HttpStatus.BAD_REQUEST,
|
||||||
@@ -1422,8 +1430,7 @@ export class RequestManagementService {
|
|||||||
await this.advanceWorkflowToNext(req, stepKey);
|
await this.advanceWorkflowToNext(req, stepKey);
|
||||||
|
|
||||||
// History
|
// History
|
||||||
if (!Array.isArray(req.history)) req.history = [];
|
const historyEntry: any = {
|
||||||
req.history.push({
|
|
||||||
type: `${stepKey}_SUBMITTED`,
|
type: `${stepKey}_SUBMITTED`,
|
||||||
actor: {
|
actor: {
|
||||||
actorId: Types.ObjectId.isValid(user?.sub)
|
actorId: Types.ObjectId.isValid(user?.sub)
|
||||||
@@ -1437,9 +1444,24 @@ export class RequestManagementService {
|
|||||||
companyCode,
|
companyCode,
|
||||||
companyName: clientName,
|
companyName: clientName,
|
||||||
},
|
},
|
||||||
} as any);
|
};
|
||||||
|
|
||||||
await (req as any).save();
|
// Use findByIdAndUpdate instead of save() to avoid Mongoose VersionError
|
||||||
|
// when two parties submit their forms concurrently. Each party only writes
|
||||||
|
// its own paths, so the $set operations are non-overlapping and safe.
|
||||||
|
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
|
||||||
|
$set: {
|
||||||
|
[`parties.${idx}.person`]: party.person,
|
||||||
|
[`parties.${idx}.vehicle`]: party.vehicle,
|
||||||
|
[`parties.${idx}.insurance`]: party.insurance,
|
||||||
|
inquiries: req.inquiries,
|
||||||
|
"workflow.completedSteps": req.workflow.completedSteps,
|
||||||
|
"workflow.currentStep": req.workflow.currentStep,
|
||||||
|
"workflow.nextStep": req.workflow.nextStep,
|
||||||
|
status: req.status,
|
||||||
|
},
|
||||||
|
$push: { history: historyEntry },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requestId: req._id,
|
requestId: req._id,
|
||||||
@@ -4129,15 +4151,12 @@ export class RequestManagementService {
|
|||||||
* Verify the current user (field expert) can access this BlameRequest (v2).
|
* Verify the current user (field expert) can access this BlameRequest (v2).
|
||||||
*/
|
*/
|
||||||
private verifyExpertAccessForBlameV2(req: any, expert: any): void {
|
private verifyExpertAccessForBlameV2(req: any, expert: any): void {
|
||||||
// FILE_REVIEWER can access any sealed expert-initiated IN_PERSON file
|
// FILE_REVIEWER can access V4 FileMaker-sealed files.
|
||||||
// (status WAITING_FOR_FILE_REVIEWER or beyond) scoped to their clientKey.
|
// They must be the assigned reviewer (or the file is still open for taking).
|
||||||
if (expert?.role === RoleEnum.FILE_REVIEWER) {
|
if (expert?.role === RoleEnum.FILE_REVIEWER) {
|
||||||
if (
|
if (!req?.isMadeByFileMaker || !req?.expertInitiated || req?.creationMethod !== CreationMethod.IN_PERSON) {
|
||||||
!req?.expertInitiated ||
|
|
||||||
req?.creationMethod !== CreationMethod.IN_PERSON
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"FileReviewer can only access expert-initiated IN_PERSON files.",
|
"FileReviewer can only access V4 FileMaker files.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -4149,6 +4168,15 @@ export class RequestManagementService {
|
|||||||
"This file has not been sealed by a FileMaker yet.",
|
"This file has not been sealed by a FileMaker yet.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Enforce reviewer assignment: must be assigned to them or open (no reviewer yet)
|
||||||
|
const assignedId = req.assignedFileReviewerId
|
||||||
|
? String(req.assignedFileReviewerId)
|
||||||
|
: null;
|
||||||
|
if (assignedId && assignedId !== String(expert.sub)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"This file has been taken by another FileReviewer.",
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req?.expertInitiated && req?.initiatedByFieldExpertId) {
|
if (req?.expertInitiated && req?.initiatedByFieldExpertId) {
|
||||||
@@ -4419,6 +4447,7 @@ export class RequestManagementService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFileMakerRole = (expert as any)?.role === RoleEnum.FILE_MAKER;
|
||||||
const created = await this.blameRequestDbService.create({
|
const created = await this.blameRequestDbService.create({
|
||||||
publicId,
|
publicId,
|
||||||
requestNo: publicId,
|
requestNo: publicId,
|
||||||
@@ -4438,6 +4467,7 @@ export class RequestManagementService {
|
|||||||
dto.creationMethod === CreationMethod.IN_PERSON
|
dto.creationMethod === CreationMethod.IN_PERSON
|
||||||
? FilledBy.EXPERT
|
? FilledBy.EXPERT
|
||||||
: FilledBy.CUSTOMER,
|
: FilledBy.CUSTOMER,
|
||||||
|
...(isFileMakerRole ? { isMadeByFileMaker: true } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const requestId = String((created as any)._id);
|
const requestId = String((created as any)._id);
|
||||||
|
|||||||
Reference in New Issue
Block a user