forked from Yara724/api
Merge pull request 'fix claim validation and expert branch scoping' (#335) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#335
This commit is contained in:
@@ -1226,6 +1226,7 @@ export class ClaimRequestManagementService {
|
|||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
expert: any,
|
expert: any,
|
||||||
dto: {
|
dto: {
|
||||||
|
selectedPartIds?: number[];
|
||||||
carPartDamage?: CarDamagePartDto;
|
carPartDamage?: CarDamagePartDto;
|
||||||
sheba?: string;
|
sheba?: string;
|
||||||
nationalCodeOfInsurer?: string;
|
nationalCodeOfInsurer?: string;
|
||||||
@@ -1262,12 +1263,34 @@ export class ClaimRequestManagementService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.carPartDamage) {
|
if (dto.selectedPartIds?.length || dto.carPartDamage) {
|
||||||
if (claim.carPartDamage && (claim.carPartDamage as any[]).length > 0) {
|
if (claim.carPartDamage && (claim.carPartDamage as any[]).length > 0) {
|
||||||
throw new BadRequestException("Car part damage is already set.");
|
throw new BadRequestException("Car part damage is already set.");
|
||||||
}
|
}
|
||||||
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage);
|
let trueItems: Array<Record<string, unknown>>;
|
||||||
const trueItems = this.findTrueItems(dto.carPartDamage);
|
if (dto.selectedPartIds?.length) {
|
||||||
|
const liveCatalog = await this.getLiveFanavaranCatalogItems();
|
||||||
|
const byId = new Map<number, OuterPartCatalogItem>(
|
||||||
|
liveCatalog.map((part) => [part.id, part]),
|
||||||
|
);
|
||||||
|
trueItems = dto.selectedPartIds.map((id) => {
|
||||||
|
const item = byId.get(id);
|
||||||
|
if (!item) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid outer part id: ${id}. Use GET outer-parts-catalog to see valid IDs.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
side: item.side || "carDamage",
|
||||||
|
part: item.titleFa,
|
||||||
|
id: item.id,
|
||||||
|
label_fa: item.titleFa,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage!);
|
||||||
|
trueItems = this.findTrueItems(dto.carPartDamage!);
|
||||||
|
}
|
||||||
const claimDoc = (await this.claimDbService.findOne(
|
const claimDoc = (await this.claimDbService.findOne(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
)) as any;
|
)) as any;
|
||||||
@@ -1302,6 +1325,7 @@ export class ClaimRequestManagementService {
|
|||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
expert: any,
|
expert: any,
|
||||||
dto: {
|
dto: {
|
||||||
|
selectedPartIds?: number[];
|
||||||
carPartDamage?: CarDamagePartDto;
|
carPartDamage?: CarDamagePartDto;
|
||||||
sheba?: string;
|
sheba?: string;
|
||||||
nationalCodeOfInsurer?: string;
|
nationalCodeOfInsurer?: string;
|
||||||
@@ -1313,7 +1337,7 @@ export class ClaimRequestManagementService {
|
|||||||
|
|
||||||
let working = claimCase;
|
let working = claimCase;
|
||||||
|
|
||||||
if (dto.carPartDamage) {
|
if (dto.selectedPartIds?.length || dto.carPartDamage) {
|
||||||
if (
|
if (
|
||||||
working.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS
|
working.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS
|
||||||
) {
|
) {
|
||||||
@@ -1327,20 +1351,41 @@ export class ClaimRequestManagementService {
|
|||||||
) {
|
) {
|
||||||
throw new BadRequestException("Car part damage is already set.");
|
throw new BadRequestException("Car part damage is already set.");
|
||||||
}
|
}
|
||||||
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage);
|
let selectedPartObjs: DamageSelectedPartV2[];
|
||||||
const selectedSlugStrings = this.carDamagePartDtoToOuterPartSlugs(
|
if (dto.selectedPartIds?.length) {
|
||||||
dto.carPartDamage,
|
const liveCatalog = await this.getLiveFanavaranCatalogItems();
|
||||||
);
|
const byId = new Map<number, OuterPartCatalogItem>(
|
||||||
if (!selectedSlugStrings.length) {
|
liveCatalog.map((part) => [part.id, part]),
|
||||||
throw new BadRequestException(
|
);
|
||||||
"No outer damaged parts could be derived from carPartDamage. Check part selections.",
|
const selectedItems = dto.selectedPartIds.map((id) => {
|
||||||
|
const item = byId.get(id);
|
||||||
|
if (!item) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid outer part id: ${id}. Use GET outer-parts-catalog to see valid IDs.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
selectedPartObjs = selectedItems.map((part) =>
|
||||||
|
catalogItemToSelectedPart(part, liveCatalog),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Backward compatibility for older expert-panel clients.
|
||||||
|
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage!);
|
||||||
|
const selectedSlugStrings = this.carDamagePartDtoToOuterPartSlugs(
|
||||||
|
dto.carPartDamage!,
|
||||||
|
);
|
||||||
|
if (!selectedSlugStrings.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"No outer damaged parts could be derived from carPartDamage. Check part selections.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
selectedPartObjs = normalizeDamageSelectedParts(
|
||||||
|
selectedSlugStrings,
|
||||||
|
working.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const selectedPartObjs = normalizeDamageSelectedParts(
|
|
||||||
selectedSlugStrings,
|
|
||||||
working.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
const damagedPartsStubs = selectedPartObjs.map((p) => ({
|
const damagedPartsStubs = selectedPartObjs.map((p) => ({
|
||||||
id: p.id ?? undefined,
|
id: p.id ?? undefined,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
@@ -9302,6 +9347,9 @@ export class ClaimRequestManagementService {
|
|||||||
blameRequestId: new Types.ObjectId(blameRequestId),
|
blameRequestId: new Types.ObjectId(blameRequestId),
|
||||||
blameRequestNo: blameRequest.requestNo,
|
blameRequestNo: blameRequest.requestNo,
|
||||||
initiatedByFieldExpertId: new Types.ObjectId(expert.sub),
|
initiatedByFieldExpertId: new Types.ObjectId(expert.sub),
|
||||||
|
...((blameRequest as any).branchId
|
||||||
|
? { branchId: new Types.ObjectId(String((blameRequest as any).branchId)) }
|
||||||
|
: {}),
|
||||||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||||||
claimStatus: ClaimStatus.PENDING,
|
claimStatus: ClaimStatus.PENDING,
|
||||||
inquiries: (blameRequest as any).inquiries ?? {},
|
inquiries: (blameRequest as any).inquiries ?? {},
|
||||||
|
|||||||
@@ -239,6 +239,10 @@ export class ClaimCase {
|
|||||||
@Prop({ type: Types.ObjectId, index: true })
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
initiatedByFieldExpertId?: Types.ObjectId;
|
initiatedByFieldExpertId?: Types.ObjectId;
|
||||||
|
|
||||||
|
/** Branch snapshot inherited from the originating expert-created blame. */
|
||||||
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
|
branchId?: Types.ObjectId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The damaged party's userId, resolved from the blame at claim-creation time.
|
* The damaged party's userId, resolved from the blame at claim-creation time.
|
||||||
* Stored here so view/list access does not require an extra blame lookup.
|
* Stored here so view/list access does not require an extra blame lookup.
|
||||||
|
|||||||
@@ -614,9 +614,24 @@ export class ExpertBlameService {
|
|||||||
)) as Record<string, unknown>[];
|
)) as Record<string, unknown>[];
|
||||||
|
|
||||||
// Scope to this reviewer's insurance company via blame party clientId
|
// Scope to this reviewer's insurance company via blame party clientId
|
||||||
const visibleCases = allSealed.filter((doc) =>
|
const tenantVisibleCases = allSealed.filter((doc) =>
|
||||||
blameCaseTouchesClient(doc, clientKey),
|
blameCaseTouchesClient(doc, clientKey),
|
||||||
);
|
);
|
||||||
|
const visibleCases = (
|
||||||
|
await Promise.all(
|
||||||
|
tenantVisibleCases.map(async (doc) => {
|
||||||
|
try {
|
||||||
|
await this.requestManagementService.assertFileReviewerBranchAccess(
|
||||||
|
doc,
|
||||||
|
actor.sub,
|
||||||
|
);
|
||||||
|
return doc;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter((doc): doc is Record<string, unknown> => doc != null);
|
||||||
|
|
||||||
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
|
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
|
||||||
return pagedResult;
|
return pagedResult;
|
||||||
|
|||||||
@@ -515,6 +515,9 @@ describe("ExpertClaimService FileReviewer assignment", () => {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
service.fanavaranLocationService = {
|
||||||
|
assertMakerReviewerBranchCompatible: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
service.assertExpertActorOnClaim = jest.fn();
|
service.assertExpertActorOnClaim = jest.fn();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -531,3 +534,28 @@ describe("ExpertClaimService FileReviewer assignment", () => {
|
|||||||
expect(service.assertExpertActorOnClaim).not.toHaveBeenCalled();
|
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,
|
claim: any,
|
||||||
actor: any,
|
actor: any,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const blame = await this.loadBlameForClaim(claim);
|
||||||
// FILE_REVIEWER: by the time we reach this method the caller has already
|
// FILE_REVIEWER: by the time we reach this method the caller has already
|
||||||
// verified that actor.sub === blame.assignedFileReviewerId (Phase 2 of
|
// verified that actor.sub === blame.assignedFileReviewerId (Phase 2 of
|
||||||
// assignClaimForReviewV2). The generic tenant-scope check would incorrectly
|
// assignClaimForReviewV2). The generic tenant-scope check would incorrectly
|
||||||
// reject them because initiatedByFieldExpertId belongs to the FileMaker, not
|
// reject them because initiatedByFieldExpertId belongs to the FileMaker, not
|
||||||
// the reviewer. Skip it — the assignment check is the access proof.
|
// the reviewer. Skip it — the assignment check is the access proof.
|
||||||
if ((actor as any).role === RoleEnum.FILE_REVIEWER) return;
|
if ((actor as any).role === RoleEnum.FILE_REVIEWER) {
|
||||||
const blame = await this.loadBlameForClaim(claim);
|
await this.assertActorBranchAccess(claim, actor, blame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
assertClaimCaseForExpertActor(claim, actor, blame);
|
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(
|
private async fieldExpertOwnsClaim(
|
||||||
@@ -2600,6 +2731,15 @@ export class ExpertClaimService {
|
|||||||
"This file does not belong to your organization.",
|
"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 (
|
if (
|
||||||
!(blame as any).isMadeByFileMaker ||
|
!(blame as any).isMadeByFileMaker ||
|
||||||
!(blame as any).expertInitiated ||
|
!(blame as any).expertInitiated ||
|
||||||
@@ -2732,6 +2872,15 @@ export class ExpertClaimService {
|
|||||||
"This file does not belong to your organization.",
|
"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
|
const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId
|
||||||
? String((reviewerBlame as any).assignedFileReviewerId)
|
? String((reviewerBlame as any).assignedFileReviewerId)
|
||||||
: null;
|
: 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),
|
claimCaseTouchesClient(c, clientKey),
|
||||||
);
|
);
|
||||||
|
const filtered = await this.filterDamageExpertClaimsByBranch(
|
||||||
|
tenantFiltered,
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
// Reconcile stale locks — if no decision was made, also clear assignedForReviewBy
|
// Reconcile stale locks — if no decision was made, also clear assignedForReviewBy
|
||||||
const staleLockToReconcile = filtered.filter(
|
const staleLockToReconcile = filtered.filter(
|
||||||
@@ -4389,7 +4542,7 @@ export class ExpertClaimService {
|
|||||||
{
|
{
|
||||||
lean: true,
|
lean: true,
|
||||||
select:
|
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[];
|
)) as any[];
|
||||||
|
|
||||||
@@ -4398,9 +4551,31 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Scope to this reviewer's insurer via blame party clientId
|
// Scope to this reviewer's insurer via blame party clientId
|
||||||
const scopedBlames = blames.filter((b) =>
|
const tenantScopedBlames = blames.filter((b) =>
|
||||||
blameCaseTouchesClient(b, clientKey),
|
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) {
|
if (scopedBlames.length === 0) {
|
||||||
return this.paginateClaimListV2([], query);
|
return this.paginateClaimListV2([], query);
|
||||||
@@ -4478,10 +4653,25 @@ export class ExpertClaimService {
|
|||||||
): Promise<GetClaimListV2ResponseDto> {
|
): Promise<GetClaimListV2ResponseDto> {
|
||||||
const makerOid = new Types.ObjectId(actor.sub);
|
const makerOid = new Types.ObjectId(actor.sub);
|
||||||
|
|
||||||
const makerBlames = (await this.blameRequestDbService.find(
|
const ownMakerBlames = (await this.blameRequestDbService.find(
|
||||||
{ isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid },
|
{ 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[];
|
)) 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) {
|
if (makerBlames.length === 0) {
|
||||||
return this.paginateClaimListV2([], query);
|
return this.paginateClaimListV2([], query);
|
||||||
@@ -4920,6 +5110,7 @@ export class ExpertClaimService {
|
|||||||
if (actor.role !== RoleEnum.FILE_REVIEWER) {
|
if (actor.role !== RoleEnum.FILE_REVIEWER) {
|
||||||
assertClaimCaseForExpertActor(claim, actor, linkedBlame);
|
assertClaimCaseForExpertActor(claim, actor, linkedBlame);
|
||||||
}
|
}
|
||||||
|
await this.assertActorBranchAccess(claim, actor, linkedBlame);
|
||||||
|
|
||||||
// Variables used both in the gate block and in the detail-build section below
|
// Variables used both in the gate block and in the detail-build section below
|
||||||
const isDamageExpertPhase =
|
const isDamageExpertPhase =
|
||||||
|
|||||||
55
src/fanavaran/fanavaran-location.service.spec.ts
Normal file
55
src/fanavaran/fanavaran-location.service.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { ForbiddenException } from "@nestjs/common";
|
||||||
|
import { FanavaranLocationService } from "./fanavaran-location.service";
|
||||||
|
|
||||||
|
describe("FanavaranLocationService branch scope", () => {
|
||||||
|
const makerId = "maker-1";
|
||||||
|
const reviewerId = "reviewer-1";
|
||||||
|
|
||||||
|
const createService = (makerBranch?: string, reviewerBranch?: string) =>
|
||||||
|
new FanavaranLocationService(
|
||||||
|
{
|
||||||
|
findById: jest.fn().mockResolvedValue(
|
||||||
|
makerBranch ? { branchId: makerBranch } : {},
|
||||||
|
),
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
findById: jest.fn().mockResolvedValue(
|
||||||
|
reviewerBranch ? { branchId: reviewerBranch } : {},
|
||||||
|
),
|
||||||
|
} as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
it("allows a reviewer to view a file from the same branch", async () => {
|
||||||
|
const service = createService("branch-1", "branch-1");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.assertMakerReviewerBranchCompatible({
|
||||||
|
fileMakerId: makerId,
|
||||||
|
fileReviewerId: reviewerId,
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a reviewer from another branch", async () => {
|
||||||
|
const service = createService("branch-1", "branch-2");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.assertMakerReviewerBranchCompatible({
|
||||||
|
fileMakerId: makerId,
|
||||||
|
fileReviewerId: reviewerId,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the immutable case branch snapshot", async () => {
|
||||||
|
const service = createService("old-branch", "branch-2");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.assertMakerReviewerBranchCompatible({
|
||||||
|
fileMakerId: makerId,
|
||||||
|
fileReviewerId: reviewerId,
|
||||||
|
caseBranchId: "branch-2",
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -113,6 +113,54 @@ export class FanavaranLocationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fileMakerBranchId(
|
||||||
|
fileMakerId: string | null | undefined,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!fileMakerId) return undefined;
|
||||||
|
const doc = await this.fileMakerDbService.findById(String(fileMakerId));
|
||||||
|
const branchId = (doc as any)?.branchId;
|
||||||
|
return branchId ? String(branchId) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fileReviewerBranchId(
|
||||||
|
fileReviewerId: string | null | undefined,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!fileReviewerId) return undefined;
|
||||||
|
const doc = await this.fileReviewerDbService.findById(
|
||||||
|
String(fileReviewerId),
|
||||||
|
);
|
||||||
|
const branchId = (doc as any)?.branchId;
|
||||||
|
return branchId ? String(branchId) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V4/V5 case visibility is branch-scoped by the FileMaker who created it.
|
||||||
|
* Missing branch assignments are denied instead of widening visibility.
|
||||||
|
*/
|
||||||
|
async assertMakerReviewerBranchCompatible(input: {
|
||||||
|
fileMakerId?: string | null;
|
||||||
|
fileReviewerId?: string | null;
|
||||||
|
caseBranchId?: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const reviewerBranchId = await this.fileReviewerBranchId(
|
||||||
|
input.fileReviewerId,
|
||||||
|
);
|
||||||
|
const caseBranchId =
|
||||||
|
(input.caseBranchId ? String(input.caseBranchId) : undefined) ??
|
||||||
|
(await this.fileMakerBranchId(input.fileMakerId));
|
||||||
|
|
||||||
|
if (!reviewerBranchId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"FileReviewer account is not assigned to a branch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!caseBranchId || caseBranchId !== reviewerBranchId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"This file belongs to another branch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async loadPrimaryLocationId(
|
private async loadPrimaryLocationId(
|
||||||
userId: string | null | undefined,
|
userId: string | null | undefined,
|
||||||
kind: "maker" | "reviewer",
|
kind: "maker" | "reviewer",
|
||||||
|
|||||||
@@ -18,9 +18,22 @@ describe("parseIranLocalDateTime", () => {
|
|||||||
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
|
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies a separately supplied time to an explicit-offset date", () => {
|
||||||
|
const instant = parseIranLocalDateTime(
|
||||||
|
"2025-05-16T00:00:00.000Z",
|
||||||
|
"18:28",
|
||||||
|
);
|
||||||
|
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
it("combines Date calendar day in Iran with accidentTime", () => {
|
it("combines Date calendar day in Iran with accidentTime", () => {
|
||||||
const dateOnly = new Date("2025-05-16T00:00:00.000Z");
|
const dateOnly = new Date("2025-05-16T00:00:00.000Z");
|
||||||
const instant = parseIranLocalDateTime(dateOnly, "18:28");
|
const instant = parseIranLocalDateTime(dateOnly, "18:28");
|
||||||
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
|
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects out-of-range hours and minutes", () => {
|
||||||
|
expect(parseIranLocalDateTime("2025-05-16", "24:00")).toBeNull();
|
||||||
|
expect(parseIranLocalDateTime("2025-05-16", "12:60")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function normalizeTimeToHms(time: string): string | null {
|
|||||||
const hh = m[1].padStart(2, "0");
|
const hh = m[1].padStart(2, "0");
|
||||||
const mm = m[2];
|
const mm = m[2];
|
||||||
const ss = m[3] ?? "00";
|
const ss = m[3] ?? "00";
|
||||||
|
if (Number(hh) > 23 || Number(mm) > 59 || Number(ss) > 59) return null;
|
||||||
return `${hh}:${mm}:${ss}`;
|
return `${hh}:${mm}:${ss}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +57,12 @@ export function parseIranLocalDateTime(
|
|||||||
|
|
||||||
if (/[zZ]|[+-]\d{2}:?\d{2}$/.test(raw)) {
|
if (/[zZ]|[+-]\d{2}:?\d{2}$/.test(raw)) {
|
||||||
const d = new Date(raw);
|
const d = new Date(raw);
|
||||||
return Number.isNaN(d.getTime()) ? null : d;
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
const cleanTime = (time ?? "").trim();
|
||||||
|
if (!cleanTime) return d;
|
||||||
|
const timeHms = normalizeTimeToHms(cleanTime);
|
||||||
|
if (!timeHms) return null;
|
||||||
|
return parseIranLocalIso(gregorianDateInIran(d), timeHms);
|
||||||
}
|
}
|
||||||
|
|
||||||
const naive = raw.match(NAIVE_ISO_DATETIME_RE);
|
const naive = raw.match(NAIVE_ISO_DATETIME_RE);
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
} from "class-validator";
|
||||||
import { CarDamagePartDto } from "src/claim-request-management/dto/car-part.dto";
|
import { CarDamagePartDto } from "src/claim-request-management/dto/car-part.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -6,6 +13,19 @@ import { CarDamagePartDto } from "src/claim-request-management/dto/car-part.dto"
|
|||||||
* All fields optional so expert can submit in one or two steps (e.g. car parts first, then sheba/other).
|
* All fields optional so expert can submit in one or two steps (e.g. car parts first, then sheba/other).
|
||||||
*/
|
*/
|
||||||
export class ExpertCompleteClaimDataDto {
|
export class ExpertCompleteClaimDataDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Selected damaged-part IDs from the live Fanavaran car-components catalog.",
|
||||||
|
example: [9, 10, 30],
|
||||||
|
type: [Number],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: "selectedPartIds must be an array" })
|
||||||
|
@ArrayMinSize(1, { message: "At least one part ID must be selected" })
|
||||||
|
@ArrayUnique({ message: "Duplicate part IDs are not allowed" })
|
||||||
|
@IsInt({ each: true, message: "Each selected part ID must be an integer" })
|
||||||
|
selectedPartIds?: number[];
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: "Car part damage selection (same as selectCarPartDamage). Required for first claim data step.",
|
description: "Car part damage selection (same as selectCarPartDamage). Required for first claim data step.",
|
||||||
type: CarDamagePartDto,
|
type: CarDamagePartDto,
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ export class BlameRequest {
|
|||||||
@Prop({ type: Types.ObjectId })
|
@Prop({ type: Types.ObjectId })
|
||||||
initiatedByFieldExpertId?: Types.ObjectId;
|
initiatedByFieldExpertId?: Types.ObjectId;
|
||||||
|
|
||||||
|
/** Branch snapshot of the expert/FileMaker who created this file. */
|
||||||
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
|
branchId?: Types.ObjectId;
|
||||||
|
|
||||||
/** True when this blame was created by a registrar. */
|
/** True when this blame was created by a registrar. */
|
||||||
@Prop({ default: false })
|
@Prop({ default: false })
|
||||||
registrarInitiated?: boolean;
|
registrarInitiated?: boolean;
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
|||||||
(service as any).claimCaseDbService = {
|
(service as any).claimCaseDbService = {
|
||||||
find: jest.fn().mockResolvedValue([]),
|
find: jest.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
|
(service as any).fanavaranLocationService = {
|
||||||
|
assertMakerReviewerBranchCompatible: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
return { service, blameRequestDbService };
|
return { service, blameRequestDbService };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +92,22 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
|||||||
expect(result.list).toEqual([]);
|
expect(result.list).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not list an open file from another branch", async () => {
|
||||||
|
const { service } = createService([sealedFile]);
|
||||||
|
(service as any).fanavaranLocationService
|
||||||
|
.assertMakerReviewerBranchCompatible.mockRejectedValue(
|
||||||
|
new Error("This file belongs to another branch."),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getMyFileReviewerFiles({
|
||||||
|
sub: String(reviewerId),
|
||||||
|
role: RoleEnum.FILE_REVIEWER,
|
||||||
|
clientKey: String(clientId),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.list).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("sorts and paginates the reviewer inbox with the shared list contract", async () => {
|
it("sorts and paginates the reviewer inbox with the shared list contract", async () => {
|
||||||
const olderFile = {
|
const olderFile = {
|
||||||
...sealedFile,
|
...sealedFile,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.
|
|||||||
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
|
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
|
||||||
import { isOtpExpiryActive } from "src/helpers/user-otp-expiry";
|
import { isOtpExpiryActive } from "src/helpers/user-otp-expiry";
|
||||||
import { parseIranLocalDateTime } from "src/helpers/iran-datetime";
|
import { parseIranLocalDateTime } from "src/helpers/iran-datetime";
|
||||||
|
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
|
||||||
import {
|
import {
|
||||||
applyListQueryV2,
|
applyListQueryV2,
|
||||||
isInListDateRange,
|
isInListDateRange,
|
||||||
@@ -1125,6 +1126,22 @@ export class RequestManagementService {
|
|||||||
private readonly fileReviewerDbService: FileReviewerDbService,
|
private readonly fileReviewerDbService: FileReviewerDbService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async assertFileReviewerBranchAccess(
|
||||||
|
file: {
|
||||||
|
initiatedByFieldExpertId?: unknown;
|
||||||
|
branchId?: unknown;
|
||||||
|
},
|
||||||
|
fileReviewerId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
|
||||||
|
fileMakerId: file?.initiatedByFieldExpertId
|
||||||
|
? String(file.initiatedByFieldExpertId)
|
||||||
|
: null,
|
||||||
|
fileReviewerId,
|
||||||
|
caseBranchId: file?.branchId ? String(file.branchId) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject CAR_BODY submissions whose accident is older than the per-client
|
* Reject CAR_BODY submissions whose accident is older than the per-client
|
||||||
* window (see `ClientService.getCarBodyAccidentMaxAgeDays`). The check is
|
* window (see `ClientService.getCarBodyAccidentMaxAgeDays`). The check is
|
||||||
@@ -1171,7 +1188,8 @@ export class RequestManagementService {
|
|||||||
if (ageMs < 0) {
|
if (ageMs < 0) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: "CAR_BODY_ACCIDENT_DATE_IN_FUTURE",
|
code: "CAR_BODY_ACCIDENT_DATE_IN_FUTURE",
|
||||||
message: "Accident date cannot be in the future.",
|
message: "Accident date and time cannot be in the future.",
|
||||||
|
messageFa: "تاریخ و ساعت حادثه نمیتواند در آینده باشد.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1202,7 +1220,47 @@ export class RequestManagementService {
|
|||||||
date: Date | string,
|
date: Date | string,
|
||||||
time?: string,
|
time?: string,
|
||||||
): Date | null {
|
): Date | null {
|
||||||
return parseIranLocalDateTime(date, time);
|
const normalizedDate =
|
||||||
|
typeof date === "string" ? jalaliToGregorianDate(date) ?? date : date;
|
||||||
|
return parseIranLocalDateTime(normalizedDate, time);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Universal write guard for accident timestamps. UI restrictions are only a
|
||||||
|
* convenience; every endpoint that persists accidentDate/accidentTime must
|
||||||
|
* reject an invalid or future Iran-local timestamp as well.
|
||||||
|
*/
|
||||||
|
private assertAccidentDateTimeNotInFuture(params: {
|
||||||
|
accidentDate: Date | string | null | undefined;
|
||||||
|
accidentTime?: string | null;
|
||||||
|
}): void {
|
||||||
|
const { accidentDate, accidentTime } = params;
|
||||||
|
if (accidentDate == null && !accidentTime) return;
|
||||||
|
|
||||||
|
if (accidentDate == null || !String(accidentTime ?? "").trim()) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "ACCIDENT_DATE_TIME_INVALID",
|
||||||
|
message: "A valid accident date and time are required together.",
|
||||||
|
messageFa: "تاریخ و ساعت معتبر حادثه باید با هم وارد شوند.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const instant = this.parseAccidentInstant(accidentDate, accidentTime!);
|
||||||
|
if (!instant || Number.isNaN(instant.getTime())) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "ACCIDENT_DATE_TIME_INVALID",
|
||||||
|
message: "Invalid accident date or time.",
|
||||||
|
messageFa: "تاریخ یا ساعت حادثه معتبر نیست.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instant.getTime() > Date.now()) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "ACCIDENT_DATE_TIME_IN_FUTURE",
|
||||||
|
message: "Accident date and time cannot be in the future.",
|
||||||
|
messageFa: "تاریخ و ساعت حادثه نمیتواند در آینده باشد.",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4106,6 +4164,10 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
// Add CAR_BODY specific fields if type is CAR_BODY
|
// Add CAR_BODY specific fields if type is CAR_BODY
|
||||||
if (request.type === "CAR_BODY") {
|
if (request.type === "CAR_BODY") {
|
||||||
|
this.assertAccidentDateTimeNotInFuture({
|
||||||
|
accidentDate: body.accidentDate,
|
||||||
|
accidentTime: body.accidentTime,
|
||||||
|
});
|
||||||
if (body.accidentDate) {
|
if (body.accidentDate) {
|
||||||
updatePayload.$set["firstPartyDetails.firstPartyFile.accidentDate"] =
|
updatePayload.$set["firstPartyDetails.firstPartyFile.accidentDate"] =
|
||||||
body.accidentDate;
|
body.accidentDate;
|
||||||
@@ -5378,6 +5440,15 @@ export class RequestManagementService {
|
|||||||
throw new ForbiddenException("FileReviewer account not found.");
|
throw new ForbiddenException("FileReviewer account not found.");
|
||||||
}
|
}
|
||||||
assertFileReviewerCanReviewBlameType(fileReviewer, req.type);
|
assertFileReviewerCanReviewBlameType(fileReviewer, req.type);
|
||||||
|
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
|
||||||
|
fileMakerId: req?.initiatedByFieldExpertId
|
||||||
|
? String(req.initiatedByFieldExpertId)
|
||||||
|
: null,
|
||||||
|
fileReviewerId: String(expert.sub),
|
||||||
|
caseBranchId: (req as any)?.branchId
|
||||||
|
? String((req as any).branchId)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
if (!assignedId) {
|
if (!assignedId) {
|
||||||
// Atomically claim — ignore if another reviewer won the race (they would have
|
// Atomically claim — ignore if another reviewer won the race (they would have
|
||||||
@@ -5687,12 +5758,19 @@ export class RequestManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isFileMakerRole = (expert as any)?.role === RoleEnum.FILE_MAKER;
|
const isFileMakerRole = (expert as any)?.role === RoleEnum.FILE_MAKER;
|
||||||
|
let expertBranchId: Types.ObjectId | undefined;
|
||||||
if (isFileMakerRole) {
|
if (isFileMakerRole) {
|
||||||
const fileMaker = await this.fileMakerDbService.findById(String(expert.sub));
|
const fileMaker = await this.fileMakerDbService.findById(String(expert.sub));
|
||||||
if (!fileMaker) {
|
if (!fileMaker) {
|
||||||
throw new ForbiddenException("FileMaker account not found.");
|
throw new ForbiddenException("FileMaker account not found.");
|
||||||
}
|
}
|
||||||
assertFileMakerCanCreateBlameType(fileMaker, type);
|
assertFileMakerCanCreateBlameType(fileMaker, type);
|
||||||
|
if (!fileMaker.branchId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"FileMaker account is not assigned to a branch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expertBranchId = new Types.ObjectId(String(fileMaker.branchId));
|
||||||
}
|
}
|
||||||
const created = await this.blameRequestDbService.create({
|
const created = await this.blameRequestDbService.create({
|
||||||
publicId,
|
publicId,
|
||||||
@@ -5708,6 +5786,7 @@ export class RequestManagementService {
|
|||||||
history: [],
|
history: [],
|
||||||
expertInitiated: true,
|
expertInitiated: true,
|
||||||
initiatedByFieldExpertId: expertId,
|
initiatedByFieldExpertId: expertId,
|
||||||
|
...(expertBranchId ? { branchId: expertBranchId } : {}),
|
||||||
creationMethod: dto.creationMethod,
|
creationMethod: dto.creationMethod,
|
||||||
filledBy:
|
filledBy:
|
||||||
dto.creationMethod === CreationMethod.IN_PERSON
|
dto.creationMethod === CreationMethod.IN_PERSON
|
||||||
@@ -8553,6 +8632,11 @@ export class RequestManagementService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.assertAccidentDateTimeNotInFuture({
|
||||||
|
accidentDate: formData.firstPartyDescription?.accidentDate,
|
||||||
|
accidentTime: formData.firstPartyDescription?.accidentTime,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get or create user for first party phone number
|
// Get or create user for first party phone number
|
||||||
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
|
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
|
||||||
@@ -11693,6 +11777,16 @@ export class RequestManagementService {
|
|||||||
const role = this.resolvePartyRoleV3(req, partyRole);
|
const role = this.resolvePartyRoleV3(req, partyRole);
|
||||||
this.assertBlameV3PartyDetailPhase(req, role);
|
this.assertBlameV3PartyDetailPhase(req, role);
|
||||||
|
|
||||||
|
// V3/V5 clients can include CAR_BODY accident fields even though this
|
||||||
|
// endpoint only persists the description. Never accept a future timestamp
|
||||||
|
// merely because those extra fields are not part of the V3 statement step.
|
||||||
|
if (body.accidentDate != null || body.accidentTime != null) {
|
||||||
|
this.assertAccidentDateTimeNotInFuture({
|
||||||
|
accidentDate: body.accidentDate,
|
||||||
|
accidentTime: body.accidentTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const idx = this.getPartyIndex(req, role);
|
const idx = this.getPartyIndex(req, role);
|
||||||
if (idx === -1) throw new BadRequestException(`${role} party not found`);
|
if (idx === -1) throw new BadRequestException(`${role} party not found`);
|
||||||
|
|
||||||
@@ -11751,6 +11845,11 @@ export class RequestManagementService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.assertAccidentDateTimeNotInFuture({
|
||||||
|
accidentDate: body.accidentDate,
|
||||||
|
accidentTime: body.accidentTime,
|
||||||
|
});
|
||||||
|
|
||||||
const idx = this.getPartyIndex(req, role);
|
const idx = this.getPartyIndex(req, role);
|
||||||
if (idx === -1) throw new BadRequestException(`${role} party not found`);
|
if (idx === -1) throw new BadRequestException(`${role} party not found`);
|
||||||
|
|
||||||
@@ -12958,10 +13057,31 @@ export class RequestManagementService {
|
|||||||
throw new ForbiddenException("Only FileMakers can use this endpoint.");
|
throw new ForbiddenException("Only FileMakers can use this endpoint.");
|
||||||
}
|
}
|
||||||
const makerId = new Types.ObjectId(fileMaker.sub);
|
const makerId = new Types.ObjectId(fileMaker.sub);
|
||||||
const files = await this.blameRequestDbService.find({
|
const ownFiles = await this.blameRequestDbService.find({
|
||||||
isMadeByFileMaker: true,
|
isMadeByFileMaker: true,
|
||||||
initiatedByFieldExpertId: makerId,
|
initiatedByFieldExpertId: makerId,
|
||||||
});
|
});
|
||||||
|
const hasBranchScopedFiles = (ownFiles || []).some(
|
||||||
|
(file: any) => !!file.branchId,
|
||||||
|
);
|
||||||
|
let makerBranchId: string | undefined;
|
||||||
|
if (hasBranchScopedFiles) {
|
||||||
|
const fileMakerProfile = await this.fileMakerDbService.findById(
|
||||||
|
String(fileMaker.sub),
|
||||||
|
);
|
||||||
|
makerBranchId = fileMakerProfile?.branchId
|
||||||
|
? String(fileMakerProfile.branchId)
|
||||||
|
: undefined;
|
||||||
|
if (!makerBranchId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"FileMaker account is not assigned to a branch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = (ownFiles || []).filter(
|
||||||
|
(file: any) =>
|
||||||
|
!file.branchId || String(file.branchId) === makerBranchId,
|
||||||
|
);
|
||||||
const blameIds = (files || []).map((f: any) => f._id).filter(Boolean);
|
const blameIds = (files || []).map((f: any) => f._id).filter(Boolean);
|
||||||
const claims =
|
const claims =
|
||||||
blameIds.length > 0
|
blameIds.length > 0
|
||||||
@@ -13021,6 +13141,17 @@ export class RequestManagementService {
|
|||||||
}
|
}
|
||||||
const req = await this.blameRequestDbService.findById(requestId);
|
const req = await this.blameRequestDbService.findById(requestId);
|
||||||
if (!req) throw new NotFoundException("Blame request not found");
|
if (!req) throw new NotFoundException("Blame request not found");
|
||||||
|
if ((req as any).branchId) {
|
||||||
|
const fileMakerProfile = await this.fileMakerDbService.findById(
|
||||||
|
String(fileMaker.sub),
|
||||||
|
);
|
||||||
|
const makerBranchId = fileMakerProfile?.branchId
|
||||||
|
? String(fileMakerProfile.branchId)
|
||||||
|
: undefined;
|
||||||
|
if (!makerBranchId || String((req as any).branchId) !== makerBranchId) {
|
||||||
|
throw new ForbiddenException("This file belongs to another branch.");
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!req.isMadeByFileMaker ||
|
!req.isMadeByFileMaker ||
|
||||||
String((req as any).initiatedByFieldExpertId) !== String(fileMaker.sub)
|
String((req as any).initiatedByFieldExpertId) !== String(fileMaker.sub)
|
||||||
@@ -13149,7 +13280,7 @@ export class RequestManagementService {
|
|||||||
{ assignedFileReviewerId: reviewerId },
|
{ assignedFileReviewerId: reviewerId },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const visibleFiles = (files || []).filter((file: any) => {
|
const tenantAndAssignmentVisible = (files || []).filter((file: any) => {
|
||||||
const assignedReviewerId = file.assignedFileReviewerId
|
const assignedReviewerId = file.assignedFileReviewerId
|
||||||
? String(file.assignedFileReviewerId)
|
? String(file.assignedFileReviewerId)
|
||||||
: null;
|
: null;
|
||||||
@@ -13163,6 +13294,26 @@ export class RequestManagementService {
|
|||||||
(isOpen || isAssignedToReviewer)
|
(isOpen || isAssignedToReviewer)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
const visibleFiles = (
|
||||||
|
await Promise.all(
|
||||||
|
tenantAndAssignmentVisible.map(async (file: any) => {
|
||||||
|
try {
|
||||||
|
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible(
|
||||||
|
{
|
||||||
|
fileMakerId: file.initiatedByFieldExpertId
|
||||||
|
? String(file.initiatedByFieldExpertId)
|
||||||
|
: null,
|
||||||
|
fileReviewerId: String(fileReviewer.sub),
|
||||||
|
caseBranchId: file.branchId ? String(file.branchId) : null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return file;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter((file): file is any => file != null);
|
||||||
|
|
||||||
const visibleBlameIds = visibleFiles
|
const visibleBlameIds = visibleFiles
|
||||||
.map((f: any) => f._id)
|
.map((f: any) => f._id)
|
||||||
@@ -13230,6 +13381,15 @@ export class RequestManagementService {
|
|||||||
"This file does not belong to your organization.",
|
"This file does not belong to your organization.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await this.fanavaranLocationService.assertMakerReviewerBranchCompatible({
|
||||||
|
fileMakerId: (req as any).initiatedByFieldExpertId
|
||||||
|
? String((req as any).initiatedByFieldExpertId)
|
||||||
|
: null,
|
||||||
|
fileReviewerId: String(fileReviewer.sub),
|
||||||
|
caseBranchId: (req as any).branchId
|
||||||
|
? String((req as any).branchId)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
const assignedId = (req as any).assignedFileReviewerId
|
const assignedId = (req as any).assignedFileReviewerId
|
||||||
? String((req as any).assignedFileReviewerId)
|
? String((req as any).assignedFileReviewerId)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
Reference in New Issue
Block a user