diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index cec1b6a..d126782 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -161,6 +161,7 @@ import { getClaimCaptureProgress, isCapturePhaseDamagedPartyDocKey, isClaimCaptureStepComplete, + OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5, } from "src/helpers/claim-capture-phase"; import { HttpService } from "@nestjs/axios"; import { firstValueFrom } from "rxjs"; @@ -8138,7 +8139,7 @@ export class ClaimRequestManagementService { file: Express.Multer.File, currentUserId: string, actor?: { sub: string; role?: string }, - options?: { v3InPersonFlow?: boolean }, + options?: { v3InPersonFlow?: boolean; skipMetalPlate?: boolean }, ): Promise { try { const claimCase = await this.claimCaseDbService.findById(claimRequestId); @@ -8315,12 +8316,16 @@ export class ClaimRequestManagementService { k === body.documentKey || this.isRequiredDocumentUploadedOnClaim(claimCase, k); remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter( - (k) => !afterThis(k), + (k) => { + if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false; + return !afterThis(k); + }, ).length; if (remaining === 0) { const progressAfterDoc = getClaimCaptureProgress(claimCase, { assumeCapturePhaseDocKey: body.documentKey, + skipMetalPlate: options?.skipMetalPlate, }); if ( @@ -10196,9 +10201,10 @@ export class ClaimRequestManagementService { private shapeCaptureRequirementsForV3( claimCase: any, - _blame: any, + blame: any, base: GetCaptureRequirementsV2ResponseDto, ): GetCaptureRequirementsV2ResponseDto { + const skipMetalPlate = !!(blame as any)?.isMadeByFileMaker; const step = claimCase.workflow?.currentStep; const capturePartDone = this.claimV3StepCompleted( claimCase, @@ -10258,7 +10264,9 @@ export class ClaimRequestManagementService { if (step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES) { const capturePhaseDocs = base.requiredDocuments.filter( - (d) => d.preferUploadDuringCapture, + (d) => + d.preferUploadDuringCapture && + !(skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(d.key as any)), ); return { ...base, @@ -10298,6 +10306,7 @@ export class ClaimRequestManagementService { ); } const blame = await this.assertV3InPersonClaim(claimCase); + const skipMetalPlate = !!(blame as any).isMadeByFileMaker; const isCaptureDoc = isCapturePhaseDamagedPartyDocKey(body.documentKey); @@ -10306,7 +10315,7 @@ export class ClaimRequestManagementService { this.assertV3ClaimWorkflowStep( claimCase, ClaimWorkflowStep.CAPTURE_PART_DAMAGES, - "Upload chassis, engine, and metal plate photos during capture after damaged-part photos and car angles.", + "Upload chassis and engine photos during capture after damaged-part photos and car angles.", ); if ( !this.claimV3StepCompleted( @@ -10318,15 +10327,15 @@ export class ClaimRequestManagementService { "Complete outer and other part selection before capture-phase vehicle documents.", ); } - const captureProgress = getClaimCaptureProgress(claimCase); + const captureProgress = getClaimCaptureProgress(claimCase, { skipMetalPlate }); if (!captureProgress.partsComplete) { throw new BadRequestException( - "Upload photos for all selected damaged parts before chassis, engine, or metal plate photos.", + "Upload photos for all selected damaged parts before chassis or engine photos.", ); } if (!captureProgress.anglesComplete) { throw new BadRequestException( - "Capture all four car angles before chassis, engine, or metal plate photos.", + "Capture all four car angles before chassis or engine photos.", ); } } else { @@ -10346,7 +10355,7 @@ export class ClaimRequestManagementService { file, currentUserId, actor, - { v3InPersonFlow: true }, + { v3InPersonFlow: true, skipMetalPlate }, ); } @@ -10524,7 +10533,8 @@ export class ClaimRequestManagementService { `Claim case with ID ${claimRequestId} not found`, ); } - await this.assertV3InPersonClaim(claimCase); + const blame = await this.assertV3InPersonClaim(claimCase); + const skipMetalPlate = !!(blame as any).isMadeByFileMaker; if (!fileDetail) { throw new BadRequestException("Video file is required."); @@ -10544,7 +10554,7 @@ export class ClaimRequestManagementService { ); } - const captureProgress = getClaimCaptureProgress(claimCase); + const captureProgress = getClaimCaptureProgress(claimCase, { skipMetalPlate }); if (!captureProgress.partsComplete) { throw new BadRequestException( "Upload photos for all selected damaged parts before the walk-around video.", @@ -10555,9 +10565,11 @@ export class ClaimRequestManagementService { "Capture all four car angles before the walk-around video.", ); } - if (!isClaimCaptureStepComplete(claimCase)) { + if (!isClaimCaptureStepComplete(claimCase, { skipMetalPlate })) { throw new BadRequestException( - "Upload capture-phase vehicle documents (chassis, engine, metal plate) before the walk-around video.", + skipMetalPlate + ? "Upload capture-phase vehicle documents (chassis and engine) before the walk-around video." + : "Upload capture-phase vehicle documents (chassis, engine, metal plate) before the walk-around video.", ); } diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index b80c82d..8262187 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -3530,6 +3530,7 @@ export class ExpertClaimService { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { claimStatus: ClaimStatus.NEEDS_REVISION, + status: ClaimCaseStatus.COMPLETED, "workflow.locked": false, $unset: { "workflow.lockedAt": "", @@ -3567,7 +3568,7 @@ export class ExpertClaimService { return { claimRequestId, - status: ClaimWorkflowStep.CLAIM_COMPLETED, + status: ClaimCaseStatus.COMPLETED, claimStatus: ClaimStatus.NEEDS_REVISION, message: "In-person visit requested. User will be notified.", }; @@ -4266,6 +4267,7 @@ export class ExpertClaimService { const blameIds = makerBlames.map((b) => b._id); const claims = (await this.claimCaseDbService.find({ blameRequestId: { $in: blameIds }, + requiresFileMakerApproval: true, })) as any[]; const blameById = new Map( @@ -4690,7 +4692,8 @@ export class ExpertClaimService { const isFactorValidationPending = claimIsAwaitingExpertFactorValidationV2(claim); - // FileMaker: can always view their own V4 files at any status. + // FileMaker: can only view V5 files (requiresFileMakerApproval: true) that they created. + // V4 files do not require FileMaker approval and must not appear in their panel. if (actor.role === RoleEnum.FILE_MAKER) { const isOwn = claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame); if (!isOwn) { @@ -4698,6 +4701,11 @@ export class ExpertClaimService { "FileMakers can only view files they created.", ); } + if (!(claim as any).requiresFileMakerApproval) { + throw new ForbiddenException( + "This file does not require FileMaker approval.", + ); + } // Fall through to the detail build below } else if ( // Field experts and FileReviewers can view IN_PERSON expert-initiated claims diff --git a/src/helpers/claim-capture-phase.ts b/src/helpers/claim-capture-phase.ts index 1c458f8..43e5046 100644 --- a/src/helpers/claim-capture-phase.ts +++ b/src/helpers/claim-capture-phase.ts @@ -16,6 +16,12 @@ export const CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS = [ "damaged_metal_plate", ] as const; +/** Metal-plate keys that are optional in the V4/V5 FileMaker flow. */ +export const OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5 = [ + "damaged_metal_plate", + "guilty_metal_plate", +] as const; + export type CapturePhaseSequence = | "parts" | "angles" @@ -52,7 +58,7 @@ function isRequiredDocumentUploadedOnClaim( export function getClaimCaptureProgress( claimCase: any, - options?: { assumeCapturePhaseDocKey?: string }, + options?: { assumeCapturePhaseDocKey?: string; skipMetalPlate?: boolean }, ): ClaimCaptureProgress { const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined; const selectedNorm = normalizeDamageSelectedParts( @@ -83,6 +89,7 @@ export function getClaimCaptureProgress( const capturePhaseDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter( (k) => { + if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false; if (k === options?.assumeCapturePhaseDocKey) return false; return !isRequiredDocumentUploadedOnClaim(claimCase, k); }, @@ -111,8 +118,11 @@ export function getClaimCaptureProgress( }; } -export function isClaimCaptureStepComplete(claimCase: any): boolean { - const p = getClaimCaptureProgress(claimCase); +export function isClaimCaptureStepComplete( + claimCase: any, + options?: { skipMetalPlate?: boolean }, +): boolean { + const p = getClaimCaptureProgress(claimCase, options); return ( p.partsComplete && p.anglesComplete && p.capturePhaseDocsComplete ); diff --git a/src/request-management/file-reviewer-blame-v4.controller.ts b/src/request-management/file-reviewer-blame-v4.controller.ts index c5cbf8a..167d855 100644 --- a/src/request-management/file-reviewer-blame-v4.controller.ts +++ b/src/request-management/file-reviewer-blame-v4.controller.ts @@ -364,7 +364,6 @@ export class FileReviewerBlameV4Controller { @ApiBody({ schema: { type: "object", - required: ["file"], properties: { file: { type: "string", format: "binary" } }, }, }) @@ -385,17 +384,19 @@ export class FileReviewerBlameV4Controller { @Post("upload-video/:requestId") @ApiParam({ name: "requestId" }) @ApiOperation({ - summary: "Blame accident video (FileReviewer final step)", + summary: "Blame accident video (FileReviewer final step — optional)", description: "Last step of the V4 flow. Requires claim walk-around video and completed capture. " + - "Sets blame status to WAITING_FOR_EXPERT.", + "File is optional — omit it to complete the blame without attaching a video.", }) async uploadBlameVideo( @Param("requestId") requestId: string, @CurrentUser() fileReviewer: any, @UploadedFile() file: Express.Multer.File, ) { - await this.mediaPolicyService.assertForBlame(file, requestId, "video"); + if (file) { + await this.mediaPolicyService.assertForBlame(file, requestId, "video"); + } return this.requestManagementService.expertUploadBlameVideoV3( fileReviewer, requestId, diff --git a/src/request-management/file-reviewer-blame-v5.controller.ts b/src/request-management/file-reviewer-blame-v5.controller.ts index 6159bc5..225cc37 100644 --- a/src/request-management/file-reviewer-blame-v5.controller.ts +++ b/src/request-management/file-reviewer-blame-v5.controller.ts @@ -365,7 +365,6 @@ export class FileReviewerBlameV5Controller { @ApiBody({ schema: { type: "object", - required: ["file"], properties: { file: { type: "string", format: "binary" } }, }, }) @@ -386,17 +385,19 @@ export class FileReviewerBlameV5Controller { @Post("upload-video/:requestId") @ApiParam({ name: "requestId" }) @ApiOperation({ - summary: "Blame accident video (FileReviewer final step — V5)", + summary: "Blame accident video (FileReviewer final step — V5, optional)", description: "Last step of the V5 FileReviewer flow. Requires claim walk-around video and completed capture. " + - "Sets blame status to WAITING_FOR_FINANCIAL_EXPERT (pending FinancialExpert approval).", + "File is optional — omit it to complete the blame without attaching a video.", }) async uploadBlameVideo( @Param("requestId") requestId: string, @CurrentUser() fileReviewer: any, @UploadedFile() file: Express.Multer.File, ) { - await this.mediaPolicyService.assertForBlame(file, requestId, "video"); + if (file) { + await this.mediaPolicyService.assertForBlame(file, requestId, "video"); + } return this.requestManagementService.expertUploadBlameVideoV5( fileReviewer, requestId, diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index f2fb3e2..68ba407 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -8695,13 +8695,16 @@ export class RequestManagementService { partyRole: PartyRole, actor: any, ): void { + // V4/V5 FileMaker files skip the location step — it is auto-completed here. + const skipLocation = !!(req as any).isMadeByFileMaker; + if (partyRole === PartyRole.FIRST) { - this.pushWorkflowSteps(req, [ - WorkflowStep.CREATED, - WorkflowStep.FIRST_INITIAL_FORM, - ]); - req.workflow.currentStep = WorkflowStep.FIRST_LOCATION; - req.workflow.nextStep = WorkflowStep.FIRST_VOICE; + const completedSteps = skipLocation + ? [WorkflowStep.CREATED, WorkflowStep.FIRST_INITIAL_FORM, WorkflowStep.FIRST_LOCATION] + : [WorkflowStep.CREATED, WorkflowStep.FIRST_INITIAL_FORM]; + this.pushWorkflowSteps(req, completedSteps); + req.workflow.currentStep = skipLocation ? WorkflowStep.FIRST_VOICE : WorkflowStep.FIRST_LOCATION; + req.workflow.nextStep = skipLocation ? WorkflowStep.FIRST_DESCRIPTION : WorkflowStep.FIRST_VOICE; const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const guiltyUserId = req.parties?.[firstIdx]?.person?.userId; @@ -8716,9 +8719,12 @@ export class RequestManagementService { (req as any).markModified("expert"); } } else { - this.pushWorkflowSteps(req, [WorkflowStep.SECOND_INITIAL_FORM]); - req.workflow.currentStep = WorkflowStep.SECOND_LOCATION; - req.workflow.nextStep = WorkflowStep.SECOND_VOICE; + const completedSteps = skipLocation + ? [WorkflowStep.SECOND_INITIAL_FORM, WorkflowStep.SECOND_LOCATION] + : [WorkflowStep.SECOND_INITIAL_FORM]; + this.pushWorkflowSteps(req, completedSteps); + req.workflow.currentStep = skipLocation ? WorkflowStep.SECOND_VOICE : WorkflowStep.SECOND_LOCATION; + req.workflow.nextStep = skipLocation ? WorkflowStep.SECOND_DESCRIPTION : WorkflowStep.SECOND_VOICE; } } @@ -9319,21 +9325,31 @@ export class RequestManagementService { async expertUploadBlameVideoV3( expert: any, requestId: string, - file: Express.Multer.File, + file: Express.Multer.File | undefined, ): Promise<{ requestId: string; publicId: string; - videoId: string; + videoId: string | undefined; status: string; message: string; }> { - if (!file) throw new BadRequestException("Video file is required"); - const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); this.assertBlameV3ExpertInPerson(req); await this.verifyExpertAccessForBlameV2(req, expert); + // Idempotent: if blame is already completed (car-capture auto-completed it + // for V4/V5, or a previous call already ran), return success immediately. + if ((req as any).status === CaseStatus.COMPLETED) { + return { + requestId: String(req._id), + publicId: req.publicId, + videoId: undefined, + status: (req as any).status, + message: "File already completed.", + }; + } + if (!(req.expert?.decision as any)?.fields?.accidentWay) { throw new BadRequestException( "Submit accident fields before uploading the blame accident video.", @@ -9375,19 +9391,23 @@ export class RequestManagementService { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; - if (firstParty?.evidence?.videoId) { - throw new ConflictException("Blame accident video already uploaded"); + + // Store video if provided; skip silently for V4/V5 where it is optional. + let videoId: string | undefined; + if (file) { + if (firstParty?.evidence?.videoId) { + throw new ConflictException("Blame accident video already uploaded"); + } + const videoDocument = await this.blameVideoDbService.create({ + fileName: file.filename, + path: file.path, + requestId: new Types.ObjectId(requestId), + } as any); + if (!firstParty.evidence) firstParty.evidence = {} as any; + firstParty.evidence.videoId = String((videoDocument as any)._id); + videoId = firstParty.evidence.videoId; } - const videoDocument = await this.blameVideoDbService.create({ - fileName: file.filename, - path: file.path, - requestId: new Types.ObjectId(requestId), - } as any); - - if (!firstParty.evidence) firstParty.evidence = {} as any; - firstParty.evidence.videoId = String((videoDocument as any)._id); - this.pushWorkflowSteps(req, [ WorkflowStep.FIRST_VIDEO, WorkflowStep.WAITING_FOR_GUILT_DECISION, @@ -9404,7 +9424,7 @@ export class RequestManagementService { actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "field_expert", }, - metadata: { videoId: firstParty.evidence.videoId }, + metadata: { videoId: videoId ?? null }, } as any); if (typeof (req as any).markModified === "function") { @@ -9443,10 +9463,11 @@ export class RequestManagementService { return { requestId: String(req._id), publicId: req.publicId, - videoId: firstParty.evidence.videoId, + videoId, status: req.status, - message: - "Blame accident video uploaded. File is now waiting for expert review.", + message: file + ? "Blame accident video uploaded. File is now waiting for expert review." + : "File completed. Claim is now ready for damage expert review.", }; } @@ -9558,6 +9579,17 @@ export class RequestManagementService { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); await this.verifyExpertAccessForBlameV2(req, actor); + + // V4/V5: location step is skipped — store whatever is sent but do not + // validate against workflow phase (the step was auto-completed at inquiry time). + if ((req as any).isMadeByFileMaker) { + const role = this.resolvePartyRoleV3(req, partyRole); + const idx = this.getPartyIndex(req, role); + if (idx !== -1 && body) req.parties[idx].location = body as any; + await (req as any).save(); + return { requestId: req._id, publicId: req.publicId, partyRole: role }; + } + const role = this.resolvePartyRoleV3(req, partyRole); this.assertBlameV3PartyDetailPhase(req, role); @@ -9677,21 +9709,31 @@ export class RequestManagementService { async expertUploadBlameVideoV5( expert: any, requestId: string, - file: Express.Multer.File, + file: Express.Multer.File | undefined, ): Promise<{ requestId: string; publicId: string; - videoId: string; + videoId: string | undefined; status: string; message: string; }> { - if (!file) throw new BadRequestException("Video file is required"); - const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); this.assertBlameV3ExpertInPerson(req); await this.verifyExpertAccessForBlameV2(req, expert); + // Idempotent: blame already completed (car-capture auto-completed it for + // V4/V5, or a previous call already ran). + if ((req as any).status === CaseStatus.COMPLETED) { + return { + requestId: String(req._id), + publicId: req.publicId, + videoId: undefined, + status: (req as any).status, + message: "File already completed.", + }; + } + if (!(req.expert?.decision as any)?.fields?.accidentWay) { throw new BadRequestException( "Submit accident fields before uploading the blame accident video.", @@ -9733,19 +9775,23 @@ export class RequestManagementService { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstIdx]; - if (firstParty?.evidence?.videoId) { - throw new ConflictException("Blame accident video already uploaded"); + + // Store video if provided; skip silently for V4/V5 where it is optional. + let videoId: string | undefined; + if (file) { + if (firstParty?.evidence?.videoId) { + throw new ConflictException("Blame accident video already uploaded"); + } + const videoDocument = await this.blameVideoDbService.create({ + fileName: file.filename, + path: file.path, + requestId: new Types.ObjectId(requestId), + } as any); + if (!firstParty.evidence) firstParty.evidence = {} as any; + firstParty.evidence.videoId = String((videoDocument as any)._id); + videoId = firstParty.evidence.videoId; } - const videoDocument = await this.blameVideoDbService.create({ - fileName: file.filename, - path: file.path, - requestId: new Types.ObjectId(requestId), - } as any); - - if (!firstParty.evidence) firstParty.evidence = {} as any; - firstParty.evidence.videoId = String((videoDocument as any)._id); - this.pushWorkflowSteps(req, [ WorkflowStep.FIRST_VIDEO, WorkflowStep.WAITING_FOR_GUILT_DECISION, @@ -9762,7 +9808,7 @@ export class RequestManagementService { actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "file_reviewer", }, - metadata: { videoId: firstParty.evidence.videoId }, + metadata: { videoId: videoId ?? null }, } as any); if (typeof (req as any).markModified === "function") { @@ -9808,11 +9854,11 @@ export class RequestManagementService { return { requestId: String(req._id), publicId: req.publicId, - videoId: firstParty.evidence.videoId, + videoId, status: req.status, - message: - "Blame accident video uploaded. File is now in expert review queue. " + - "After the full claim flow completes and the owner signs, the FileMaker must approve before fanavaran submission.", + message: file + ? "Blame accident video uploaded. File is now in expert review queue. After the full claim flow completes and the owner signs, the FileMaker must approve before fanavaran submission." + : "File completed. Claim is now ready for damage expert review.", }; }