Compare commits

...

9 Commits

6 changed files with 397 additions and 35 deletions

View File

@@ -8198,8 +8198,12 @@ export class ClaimRequestManagementService {
);
}
// 4. Validate outer parts haven't been selected yet
// 4. Validate outer parts haven't been selected yet.
// Skip this guard when FileMaker rejected the claim and the FileReviewer
// is updating (overwriting) the previous selection.
const isFileMakerRejectedRerun = !!(claimCase as any).fileMakerRejectedPendingReview;
if (
!isFileMakerRejectedRerun &&
claimCase.damage?.selectedParts &&
claimCase.damage.selectedParts.length > 0
) {
@@ -8266,7 +8270,9 @@ export class ClaimRequestManagementService {
}));
const selectedPartIds = selectedItems.map((p) => p.id);
// 6. Update claim case with selected parts and move to next step
// 6. Update claim case with selected parts and move to next step.
// Also clear fileMakerRejectedPendingReview now that the reviewer has
// actively re-submitted outer parts.
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
claimRequestId,
{
@@ -8276,7 +8282,10 @@ export class ClaimRequestManagementService {
status: ClaimCaseStatus.SELECTING_OTHER_PARTS,
"workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS,
"workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
$unset: { "damage.selectedOuterParts": "" },
$unset: {
"damage.selectedOuterParts": "",
"fileMakerRejectedPendingReview": "",
},
$push: {
"workflow.completedSteps": ClaimWorkflowStep.SELECT_OUTER_PARTS,
history: {
@@ -8401,8 +8410,11 @@ export class ClaimRequestManagementService {
);
}
// 4. Validate other parts and bank info haven't been submitted yet
// 4. Validate other parts and bank info haven't been submitted yet.
// Skip when FileMaker rejected the claim — the FileReviewer can update bank info.
const isFileMakerRejectedRerun = !!(claimCase as any).fileMakerRejectedPendingReview;
if (
!isFileMakerRejectedRerun &&
claimCase.money?.sheba &&
claimCase.money?.nationalCodeOfInsurer &&
claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND

View File

@@ -93,6 +93,28 @@ export class FileMakerBlameV4Controller {
);
}
@Get("my-files")
@ApiOperation({
summary: "List all blame files created by this FileMaker",
description: "Returns all V4 FileMaker blame files initiated by the authenticated FileMaker.",
})
async getMyFiles(@CurrentUser() fileMaker: any) {
return this.requestManagementService.getMyFileMakerFiles(fileMaker);
}
@Get("my-files/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({
summary: "Get a single blame file created by this FileMaker",
description: "Returns full detail — parties, workflow, linked claim ID — for one V4 FileMaker blame file.",
})
async getMyFileDetail(
@Param("requestId") requestId: string,
@CurrentUser() fileMaker: any,
) {
return this.requestManagementService.getMyFileMakerFileDetail(fileMaker, requestId);
}
@Get("claim-id/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({

View File

@@ -92,6 +92,28 @@ export class FileMakerBlameV5Controller {
);
}
@Get("my-files")
@ApiOperation({
summary: "List all blame files created by this FileMaker",
description: "Returns all V5 FileMaker blame files initiated by the authenticated FileMaker.",
})
async getMyFiles(@CurrentUser() fileMaker: any) {
return this.requestManagementService.getMyFileMakerFiles(fileMaker);
}
@Get("my-files/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({
summary: "Get a single blame file created by this FileMaker",
description: "Returns full detail — parties, workflow, linked claim ID — for one V5 FileMaker blame file.",
})
async getMyFileDetail(
@Param("requestId") requestId: string,
@CurrentUser() fileMaker: any,
) {
return this.requestManagementService.getMyFileMakerFileDetail(fileMaker, requestId);
}
@Get("claim-id/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({

View File

@@ -84,6 +84,28 @@ export class FileReviewerBlameV4Controller {
// ─── Linked claim lookup ──────────────────────────────────────────────────────
@Get("my-files")
@ApiOperation({
summary: "List all blame files assigned to this FileReviewer",
description: "Returns all V4 FileMaker blame files that have been assigned to the authenticated FileReviewer.",
})
async getMyFiles(@CurrentUser() fileReviewer: any) {
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
}
@Get("my-files/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({
summary: "Get a single blame file assigned to this FileReviewer",
description: "Returns full detail — parties, workflow, expert fields, linked claim ID — for one V4 blame file.",
})
async getMyFileDetail(
@Param("requestId") requestId: string,
@CurrentUser() fileReviewer: any,
) {
return this.requestManagementService.getMyFileReviewerFileDetail(fileReviewer, requestId);
}
@Get("claim-id/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({

View File

@@ -82,6 +82,28 @@ export class FileReviewerBlameV5Controller {
// ─── Linked claim lookup ──────────────────────────────────────────────────────
@Get("my-files")
@ApiOperation({
summary: "List all blame files assigned to this FileReviewer",
description: "Returns all V5 FileMaker blame files that have been assigned to the authenticated FileReviewer.",
})
async getMyFiles(@CurrentUser() fileReviewer: any) {
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
}
@Get("my-files/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({
summary: "Get a single blame file assigned to this FileReviewer",
description: "Returns full detail — parties, workflow, expert fields, linked claim ID — for one V5 blame file.",
})
async getMyFileDetail(
@Param("requestId") requestId: string,
@CurrentUser() fileReviewer: any,
) {
return this.requestManagementService.getMyFileReviewerFileDetail(fileReviewer, requestId);
}
@Get("claim-id/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({

View File

@@ -272,6 +272,31 @@ export class RequestManagementService {
req: any,
submittedStepKey: WorkflowStep,
) {
// V6 call-center initiated THIRD_PARTY: skip FIRST_INITIAL_FORM because the
// call-center agent already ran the inquiry during run-inquiry.
// After FIRST_VIDEO, jump straight to FIRST_LOCATION.
if (
req.callCenterInitiated &&
req.type === BlameRequestType.THIRD_PARTY &&
submittedStepKey === WorkflowStep.FIRST_VIDEO
) {
const completed = Array.isArray(req.workflow.completedSteps)
? req.workflow.completedSteps
: [];
if (!completed.includes(submittedStepKey)) completed.push(submittedStepKey);
// Also mark FIRST_INITIAL_FORM as completed so the step chain is consistent
if (!completed.includes(WorkflowStep.FIRST_INITIAL_FORM)) {
completed.push(WorkflowStep.FIRST_INITIAL_FORM);
}
req.workflow.completedSteps = completed;
req.workflow.currentStep = WorkflowStep.FIRST_LOCATION;
req.workflow.nextStep = WorkflowStep.FIRST_VOICE;
this.logger.debug(
"[WORKFLOW] V6 call-center: skipped FIRST_INITIAL_FORM after FIRST_VIDEO, advanced to FIRST_LOCATION",
);
return;
}
// CAR_BODY: after first-party description, move to signature step.
if (
req.type === BlameRequestType.CAR_BODY &&
@@ -326,9 +351,15 @@ export class RequestManagementService {
req.creationMethod === CreationMethod.IN_PERSON &&
(req.expertInitiated || req.registrarInitiated);
// V6 call-center THIRD_PARTY: guilt is pre-decided (first party is always guilty),
// so skip the WAITING_FOR_GUILT_DECISION waiting state just like IN_PERSON.
const isCallCenterThirdParty =
req.type === BlameRequestType.THIRD_PARTY &&
!!req.callCenterInitiated;
const resolveStep = (step: WorkflowStep | undefined): WorkflowStep | undefined => {
if (
isInPersonThirdParty &&
(isInPersonThirdParty || isCallCenterThirdParty) &&
step === WorkflowStep.WAITING_FOR_GUILT_DECISION
) {
return WorkflowStep.WAITING_FOR_SIGNATURES;
@@ -1045,26 +1076,38 @@ export class RequestManagementService {
if (!completed.includes(step3Key)) {
completed.push(step3Key);
}
req.workflow.completedSteps = completed;
const nextStepKey = step3.nextPossibleSteps?.[0] as
| WorkflowStep
| undefined;
if (!nextStepKey) {
throw new InternalServerErrorException(
"Workflow stepNumber=3 has no nextPossibleSteps configured",
);
}
const nextStepDoc = await this.getWorkflowStep({ stepKey: nextStepKey });
const afterNext = nextStepDoc.nextPossibleSteps?.[0];
if (!afterNext) {
throw new InternalServerErrorException(
`Workflow step ${nextStepKey} has no nextPossibleSteps configured`,
);
}
// V6 call-center initiated THIRD_PARTY: inquiry already done by agent — skip
// FIRST_INITIAL_FORM and go straight to FIRST_LOCATION.
if (req.callCenterInitiated && req.type === BlameRequestType.THIRD_PARTY) {
if (!completed.includes(WorkflowStep.FIRST_INITIAL_FORM)) {
completed.push(WorkflowStep.FIRST_INITIAL_FORM);
}
req.workflow.completedSteps = completed;
req.workflow.currentStep = WorkflowStep.FIRST_LOCATION;
req.workflow.nextStep = WorkflowStep.FIRST_VOICE;
} else {
req.workflow.completedSteps = completed;
req.workflow.currentStep = nextStepKey;
req.workflow.nextStep = afterNext as WorkflowStep;
const nextStepKey = step3.nextPossibleSteps?.[0] as
| WorkflowStep
| undefined;
if (!nextStepKey) {
throw new InternalServerErrorException(
"Workflow stepNumber=3 has no nextPossibleSteps configured",
);
}
const nextStepDoc = await this.getWorkflowStep({ stepKey: nextStepKey });
const afterNext = nextStepDoc.nextPossibleSteps?.[0];
if (!afterNext) {
throw new InternalServerErrorException(
`Workflow step ${nextStepKey} has no nextPossibleSteps configured`,
);
}
req.workflow.currentStep = nextStepKey;
req.workflow.nextStep = afterNext as WorkflowStep;
}
// History
if (!Array.isArray(req.history)) req.history = [];
@@ -2147,6 +2190,19 @@ export class RequestManagementService {
req.type === BlameRequestType.THIRD_PARTY;
let closedByMutualAgreement = false;
// V6 call-center initiated THIRD_PARTY: first party is always the guilty party.
// If blameStatus is still UNKNOWN here (pre-fix file or race condition), force it to AGREED now.
if (isSecondThirdParty && req.callCenterInitiated && req.blameStatus !== BlameStatus.AGREED) {
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx !== -1) {
if (!req.parties[firstIdx].statement) req.parties[firstIdx].statement = {} as any;
req.parties[firstIdx].statement.admitsGuilt = true;
req.parties[firstIdx].statement.claimsDamage = false;
req.parties[firstIdx].statement.acceptsExpertOpinion = false;
}
req.blameStatus = BlameStatus.AGREED;
}
// THIRD_PARTY + AGREED: guilt/damage already implied by first-party confession — no field expert needed.
if (isSecondThirdParty && req.blameStatus === BlameStatus.AGREED) {
const built = buildMutualAgreementExpertDecision(
@@ -10559,25 +10615,33 @@ export class RequestManagementService {
const newRejectionCount = currentRejections + 1;
const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim();
// Steps that belong to the re-run assessment cycle.
// Pulled from completedSteps so workflow ordering guards pass on re-run.
const assessmentSteps = [
ClaimWorkflowStep.SELECT_OUTER_PARTS,
ClaimWorkflowStep.SELECT_OTHER_PARTS,
ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
];
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
claimStatus: ClaimStatus.NEEDS_REVISION,
fileMakerRejectionCount: newRejectionCount,
fileMakerRejectionReason: reason ?? null,
"workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
// Land directly at SELECT_OUTER_PARTS: the FileReviewer sees the
// existing parts pre-populated and can adjust or confirm them.
"workflow.currentStep": ClaimWorkflowStep.SELECT_OUTER_PARTS,
"workflow.nextStep": ClaimWorkflowStep.SELECT_OTHER_PARTS,
"workflow.locked": false,
// Flag consumed by selectOuterPartsV2 / selectOtherPartsV2 to allow
// overwriting previously submitted data instead of throwing ConflictException.
"fileMakerRejectedPendingReview": true,
},
// Clear all stale lock fields and prior-cycle evaluation data so the
// FileReviewer starts a completely clean lock cycle:
// - workflow lock fields left over from the completed review cycle
// - damageExpertReply / damageExpertResend / ownerInsurerApproval /
// ownerPricedPartsApproval — all belong to the rejected cycle and
// must not bleed into the new one (resend fulfilledAt would block
// submitResendDocsV2; stale reply would mislead the frontend)
// - assignedForReviewBy is intentionally kept so the same reviewer
// is still scoped to this file on re-lock
// Clear stale lock/evaluation fields and media that must be re-captured.
// Part selections (damage.selectedParts, damage.otherParts) are intentionally
// preserved as a pre-populated starting point for the FileReviewer to adjust.
$unset: {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
@@ -10587,6 +10651,15 @@ export class RequestManagementService {
"evaluation.damageExpertResend": "",
"evaluation.ownerInsurerApproval": "",
"evaluation.ownerPricedPartsApproval": "",
// Photos and walk-around video must be re-taken for the new part set.
"media.damagedParts": "",
"media.captures": "",
"media.videoCaptureId": "",
},
// Remove assessment-cycle steps from completedSteps so workflow ordering
// guards pass when the FileReviewer re-runs each step.
$pull: {
"workflow.completedSteps": { $in: assessmentSteps },
},
$push: {
history: {
@@ -10759,7 +10832,9 @@ export class RequestManagementService {
const firstParty = req.parties[firstIdx];
await this.runPartyInquiriesV3Internal(req, dto, PartyRole.FIRST, firstParty);
this.markPartyInquiriesCompleteOnBlame(req, PartyRole.FIRST, agent);
// Do NOT call markPartyInquiriesCompleteOnBlame here: that helper is for V3/V4/V5
// FileMaker flows and would advance the workflow to FIRST_LOCATION prematurely.
// In V6 the workflow must stay at CREATED until send-link advances it to FIRST_VIDEO.
if (!Array.isArray((req as any).history)) (req as any).history = [];
(req as any).history.push({
type: "CALL_CENTER_INQUIRY_COMPLETED",
@@ -10981,6 +11056,193 @@ export class RequestManagementService {
/**
* V6: List blame files created by this call-center agent.
*/
// ─── FileMaker file list / detail (V4 + V5) ────────────────────────────────
async getMyFileMakerFiles(fileMaker: any): Promise<any[]> {
if (fileMaker?.role !== RoleEnum.FILE_MAKER) {
throw new ForbiddenException("Only FileMakers can use this endpoint.");
}
const makerId = new Types.ObjectId(fileMaker.sub);
const files = await this.blameRequestDbService.find({
isMadeByFileMaker: true,
initiatedByFieldExpertId: makerId,
});
return (files || []).map((f: any) => ({
_id: f._id,
publicId: f.publicId,
requestNo: f.requestNo,
type: f.type,
status: f.status,
blameStatus: f.blameStatus,
workflow: {
currentStep: f.workflow?.currentStep,
nextStep: f.workflow?.nextStep,
completedSteps: f.workflow?.completedSteps,
},
requiresFileMakerApproval: f.requiresFileMakerApproval,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
}));
}
async getMyFileMakerFileDetail(fileMaker: any, requestId: string): Promise<any> {
if (fileMaker?.role !== RoleEnum.FILE_MAKER) {
throw new ForbiddenException("Only FileMakers can use this endpoint.");
}
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Blame request not found");
if (
!req.isMadeByFileMaker ||
String((req as any).initiatedByFieldExpertId) !== String(fileMaker.sub)
) {
throw new ForbiddenException("You can only access files that you have created.");
}
const plain = typeof (req as any).toObject === "function"
? (req as any).toObject({ versionKey: false })
: { ...(req as any) };
// Attach linked claim ID if present
const claim = await this.claimCaseDbService.findOne({ blameRequestId: (req as any)._id });
return {
_id: plain._id,
publicId: plain.publicId,
requestNo: plain.requestNo,
type: plain.type,
status: plain.status,
blameStatus: plain.blameStatus,
workflow: plain.workflow,
requiresFileMakerApproval: plain.requiresFileMakerApproval,
parties: (plain.parties ?? []).map((p: any) => ({
role: p.role,
person: {
phoneNumber: p.person?.phoneNumber,
nationalCodeOfInsurer: p.person?.nationalCodeOfInsurer,
nationalCodeOfDriver: p.person?.nationalCodeOfDriver,
driverIsInsurer: p.person?.driverIsInsurer,
userId: p.person?.userId ? String(p.person.userId) : undefined,
clientId: p.person?.clientId ? String(p.person.clientId) : undefined,
},
insurance: p.insurance ? {
company: p.insurance.company,
policyNumber: p.insurance.policyNumber,
startDate: p.insurance.startDate,
endDate: p.insurance.endDate,
financialCeiling: p.insurance.financialCeiling,
} : undefined,
vehicle: p.vehicle ? {
plateId: p.vehicle.plateId,
name: p.vehicle.name,
type: p.vehicle.type,
} : undefined,
inquiryComplete: !!(p.person?.inquiriesCompleted),
hasSigned: p.confirmation != null,
})),
linkedClaimId: claim ? String((claim as any)._id) : null,
createdAt: plain.createdAt,
updatedAt: plain.updatedAt,
};
}
// ─── FileReviewer file list / detail (V4 + V5) ─────────────────────────────
async getMyFileReviewerFiles(fileReviewer: any): Promise<any[]> {
if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) {
throw new ForbiddenException("Only FileReviewers can use this endpoint.");
}
const reviewerId = new Types.ObjectId(fileReviewer.sub);
const files = await this.blameRequestDbService.find({
isMadeByFileMaker: true,
assignedFileReviewerId: reviewerId,
});
return (files || []).map((f: any) => ({
_id: f._id,
publicId: f.publicId,
requestNo: f.requestNo,
type: f.type,
status: f.status,
blameStatus: f.blameStatus,
workflow: {
currentStep: f.workflow?.currentStep,
nextStep: f.workflow?.nextStep,
completedSteps: f.workflow?.completedSteps,
},
requiresFileMakerApproval: f.requiresFileMakerApproval,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
}));
}
async getMyFileReviewerFileDetail(fileReviewer: any, requestId: string): Promise<any> {
if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) {
throw new ForbiddenException("Only FileReviewers can use this endpoint.");
}
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Blame request not found");
if (!req.isMadeByFileMaker) {
throw new ForbiddenException("FileReviewer can only access V4/V5 FileMaker files.");
}
const assignedId = (req as any).assignedFileReviewerId
? String((req as any).assignedFileReviewerId)
: null;
if (assignedId && assignedId !== String(fileReviewer.sub)) {
throw new ForbiddenException("This file has been taken by another FileReviewer.");
}
const plain = typeof (req as any).toObject === "function"
? (req as any).toObject({ versionKey: false })
: { ...(req as any) };
const claim = await this.claimCaseDbService.findOne({ blameRequestId: (req as any)._id });
return {
_id: plain._id,
publicId: plain.publicId,
requestNo: plain.requestNo,
type: plain.type,
status: plain.status,
blameStatus: plain.blameStatus,
workflow: plain.workflow,
requiresFileMakerApproval: plain.requiresFileMakerApproval,
assignedFileReviewerId: plain.assignedFileReviewerId
? String(plain.assignedFileReviewerId)
: null,
parties: (plain.parties ?? []).map((p: any) => ({
role: p.role,
person: {
phoneNumber: p.person?.phoneNumber,
nationalCodeOfInsurer: p.person?.nationalCodeOfInsurer,
nationalCodeOfDriver: p.person?.nationalCodeOfDriver,
driverIsInsurer: p.person?.driverIsInsurer,
userId: p.person?.userId ? String(p.person.userId) : undefined,
clientId: p.person?.clientId ? String(p.person.clientId) : undefined,
},
insurance: p.insurance ? {
company: p.insurance.company,
policyNumber: p.insurance.policyNumber,
startDate: p.insurance.startDate,
endDate: p.insurance.endDate,
financialCeiling: p.insurance.financialCeiling,
} : undefined,
vehicle: p.vehicle ? {
plateId: p.vehicle.plateId,
name: p.vehicle.name,
type: p.vehicle.type,
} : undefined,
inquiryComplete: !!(p.person?.inquiriesCompleted),
hasSigned: p.confirmation != null,
})),
expert: plain.expert ? {
decision: plain.expert.decision ? {
guiltyPartyId: plain.expert.decision.guiltyPartyId
? String(plain.expert.decision.guiltyPartyId)
: undefined,
description: plain.expert.decision.description,
decidedAt: plain.expert.decision.decidedAt,
} : undefined,
accidentFields: plain.expert.accidentFields,
} : undefined,
linkedClaimId: claim ? String((claim as any)._id) : null,
createdAt: plain.createdAt,
updatedAt: plain.updatedAt,
};
}
async getMyCallCenterFilesV6(agent: any): Promise<any[]> {
if (agent?.role !== RoleEnum.CALL_CENTER) {
throw new ForbiddenException("Only call-center agents can use this endpoint.");