diff --git a/src/Types&Enums/claim-request-management/claim-case-status.enum.ts b/src/Types&Enums/claim-request-management/claim-case-status.enum.ts index 2476c94..0c0905f 100644 --- a/src/Types&Enums/claim-request-management/claim-case-status.enum.ts +++ b/src/Types&Enums/claim-request-management/claim-case-status.enum.ts @@ -34,7 +34,15 @@ export enum ClaimCaseStatus { /** All required factor files are uploaded; damage expert validates factors (`UNDER_REVIEW` @ `EXPERT_COST_EVALUATION`). */ 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 COMPLETED = "COMPLETED", CANCELLED = "CANCELLED", diff --git a/src/auth/guards/global.guard.ts b/src/auth/guards/global.guard.ts index b709d37..bed0053 100644 --- a/src/auth/guards/global.guard.ts +++ b/src/auth/guards/global.guard.ts @@ -12,6 +12,8 @@ const GLOBAL_GUARD_ROLES = new Set([ RoleEnum.USER, RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR, + RoleEnum.FILE_MAKER, + RoleEnum.FILE_REVIEWER ]); @Injectable() diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index b18f147..d723527 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -8232,10 +8232,12 @@ export class ClaimRequestManagementService { if (allDocumentsUploaded) { if (options?.v3InPersonFlow) { - $set["status"] = ClaimCaseStatus.SELECTING_OUTER_PARTS; - $set["workflow.currentStep"] = - ClaimWorkflowStep.SELECT_OUTER_PARTS; - $set["workflow.nextStep"] = ClaimWorkflowStep.SELECT_OTHER_PARTS; + // V4 split flow: mark the claim as waiting for the FileReviewer. + // The FileReviewer calls accident-fields then select-outer-parts, at + // which point advanceV3ClaimToOuterPartsIfReady() moves the claim to + // SELECTING_OUTER_PARTS / SELECT_OUTER_PARTS. + $set["status"] = ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER; + $set["workflow.nextStep"] = ClaimWorkflowStep.SELECT_OUTER_PARTS; completedStepsEntries.push( ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, @@ -8251,7 +8253,7 @@ export class ClaimRequestManagementService { metadata: { stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, description: - "Initial required documents uploaded. Proceed to outer part selection.", + "FileMaker documents complete. File sealed — awaiting FileReviewer.", v3InPersonFlow: true, }, }); @@ -8367,7 +8369,7 @@ export class ClaimRequestManagementService { ) : allDocumentsUploaded ? 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." : `Document uploaded successfully. ${remaining} documents remaining.`; @@ -8382,7 +8384,7 @@ export class ClaimRequestManagementService { : ClaimWorkflowStep.CAPTURE_PART_DAMAGES : allDocumentsUploaded ? options?.v3InPersonFlow - ? ClaimWorkflowStep.SELECT_OUTER_PARTS + ? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS : ClaimWorkflowStep.USER_SUBMISSION_COMPLETE : ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, 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( claimCase: any, blame: any, @@ -9980,12 +9977,16 @@ export class ClaimRequestManagementService { } const step = claimCase.workflow?.currentStep; - const partsNotStarted = this.v3PartsFlowNotStarted(claimCase); - const prematurelyCompleted = - step === ClaimWorkflowStep.USER_SUBMISSION_COMPLETE && partsNotStarted; + // UPLOAD_REQUIRED_DOCUMENTS: normal V3/V4 path after FileMaker docs. + // WAITING_FOR_FILE_REVIEWER (claim status): same DB step but with the new + // 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 = step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS || - prematurelyCompleted; + step === ClaimWorkflowStep.USER_SUBMISSION_COMPLETE || + claimCase.status === ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER; if (!canAdvance) { throw new BadRequestException( diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts index ff3bf64..0e78a83 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -15,7 +15,15 @@ import { UploadedFile, } from "@nestjs/common"; 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 { diskStorage } from "multer"; import { extname } from "node:path"; @@ -33,9 +41,15 @@ import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto, } 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 { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto"; +import { + UploadRequiredDocumentV2Dto, + UploadRequiredDocumentV2ResponseDto, +} from "./dto/upload-document-v2.dto"; import { CapturePartV2Dto, CapturePartV2ResponseDto, @@ -52,7 +66,13 @@ import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; @Controller("v2/claim-request-management") @ApiBearerAuth() @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 { constructor( private readonly claimRequestManagementService: ClaimRequestManagementService, @@ -142,7 +162,10 @@ export class ClaimRequestManagementV2Controller { }) @ApiParam({ name: "claimRequestId" }) @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( @Param("claimRequestId") claimRequestId: string, @CurrentUser() user: any, @@ -156,7 +179,9 @@ export class ClaimRequestManagementV2Controller { } catch (error) { if (error instanceof HttpException) throw error; 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 }) @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: 404, description: "Claim not found" }) @ApiResponse({ status: 409, description: "Objection already submitted" }) @@ -222,7 +250,10 @@ export class ClaimRequestManagementV2Controller { }) @ApiBody({ type: UserRatingDto }) @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: 404, description: "Claim not found" }) @ApiResponse({ status: 409, description: "Rating already submitted" }) @@ -270,21 +301,32 @@ export class ClaimRequestManagementV2Controller { type: "object", required: ["sign", "agree", "branchId"], properties: { - sign: { type: "string", format: "binary", description: "Signature image" }, + sign: { + type: "string", + format: "binary", + description: "Signature image", + }, agree: { type: "boolean", description: "true to accept expert pricing and complete the claim", }, branchId: { 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", }, }, }, }) - @ApiResponse({ status: 200, description: "Signature stored; claim completed or rejected" }) - @ApiResponse({ status: 400, description: "Wrong step/status or missing file" }) + @ApiResponse({ + 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: 404, description: "Claim not found" }) @ApiResponse({ status: 409, description: "Already signed" }) @@ -314,7 +356,9 @@ export class ClaimRequestManagementV2Controller { } await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image"); const agreed = - typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree); + typeof agree === "string" + ? agree === "true" || agree === "1" + : Boolean(agree); try { return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2( claimRequestId, @@ -453,8 +497,7 @@ export class ClaimRequestManagementV2Controller { }) @ApiBody({ type: SelectOuterPartsV2Dto, - description: - "Selected vehicle type + selected outer part IDs from catalog", + description: "Selected vehicle type + selected outer part IDs from catalog", examples: { example1: { summary: "Sedan - minor front damage", @@ -522,9 +565,7 @@ export class ClaimRequestManagementV2Controller { } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( - error instanceof Error - ? error.message - : "Failed to select outer parts", + error instanceof Error ? error.message : "Failed to select outer parts", ); } } @@ -638,9 +679,7 @@ Optional: upload car green card file in the same step. } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( - error instanceof Error - ? error.message - : "Failed to select other parts", + error instanceof Error ? error.message : "Failed to select other parts", ); } } @@ -862,7 +901,7 @@ Returns status of each item (uploaded/captured or not). type: "string", example: "front", 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: { type: "string", @@ -1005,7 +1044,10 @@ Returns status of each item (uploaded/captured or not). description: "Video uploaded successfully", 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: 404, description: "Claim not found" }) @ApiResponse({ status: 409, description: "Video already uploaded" }) @@ -1025,9 +1067,10 @@ Returns status of each item (uploaded/captured or not). } catch (error) { if (error instanceof HttpException) throw error; 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", ); } } - } diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 8c409db..d0f2073 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -770,7 +770,9 @@ export class ExpertBlameService { "", }, 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), }; } diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index ecf70ae..c0bd256 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -38,7 +38,7 @@ import { ResendRequestDto } from "./dto/resend.dto"; @Controller("v2/expert-blame") @ApiBearerAuth() @UseGuards(LocalActorAuthGuard, RolesGuard) -@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT, RoleEnum.FILE_REVIEWER, RoleEnum.FILE_MAKER) +@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT) export class ExpertBlameV2Controller { constructor(private readonly expertBlameService: ExpertBlameService) {} diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 050fd23..72d780c 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -2477,6 +2477,104 @@ export class ExpertClaimService { 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 { + 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). * Free cases are locked atomically; already-assigned-to-self is idempotent. @@ -2490,7 +2588,10 @@ export class ExpertClaimService { role?: string; }, ): Promise { - 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); await this.expireClaimWorkflowLockV2IfStale(claimRequestId); @@ -2499,6 +2600,13 @@ export class ExpertClaimService { 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); const isFieldExpertOwner = @@ -3652,6 +3760,9 @@ export class ExpertClaimService { if (actor.role === RoleEnum.FILE_REVIEWER) { return this.getFileReviewerClaimListV2(actor, query); } + if (actor.role === RoleEnum.FILE_MAKER) { + return this.getFileMakerClaimListV2(actor, query); + } requireActorClientKey(actor); const actorId = actor.sub; const clientKey = actor.clientKey as string; @@ -3780,7 +3891,7 @@ export class ExpertClaimService { blameIds.length > 0 ? ((await this.blameRequestDbService.find( { _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[]) : []; const blameById = new Map( @@ -3916,7 +4027,9 @@ export class ExpertClaimService { createdAt: c.createdAt, awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c), 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[]; @@ -3924,51 +4037,147 @@ export class ExpertClaimService { } /** - * Claim list for FILE_REVIEWER — returns claims linked to sealed expert-initiated - * IN_PERSON blame files (WAITING_FOR_FILE_REVIEWER, WAITING_FOR_EXPERT, COMPLETED) - * that belong to this reviewer's insurance company. + * Claim list for FILE_REVIEWER. + * + * 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( actor: any, query: ListQueryV2Dto = {}, ): Promise { const clientKey = requireActorClientKey(actor); + const reviewerOid = new Types.ObjectId(actor.sub); - // Find all sealed blame files (across statuses the reviewer cares about) - const sealedBlames = (await this.blameRequestDbService.find( + // Find V4 blame files visible to this reviewer + const blames = (await this.blameRequestDbService.find( { + isMadeByFileMaker: true, expertInitiated: true, creationMethod: "IN_PERSON", - status: { - $in: [ - "WAITING_FOR_FILE_REVIEWER", - "WAITING_FOR_EXPERT", - "COMPLETED", - ], - }, + $or: [ + // Open: sealed by FileMaker, not yet taken by any reviewer + { + status: "WAITING_FOR_FILE_REVIEWER", + $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[]; - if (sealedBlames.length === 0) { + if (blames.length === 0) { return this.paginateClaimListV2([], query); } // Scope to this reviewer's insurer via blame party clientId - const scopedBlameIds = sealedBlames - .filter((b) => blameCaseTouchesClient(b, clientKey)) - .map((b) => b._id); + const scopedBlames = blames.filter((b) => + blameCaseTouchesClient(b, clientKey), + ); - if (scopedBlameIds.length === 0) { + if (scopedBlames.length === 0) { return this.paginateClaimListV2([], query); } + const scopedBlameIds = scopedBlames.map((b) => b._id); const claims = (await this.claimCaseDbService.find({ blameRequestId: { $in: scopedBlameIds }, })) as any[]; const blameById = new Map( - 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 { + 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( + makerBlames.map((b) => [String(b._id), b]), ); const list = claims.map((c) => { @@ -4007,7 +4216,12 @@ export class ExpertClaimService { createdAt: c.createdAt, awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c), 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[]; @@ -4193,7 +4407,8 @@ export class ExpertClaimService { }): Promise { if ( (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; const clientKey = requireActorClientKey(actor); const lockTtlMs = EXPERT_WORKFLOW_LOCK_TTL_MS; @@ -4354,7 +4569,10 @@ export class ExpertClaimService { actor: any, ): Promise { 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.expireClaimWorkflowLockV2IfStale(claimRequestId); @@ -4374,31 +4592,51 @@ export class ExpertClaimService { const isFactorValidationPending = claimIsAwaitingExpertFactorValidationV2(claim); - // Field experts and FileReviewers can always view IN_PERSON expert-initiated - // claims regardless of claim status (they work across multiple non-standard steps). - if ( + // FileMaker: can always view their own V4 files at any status. + if (actor.role === RoleEnum.FILE_MAKER) { + 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.FILE_REVIEWER ) { - if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) { - // For FILE_REVIEWER: also accept if the linked blame is sealed for them - const isFileReviewerEligible = - actor.role === RoleEnum.FILE_REVIEWER && + if (actor.role === RoleEnum.FILE_REVIEWER) { + // FILE_REVIEWER: must be a V4 file AND (they are the assigned reviewer OR + // the file is still open — WAITING_FOR_FILE_REVIEWER with no reviewer yet). + const isV4Blame = + (linkedBlame as any)?.isMadeByFileMaker && linkedBlame?.expertInitiated && - linkedBlame?.creationMethod === "IN_PERSON" && - [ - "WAITING_FOR_FILE_REVIEWER", - "WAITING_FOR_EXPERT", - "COMPLETED", - ].includes(String(linkedBlame?.status ?? "")); - if (!isFileReviewerEligible) { + linkedBlame?.creationMethod === "IN_PERSON"; + if (!isV4Blame) { 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) - } else { + } else if (actor.role !== RoleEnum.FILE_MAKER) { const isResendPending = claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND; diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index ac66e52..fc72998 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -1519,6 +1519,7 @@ export class ExpertInsurerService { ClaimCaseStatus.SELECTING_OTHER_PARTS, ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, ClaimCaseStatus.CAPTURING_PART_DAMAGES, + ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER, ClaimCaseStatus.WAITING_FOR_USER_RESEND, ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, ClaimCaseStatus.EXPERT_REVIEWING, diff --git a/src/helpers/expert-panel-status-report.ts b/src/helpers/expert-panel-status-report.ts index f8b5e63..bcd29ab 100644 --- a/src/helpers/expert-panel-status-report.ts +++ b/src/helpers/expert-panel-status-report.ts @@ -16,6 +16,8 @@ const CLAIM_IN_PROGRESS_STATUSES = new Set([ ClaimCaseStatus.SELECTING_OTHER_PARTS, ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, ClaimCaseStatus.CAPTURING_PART_DAMAGES, + // V4 split flow: sealed by FileMaker, pending FileReviewer pickup. + ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER, ]); export function blameCaseStatusToReportBucket(status: string): string { diff --git a/src/helpers/unified-file-status.ts b/src/helpers/unified-file-status.ts index 06f154b..a1803f4 100644 --- a/src/helpers/unified-file-status.ts +++ b/src/helpers/unified-file-status.ts @@ -31,6 +31,8 @@ const CLAIM_USER_PHASE = new Set([ ClaimCaseStatus.SELECTING_OTHER_PARTS, ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, 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([ diff --git a/src/request-management/entities/schema/blame-cases.schema.ts b/src/request-management/entities/schema/blame-cases.schema.ts index 2df68eb..b6cb33b 100644 --- a/src/request-management/entities/schema/blame-cases.schema.ts +++ b/src/request-management/entities/schema/blame-cases.schema.ts @@ -109,6 +109,20 @@ export class BlameRequest { /** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */ @Prop({ type: String, enum: 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; diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 13493ad..2c7411e 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -1165,7 +1165,9 @@ export class RequestManagementService { {}, err, ); - await (req as any).save(); + await this.blameRequestDbService.findByIdAndUpdate(req._id, { + $set: { inquiries: req.inquiries }, + }); throw new HttpException("Inquiry failed", HttpStatus.BAD_REQUEST); } @@ -1180,7 +1182,9 @@ export class RequestManagementService { raw: inquiryRaw, mapped: inquiryMapped, }); - await (req as any).save(); + await this.blameRequestDbService.findByIdAndUpdate(req._id, { + $set: { inquiries: req.inquiries }, + }); throw new HttpException( inquiryMapped.Error.Message || "Inquiry returned error", HttpStatus.BAD_REQUEST, @@ -1232,7 +1236,9 @@ export class RequestManagementService { `[SANDHUB] personal inquiry failed request=${req._id} nationalCode=${personalNationalCode}: ${err?.message || 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( "Personal identity inquiry failed", HttpStatus.BAD_REQUEST, @@ -1353,7 +1359,9 @@ export class RequestManagementService { {}, err, ); - await (req as any).save(); + await this.blameRequestDbService.findByIdAndUpdate(req._id, { + $set: { inquiries: req.inquiries }, + }); throw new HttpException( "Car body inquiry failed", HttpStatus.BAD_REQUEST, @@ -1422,8 +1430,7 @@ export class RequestManagementService { await this.advanceWorkflowToNext(req, stepKey); // History - if (!Array.isArray(req.history)) req.history = []; - req.history.push({ + const historyEntry: any = { type: `${stepKey}_SUBMITTED`, actor: { actorId: Types.ObjectId.isValid(user?.sub) @@ -1437,9 +1444,24 @@ export class RequestManagementService { companyCode, 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 { requestId: req._id, @@ -4129,15 +4151,12 @@ export class RequestManagementService { * Verify the current user (field expert) can access this BlameRequest (v2). */ private verifyExpertAccessForBlameV2(req: any, expert: any): void { - // FILE_REVIEWER can access any sealed expert-initiated IN_PERSON file - // (status WAITING_FOR_FILE_REVIEWER or beyond) scoped to their clientKey. + // FILE_REVIEWER can access V4 FileMaker-sealed files. + // They must be the assigned reviewer (or the file is still open for taking). if (expert?.role === RoleEnum.FILE_REVIEWER) { - if ( - !req?.expertInitiated || - req?.creationMethod !== CreationMethod.IN_PERSON - ) { + if (!req?.isMadeByFileMaker || !req?.expertInitiated || req?.creationMethod !== CreationMethod.IN_PERSON) { throw new ForbiddenException( - "FileReviewer can only access expert-initiated IN_PERSON files.", + "FileReviewer can only access V4 FileMaker files.", ); } if ( @@ -4149,6 +4168,15 @@ export class RequestManagementService { "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; } 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({ publicId, requestNo: publicId, @@ -4438,6 +4467,7 @@ export class RequestManagementService { dto.creationMethod === CreationMethod.IN_PERSON ? FilledBy.EXPERT : FilledBy.CUSTOMER, + ...(isFileMakerRole ? { isMadeByFileMaker: true } : {}), }); const requestId = String((created as any)._id);