FIXED FILE REVIEWER GET ALL

This commit is contained in:
SepehrYahyaee
2026-07-01 16:54:24 +03:30
parent 0c5a2fe38b
commit 6be9ff16e1
5 changed files with 213 additions and 12 deletions

View File

@@ -11,6 +11,12 @@ export enum CaseStatus {
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES", 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", COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED", CANCELLED = "CANCELLED",

View File

@@ -121,6 +121,12 @@ export class AllRequestDtoV2 {
unifiedFileStatus?: string; unifiedFileStatus?: string;
partiesInitialForms: { firstParty: string; secondParty: string }; partiesInitialForms: { firstParty: string; secondParty: string };
partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: 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 { export class AllRequestDtoRsV2 {

View File

@@ -12,6 +12,7 @@ import {
assertBlameCaseForExpertTenant, assertBlameCaseForExpertTenant,
blameCaseAccessibleToExpert, blameCaseAccessibleToExpert,
blameCaseInitiatedByFieldExpert, blameCaseInitiatedByFieldExpert,
blameCaseTouchesClient,
requireActorClientKey, requireActorClientKey,
} from "src/helpers/tenant-scope"; } from "src/helpers/tenant-scope";
import { import {
@@ -438,6 +439,9 @@ export class ExpertBlameService {
if (actor.role === RoleEnum.FIELD_EXPERT) { if (actor.role === RoleEnum.FIELD_EXPERT) {
return this.getFieldExpertBlameListV2(actor, query); return this.getFieldExpertBlameListV2(actor, query);
} }
if (actor.role === RoleEnum.FILE_REVIEWER) {
return this.getFileReviewerBlameListV2(actor, query);
}
requireActorClientKey(actor); requireActorClientKey(actor);
const expertId = actor.sub; const expertId = actor.sub;
@@ -579,6 +583,44 @@ export class ExpertBlameService {
return pagedResult; 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<AllRequestDtoRsV2> {
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<string, unknown>[];
// 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( private async paginateBlameListV2(
visibleCases: Record<string, unknown>[], visibleCases: Record<string, unknown>[],
query: ListQueryV2Dto, query: ListQueryV2Dto,
@@ -727,6 +769,8 @@ export class ExpertBlameService {
secondParty?.vehicle?.inquiry?.mapped?.CarName || secondParty?.vehicle?.inquiry?.mapped?.CarName ||
"", "",
}, },
needsFileReviewerCompletion:
String(doc.status ?? "") === CaseStatus.WAITING_FOR_FILE_REVIEWER,
}; };
} }

View File

@@ -58,6 +58,7 @@ import { RoleEnum } from "src/Types&Enums/role.enum";
import { import {
assertClaimCaseForTenant, assertClaimCaseForTenant,
assertClaimCaseForExpertActor, assertClaimCaseForExpertActor,
blameCaseTouchesClient,
claimCaseInitiatedByFieldExpert, claimCaseInitiatedByFieldExpert,
claimCaseTouchesClient, claimCaseTouchesClient,
requireActorClientKey, requireActorClientKey,
@@ -3618,6 +3619,9 @@ export class ExpertClaimService {
if (actor.role === RoleEnum.FIELD_EXPERT) { if (actor.role === RoleEnum.FIELD_EXPERT) {
return this.getFieldExpertClaimListV2(actor, query); return this.getFieldExpertClaimListV2(actor, query);
} }
if (actor.role === RoleEnum.FILE_REVIEWER) {
return this.getFileReviewerClaimListV2(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;
@@ -3885,6 +3889,96 @@ export class ExpertClaimService {
return this.paginateClaimListV2(list, query); 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<GetClaimListV2ResponseDto> {
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<string, any>(
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`: * Load linked blame case for damage-expert claim detail (same enrichment as expert-blame `findOneV2`:
* party evidence `videoUrl` / `voiceUrls`, Jalali date strings). * party evidence `videoUrl` / `voiceUrls`, Jalali date strings).
@@ -4062,7 +4156,10 @@ export class ExpertClaimService {
clientKey?: string; clientKey?: string;
role?: string; role?: string;
}): Promise<void> { }): Promise<void> {
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 clientKey = requireActorClientKey(actor);
const lockTtlMs = EXPERT_WORKFLOW_LOCK_TTL_MS; const lockTtlMs = EXPERT_WORKFLOW_LOCK_TTL_MS;
const now = new Date(); const now = new Date();
@@ -4242,10 +4339,28 @@ export class ExpertClaimService {
const isFactorValidationPending = const isFactorValidationPending =
claimIsAwaitingExpertFactorValidationV2(claim); claimIsAwaitingExpertFactorValidationV2(claim);
// Field experts can always view their own initiated claims regardless of status // Field experts and FileReviewers can always view IN_PERSON expert-initiated
if (actor.role === RoleEnum.FIELD_EXPERT) { // 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)) { 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) // Fall through to the detail build below (skip the damage-expert gate)
} else { } else {

View File

@@ -4129,6 +4129,28 @@ 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
// (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 (req?.expertInitiated && req?.initiatedByFieldExpertId) {
if (String(req.initiatedByFieldExpertId) !== String(expert?.sub)) { if (String(req.initiatedByFieldExpertId) !== String(expert?.sub)) {
throw new ForbiddenException( throw new ForbiddenException(
@@ -8815,6 +8837,7 @@ export class RequestManagementService {
const fresh = await this.blameRequestDbService.findById(requestId); const fresh = await this.blameRequestDbService.findById(requestId);
if (fresh) { if (fresh) {
const isFileMakerActor = expert?.role === RoleEnum.FILE_MAKER;
if (partyRole === PartyRole.FIRST) { if (partyRole === PartyRole.FIRST) {
this.pushWorkflowSteps(fresh, [ this.pushWorkflowSteps(fresh, [
WorkflowStep.FIRST_LOCATION, WorkflowStep.FIRST_LOCATION,
@@ -8823,14 +8846,17 @@ export class RequestManagementService {
WorkflowStep.FIRST_SIGN, WorkflowStep.FIRST_SIGN,
WorkflowStep.FIRST_COMPLETED, WorkflowStep.FIRST_COMPLETED,
]); ]);
fresh.workflow.currentStep = if (fresh.type === BlameRequestType.THIRD_PARTY) {
fresh.type === BlameRequestType.THIRD_PARTY fresh.workflow.currentStep = WorkflowStep.FIRST_INVITE_SECOND;
? WorkflowStep.FIRST_INVITE_SECOND fresh.workflow.nextStep = WorkflowStep.SECOND_INITIAL_FORM;
: WorkflowStep.FIRST_COMPLETED; } else {
fresh.workflow.nextStep = // CAR_BODY: FileMaker is done — seal the file for FileReviewer pickup
fresh.type === BlameRequestType.THIRD_PARTY fresh.workflow.currentStep = WorkflowStep.FIRST_COMPLETED;
? WorkflowStep.SECOND_INITIAL_FORM fresh.workflow.nextStep = WorkflowStep.WAITING_FOR_GUILT_DECISION;
: WorkflowStep.WAITING_FOR_GUILT_DECISION; if (isFileMakerActor) {
fresh.status = CaseStatus.WAITING_FOR_FILE_REVIEWER;
}
}
} else { } else {
this.pushWorkflowSteps(fresh, [ this.pushWorkflowSteps(fresh, [
WorkflowStep.SECOND_LOCATION, WorkflowStep.SECOND_LOCATION,
@@ -8841,6 +8867,10 @@ export class RequestManagementService {
]); ]);
fresh.workflow.currentStep = WorkflowStep.SECOND_COMPLETED; fresh.workflow.currentStep = WorkflowStep.SECOND_COMPLETED;
fresh.workflow.nextStep = WorkflowStep.WAITING_FOR_GUILT_DECISION; 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(); await (fresh as any).save();
} }