diff --git a/src/Types&Enums/blame-request-management/caseStatus.enum.ts b/src/Types&Enums/blame-request-management/caseStatus.enum.ts index 6c665f2..8ad2c3b 100644 --- a/src/Types&Enums/blame-request-management/caseStatus.enum.ts +++ b/src/Types&Enums/blame-request-management/caseStatus.enum.ts @@ -11,6 +11,12 @@ export enum CaseStatus { WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES", + /** + * FileMaker has collected all signatures; the file is sealed and waiting + * for a FileReviewer to complete it (accident fields → capture → video). + */ + WAITING_FOR_FILE_REVIEWER = "WAITING_FOR_FILE_REVIEWER", + COMPLETED = "COMPLETED", CANCELLED = "CANCELLED", diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 14fcd3f..d2db482 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -6812,10 +6812,14 @@ export class ClaimRequestManagementService { currentUserId: string, actorRole?: string, ): Promise { - if ( - ![RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR].includes(actorRole as any) || - !claimCase?.blameRequestId - ) { + const isFieldLikeRole = [ + RoleEnum.FIELD_EXPERT, + RoleEnum.REGISTRAR, + RoleEnum.FILE_MAKER, + RoleEnum.FILE_REVIEWER, + ].includes(actorRole as any); + + if (!isFieldLikeRole || !claimCase?.blameRequestId) { return currentUserId; } const blameRequest = await this.blameRequestDbService.findById( @@ -6823,7 +6827,7 @@ export class ClaimRequestManagementService { ); if (!blameRequest || blameRequest.creationMethod !== "IN_PERSON") return currentUserId; - if (actorRole === RoleEnum.FIELD_EXPERT) { + if (actorRole === RoleEnum.FIELD_EXPERT || actorRole === RoleEnum.FILE_MAKER) { if ( !blameRequest.expertInitiated || !blameRequest.initiatedByFieldExpertId || @@ -6831,7 +6835,7 @@ export class ClaimRequestManagementService { ) { return currentUserId; } - } else { + } else if (actorRole === RoleEnum.REGISTRAR) { if ( !blameRequest.registrarInitiated || !blameRequest.initiatedByRegistrarId || @@ -6840,6 +6844,8 @@ export class ClaimRequestManagementService { return currentUserId; } } + // FILE_REVIEWER: no initiator-id check — they pick up any IN_PERSON expert + // file after the FileMaker seals it, so we resolve the owner unconditionally. return ( resolveDamagedPartyUserId(blameRequest) ?? (claimCase.owner?.userId ? String(claimCase.owner.userId) : currentUserId) @@ -9794,8 +9800,14 @@ export class ClaimRequestManagementService { return !!party?.confirmation; } - private assertV3ClaimDocumentsPhase(blame: any): void { - if (!(blame.expert?.decision as any)?.fields?.accidentWay) { + private assertV3ClaimDocumentsPhase( + blame: any, + options?: { skipAccidentFieldsCheck?: boolean }, + ): void { + if ( + !options?.skipAccidentFieldsCheck && + !(blame.expert?.decision as any)?.fields?.accidentWay + ) { throw new BadRequestException( "Submit accident fields before uploading required documents.", ); @@ -10079,7 +10091,9 @@ export class ClaimRequestManagementService { ); } } else { - this.assertV3ClaimDocumentsPhase(blame); + this.assertV3ClaimDocumentsPhase(blame, { + skipAccidentFieldsCheck: actor?.role === "file_maker", + }); if (!this.isV3InitialDocumentsPhase(claimCase)) { throw new BadRequestException( `Upload initial required documents during ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS} before part selection. Current step: ${claimCase.workflow?.currentStep}`, @@ -10111,7 +10125,9 @@ export class ClaimRequestManagementService { const blame = await this.assertV3InPersonClaim(claimCase); if (this.isV3InitialDocumentsPhase(claimCase)) { - this.assertV3ClaimDocumentsPhase(blame); + this.assertV3ClaimDocumentsPhase(blame, { + skipAccidentFieldsCheck: actor?.role === "file_maker", + }); } else if ( claimCase.workflow?.currentStep === ClaimWorkflowStep.CAPTURE_PART_DAMAGES ) { diff --git a/src/expert-blame/dto/all-request.dto.ts b/src/expert-blame/dto/all-request.dto.ts index 0664bc3..c2fb6a3 100644 --- a/src/expert-blame/dto/all-request.dto.ts +++ b/src/expert-blame/dto/all-request.dto.ts @@ -121,6 +121,12 @@ export class AllRequestDtoV2 { unifiedFileStatus?: string; partiesInitialForms: { firstParty: string; secondParty: string }; partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: string }; + /** + * True when the file is in WAITING_FOR_FILE_REVIEWER status — the FileReviewer + * must complete accident fields, capture, and upload-video via v4 endpoints + * before the file enters the normal expert-blame review queue. + */ + needsFileReviewerCompletion?: boolean; } export class AllRequestDtoRsV2 { diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 753f543..8c409db 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -12,6 +12,7 @@ import { assertBlameCaseForExpertTenant, blameCaseAccessibleToExpert, blameCaseInitiatedByFieldExpert, + blameCaseTouchesClient, requireActorClientKey, } from "src/helpers/tenant-scope"; import { @@ -438,6 +439,9 @@ export class ExpertBlameService { if (actor.role === RoleEnum.FIELD_EXPERT) { return this.getFieldExpertBlameListV2(actor, query); } + if (actor.role === RoleEnum.FILE_REVIEWER) { + return this.getFileReviewerBlameListV2(actor, query); + } requireActorClientKey(actor); const expertId = actor.sub; @@ -579,6 +583,44 @@ export class ExpertBlameService { return pagedResult; } + /** + * Blame inbox for FILE_REVIEWER — all expert-initiated IN_PERSON files that + * have been sealed by a FileMaker (WAITING_FOR_FILE_REVIEWER) and belong to + * this reviewer's insurance company (clientKey). + * + * Once the FileReviewer completes their steps the file moves to + * WAITING_FOR_EXPERT (via upload-video), after which it is visible in the + * standard expert-blame panel as usual. + */ + private async getFileReviewerBlameListV2( + actor: { sub: string; clientKey?: string }, + query: ListQueryV2Dto = {}, + ): Promise { + const clientKey = requireActorClientKey(actor); + + const allSealed = (await this.blameRequestDbService.find( + { + expertInitiated: true, + status: { + $in: [ + CaseStatus.WAITING_FOR_FILE_REVIEWER, + CaseStatus.WAITING_FOR_EXPERT, + CaseStatus.COMPLETED, + ], + }, + }, + { lean: true }, + )) as Record[]; + + // Scope to this reviewer's insurance company via blame party clientId + const visibleCases = allSealed.filter((doc) => + blameCaseTouchesClient(doc, clientKey), + ); + + const pagedResult = await this.paginateBlameListV2(visibleCases, query); + return pagedResult; + } + private async paginateBlameListV2( visibleCases: Record[], query: ListQueryV2Dto, @@ -727,6 +769,8 @@ export class ExpertBlameService { secondParty?.vehicle?.inquiry?.mapped?.CarName || "", }, + needsFileReviewerCompletion: + String(doc.status ?? "") === CaseStatus.WAITING_FOR_FILE_REVIEWER, }; } diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 611723e..dd505c5 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -59,6 +59,7 @@ import { RoleEnum } from "src/Types&Enums/role.enum"; import { assertClaimCaseForTenant, assertClaimCaseForExpertActor, + blameCaseTouchesClient, claimCaseInitiatedByFieldExpert, claimCaseTouchesClient, requireActorClientKey, @@ -3648,6 +3649,9 @@ export class ExpertClaimService { if (actor.role === RoleEnum.FIELD_EXPERT) { return this.getFieldExpertClaimListV2(actor, query); } + if (actor.role === RoleEnum.FILE_REVIEWER) { + return this.getFileReviewerClaimListV2(actor, query); + } requireActorClientKey(actor); const actorId = actor.sub; const clientKey = actor.clientKey as string; @@ -3915,6 +3919,96 @@ export class ExpertClaimService { return this.paginateClaimListV2(list, query); } + /** + * 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. + */ + private async getFileReviewerClaimListV2( + actor: any, + query: ListQueryV2Dto = {}, + ): Promise { + const clientKey = requireActorClientKey(actor); + + // Find all sealed blame files (across statuses the reviewer cares about) + const sealedBlames = (await this.blameRequestDbService.find( + { + expertInitiated: true, + creationMethod: "IN_PERSON", + status: { + $in: [ + "WAITING_FOR_FILE_REVIEWER", + "WAITING_FOR_EXPERT", + "COMPLETED", + ], + }, + }, + { lean: true, select: "_id type parties blameStatus status expert.decision" }, + )) as any[]; + + if (sealedBlames.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); + + if (scopedBlameIds.length === 0) { + return this.paginateClaimListV2([], query); + } + + const claims = (await this.claimCaseDbService.find({ + blameRequestId: { $in: scopedBlameIds }, + })) as any[]; + + const blameById = new Map( + sealedBlames.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) + ); + 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, + createdAt: c.createdAt, + awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c), + needsFileReviewerCompletion: + String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER", + }; + }) as ClaimListItemV2Dto[]; + + return this.paginateClaimListV2(list, query); + } + /** * Load linked blame case for damage-expert claim detail (same enrichment as expert-blame `findOneV2`: * party evidence `videoUrl` / `voiceUrls`, Jalali date strings). @@ -4092,7 +4186,10 @@ export class ExpertClaimService { clientKey?: string; role?: string; }): Promise { - if ((actor as any).role === RoleEnum.FIELD_EXPERT) return; + if ( + (actor as any).role === RoleEnum.FIELD_EXPERT || + (actor as any).role === RoleEnum.FILE_REVIEWER + ) return; const clientKey = requireActorClientKey(actor); const lockTtlMs = EXPERT_WORKFLOW_LOCK_TTL_MS; const now = new Date(); @@ -4272,10 +4369,28 @@ export class ExpertClaimService { const isFactorValidationPending = claimIsAwaitingExpertFactorValidationV2(claim); - // Field experts can always view their own initiated claims regardless of status - if (actor.role === RoleEnum.FIELD_EXPERT) { + // Field experts and FileReviewers can always view IN_PERSON expert-initiated + // claims regardless of claim status (they work across multiple non-standard steps). + if ( + actor.role === RoleEnum.FIELD_EXPERT || + actor.role === RoleEnum.FILE_REVIEWER + ) { if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) { - throw new ForbiddenException("This claim was not initiated by you."); + // For FILE_REVIEWER: also accept if the linked blame is sealed for them + const isFileReviewerEligible = + actor.role === RoleEnum.FILE_REVIEWER && + linkedBlame?.expertInitiated && + linkedBlame?.creationMethod === "IN_PERSON" && + [ + "WAITING_FOR_FILE_REVIEWER", + "WAITING_FOR_EXPERT", + "COMPLETED", + ].includes(String(linkedBlame?.status ?? "")); + if (!isFileReviewerEligible) { + throw new ForbiddenException( + "This claim is not accessible to you.", + ); + } } // Fall through to the detail build below (skip the damage-expert gate) } else { diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 49572d0..13493ad 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -4129,6 +4129,28 @@ 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. + if (expert?.role === RoleEnum.FILE_REVIEWER) { + if ( + !req?.expertInitiated || + req?.creationMethod !== CreationMethod.IN_PERSON + ) { + throw new ForbiddenException( + "FileReviewer can only access expert-initiated IN_PERSON files.", + ); + } + if ( + req.status !== CaseStatus.WAITING_FOR_FILE_REVIEWER && + req.status !== CaseStatus.WAITING_FOR_EXPERT && + req.status !== CaseStatus.COMPLETED + ) { + throw new ForbiddenException( + "This file has not been sealed by a FileMaker yet.", + ); + } + return; + } if (req?.expertInitiated && req?.initiatedByFieldExpertId) { if (String(req.initiatedByFieldExpertId) !== String(expert?.sub)) { throw new ForbiddenException( @@ -8815,6 +8837,7 @@ export class RequestManagementService { const fresh = await this.blameRequestDbService.findById(requestId); if (fresh) { + const isFileMakerActor = expert?.role === RoleEnum.FILE_MAKER; if (partyRole === PartyRole.FIRST) { this.pushWorkflowSteps(fresh, [ WorkflowStep.FIRST_LOCATION, @@ -8823,14 +8846,17 @@ export class RequestManagementService { WorkflowStep.FIRST_SIGN, WorkflowStep.FIRST_COMPLETED, ]); - fresh.workflow.currentStep = - fresh.type === BlameRequestType.THIRD_PARTY - ? WorkflowStep.FIRST_INVITE_SECOND - : WorkflowStep.FIRST_COMPLETED; - fresh.workflow.nextStep = - fresh.type === BlameRequestType.THIRD_PARTY - ? WorkflowStep.SECOND_INITIAL_FORM - : WorkflowStep.WAITING_FOR_GUILT_DECISION; + if (fresh.type === BlameRequestType.THIRD_PARTY) { + fresh.workflow.currentStep = WorkflowStep.FIRST_INVITE_SECOND; + fresh.workflow.nextStep = WorkflowStep.SECOND_INITIAL_FORM; + } else { + // CAR_BODY: FileMaker is done — seal the file for FileReviewer pickup + fresh.workflow.currentStep = WorkflowStep.FIRST_COMPLETED; + fresh.workflow.nextStep = WorkflowStep.WAITING_FOR_GUILT_DECISION; + if (isFileMakerActor) { + fresh.status = CaseStatus.WAITING_FOR_FILE_REVIEWER; + } + } } else { this.pushWorkflowSteps(fresh, [ WorkflowStep.SECOND_LOCATION, @@ -8841,6 +8867,10 @@ export class RequestManagementService { ]); fresh.workflow.currentStep = WorkflowStep.SECOND_COMPLETED; fresh.workflow.nextStep = WorkflowStep.WAITING_FOR_GUILT_DECISION; + // THIRD_PARTY: second party sign is FileMaker's last step — seal for FileReviewer + if (isFileMakerActor) { + fresh.status = CaseStatus.WAITING_FOR_FILE_REVIEWER; + } } await (fresh as any).save(); }