1
0
forked from Yara724/api

merge upstream

This commit is contained in:
2026-07-05 15:47:59 +03:30
12 changed files with 443 additions and 100 deletions

View File

@@ -35,6 +35,14 @@ 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",

View File

@@ -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()

View File

@@ -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(

View File

@@ -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",
); );
} }
} }
} }

View File

@@ -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),
}; };
} }

View File

@@ -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) {}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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>([

View File

@@ -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>;

View File

@@ -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);