forked from Yara724/api
fix claim validation and expert branch scoping
This commit is contained in:
@@ -515,6 +515,9 @@ describe("ExpertClaimService FileReviewer assignment", () => {
|
||||
],
|
||||
}),
|
||||
};
|
||||
service.fanavaranLocationService = {
|
||||
assertMakerReviewerBranchCompatible: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
service.assertExpertActorOnClaim = jest.fn();
|
||||
|
||||
await expect(
|
||||
@@ -531,3 +534,28 @@ describe("ExpertClaimService FileReviewer assignment", () => {
|
||||
expect(service.assertExpertActorOnClaim).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExpertClaimService branch-scoped claim views", () => {
|
||||
it("shows branch-scoped claims only to damage experts in that branch", async () => {
|
||||
const service = createService() as any;
|
||||
const branchId = new Types.ObjectId();
|
||||
const otherBranchId = new Types.ObjectId();
|
||||
service.damageExpertDbService = {
|
||||
findById: jest.fn().mockResolvedValue({ branchId }),
|
||||
};
|
||||
service.blameRequestDbService = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const inBranch = { _id: "in-branch", branchId };
|
||||
const otherBranch = { _id: "other-branch", branchId: otherBranchId };
|
||||
const legacyUnscoped = { _id: "legacy-unscoped" };
|
||||
|
||||
await expect(
|
||||
service.filterDamageExpertClaimsByBranch(
|
||||
[inBranch, otherBranch, legacyUnscoped],
|
||||
{ sub: V2_EXPERT_ID, role: RoleEnum.DAMAGE_EXPERT },
|
||||
),
|
||||
).resolves.toEqual([inBranch, legacyUnscoped]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user