fix claim validation and expert branch scoping

This commit is contained in:
SepehrYahyaee
2026-09-20 15:00:46 +03:30
parent 4ef53f2cc9
commit e06178f804
13 changed files with 640 additions and 29 deletions

View File

@@ -418,14 +418,145 @@ export class ExpertClaimService {
claim: any,
actor: any,
): Promise<void> {
const blame = await this.loadBlameForClaim(claim);
// FILE_REVIEWER: by the time we reach this method the caller has already
// verified that actor.sub === blame.assignedFileReviewerId (Phase 2 of
// assignClaimForReviewV2). The generic tenant-scope check would incorrectly
// reject them because initiatedByFieldExpertId belongs to the FileMaker, not
// the reviewer. Skip it — the assignment check is the access proof.
if ((actor as any).role === RoleEnum.FILE_REVIEWER) return;
const blame = await this.loadBlameForClaim(claim);
if ((actor as any).role === RoleEnum.FILE_REVIEWER) {
await this.assertActorBranchAccess(claim, actor, blame);
return;
}
assertClaimCaseForExpertActor(claim, actor, blame);
await this.assertActorBranchAccess(claim, actor, blame);
}
private async assertActorBranchAccess(
claim: any,
actor: any,
blame?: any,
): Promise<void> {
const caseBranchId = claim?.branchId
? String(claim.branchId)
: blame?.branchId
? String(blame.branchId)
: undefined;
if (actor?.role === RoleEnum.FILE_REVIEWER) {
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
fileMakerId: blame?.initiatedByFieldExpertId
? String(blame.initiatedByFieldExpertId)
: null,
fileReviewerId: String(actor.sub),
caseBranchId,
});
return;
}
if (actor?.role === RoleEnum.FILE_MAKER) {
const actorBranchId = await this.fanavaranLocationService.fileMakerBranchId(
String(actor.sub),
);
if (!actorBranchId || (caseBranchId && caseBranchId !== actorBranchId)) {
throw new ForbiddenException("This file belongs to another branch.");
}
return;
}
if (actor?.role === RoleEnum.FIELD_EXPERT) return;
const damageExpert = await this.damageExpertDbService.findById(
String(actor.sub),
);
const actorBranchId = (damageExpert as any)?.branchId
? String((damageExpert as any).branchId)
: undefined;
if (!actorBranchId) {
throw new ForbiddenException(
"DamageExpert account is not assigned to a branch.",
);
}
// Ordinary user-created legacy claims have no branch snapshot. Preserve
// their existing tenant queue; branch-created claims must match exactly.
if (caseBranchId && caseBranchId !== actorBranchId) {
throw new ForbiddenException("This file belongs to another branch.");
}
}
private async filterDamageExpertClaimsByBranch(
claims: any[],
actor: any,
): Promise<any[]> {
const damageExpert = await this.damageExpertDbService.findById(
String(actor.sub),
);
const actorBranchId = (damageExpert as any)?.branchId
? String((damageExpert as any).branchId)
: undefined;
if (!actorBranchId) {
throw new ForbiddenException(
"DamageExpert account is not assigned to a branch.",
);
}
const missingBranchBlameIds = [
...new Set(
claims
.filter((claim) => !claim?.branchId && claim?.blameRequestId)
.map((claim) => String(claim.blameRequestId)),
),
];
const blames = missingBranchBlameIds.length
? ((await this.blameRequestDbService.find(
{
_id: {
$in: missingBranchBlameIds.map((id) => new Types.ObjectId(id)),
},
},
{
lean: true,
select:
"_id branchId isMadeByFileMaker initiatedByFieldExpertId",
},
)) as any[])
: [];
const blameById = new Map(
blames.map((blame) => [String(blame._id), blame] as const),
);
const makerBranchById = new Map<string, string | undefined>();
await Promise.all(
[
...new Set(
blames
.filter(
(blame) =>
blame.isMadeByFileMaker &&
!blame.branchId &&
blame.initiatedByFieldExpertId,
)
.map((blame) => String(blame.initiatedByFieldExpertId)),
),
].map(async (makerId) => {
makerBranchById.set(
makerId,
await this.fanavaranLocationService.fileMakerBranchId(makerId),
);
}),
);
return claims.filter((claim) => {
let caseBranchId = claim?.branchId ? String(claim.branchId) : undefined;
if (!caseBranchId && claim?.blameRequestId) {
const blame = blameById.get(String(claim.blameRequestId));
caseBranchId = blame?.branchId
? String(blame.branchId)
: blame?.isMadeByFileMaker && blame?.initiatedByFieldExpertId
? makerBranchById.get(String(blame.initiatedByFieldExpertId))
: undefined;
}
return !caseBranchId || caseBranchId === actorBranchId;
});
}
private async fieldExpertOwnsClaim(
@@ -2600,6 +2731,15 @@ export class ExpertClaimService {
"This file does not belong to your organization.",
);
}
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
fileMakerId: (blame as any)?.initiatedByFieldExpertId
? String((blame as any).initiatedByFieldExpertId)
: null,
fileReviewerId: actor.sub,
caseBranchId: (blame as any)?.branchId
? String((blame as any).branchId)
: null,
});
if (
!(blame as any).isMadeByFileMaker ||
!(blame as any).expertInitiated ||
@@ -2732,6 +2872,15 @@ export class ExpertClaimService {
"This file does not belong to your organization.",
);
}
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
fileMakerId: (reviewerBlame as any).initiatedByFieldExpertId
? String((reviewerBlame as any).initiatedByFieldExpertId)
: null,
fileReviewerId: actor.sub,
caseBranchId: (reviewerBlame as any).branchId
? String((reviewerBlame as any).branchId)
: null,
});
const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId
? String((reviewerBlame as any).assignedFileReviewerId)
: null;
@@ -4121,9 +4270,13 @@ export class ExpertClaimService {
],
});
const filtered = (claims as any[]).filter((c) =>
const tenantFiltered = (claims as any[]).filter((c) =>
claimCaseTouchesClient(c, clientKey),
);
const filtered = await this.filterDamageExpertClaimsByBranch(
tenantFiltered,
actor,
);
// Reconcile stale locks — if no decision was made, also clear assignedForReviewBy
const staleLockToReconcile = filtered.filter(
@@ -4389,7 +4542,7 @@ export class ExpertClaimService {
{
lean: true,
select:
"_id type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval",
"_id branchId initiatedByFieldExpertId type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval",
},
)) as any[];
@@ -4398,9 +4551,31 @@ export class ExpertClaimService {
}
// Scope to this reviewer's insurer via blame party clientId
const scopedBlames = blames.filter((b) =>
const tenantScopedBlames = blames.filter((b) =>
blameCaseTouchesClient(b, clientKey),
);
const scopedBlames = (
await Promise.all(
tenantScopedBlames.map(async (blame) => {
try {
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible(
{
fileMakerId: blame.initiatedByFieldExpertId
? String(blame.initiatedByFieldExpertId)
: null,
fileReviewerId: actor.sub,
caseBranchId: blame.branchId
? String(blame.branchId)
: null,
},
);
return blame;
} catch {
return null;
}
}),
)
).filter((blame): blame is any => blame != null);
if (scopedBlames.length === 0) {
return this.paginateClaimListV2([], query);
@@ -4478,10 +4653,25 @@ export class ExpertClaimService {
): Promise<GetClaimListV2ResponseDto> {
const makerOid = new Types.ObjectId(actor.sub);
const makerBlames = (await this.blameRequestDbService.find(
const ownMakerBlames = (await this.blameRequestDbService.find(
{ isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid },
{ lean: true, select: "_id type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval" },
{ lean: true, select: "_id branchId type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval" },
)) as any[];
const hasBranchScopedFiles = ownMakerBlames.some(
(blame) => !!blame.branchId,
);
const makerBranchId = hasBranchScopedFiles
? await this.fanavaranLocationService.fileMakerBranchId(actor.sub)
: undefined;
if (hasBranchScopedFiles && !makerBranchId) {
throw new ForbiddenException(
"FileMaker account is not assigned to a branch.",
);
}
const makerBlames = ownMakerBlames.filter(
(blame) =>
!blame.branchId || String(blame.branchId) === makerBranchId,
);
if (makerBlames.length === 0) {
return this.paginateClaimListV2([], query);
@@ -4920,6 +5110,7 @@ export class ExpertClaimService {
if (actor.role !== RoleEnum.FILE_REVIEWER) {
assertClaimCaseForExpertActor(claim, actor, linkedBlame);
}
await this.assertActorBranchAccess(claim, actor, linkedBlame);
// Variables used both in the gate block and in the detail-build section below
const isDamageExpertPhase =