forked from Yara724/api
fix claim validation and expert branch scoping
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -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).
|
||||
*/
|
||||
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({
|
||||
description: "Car part damage selection (same as selectCarPartDamage). Required for first claim data step.",
|
||||
type: CarDamagePartDto,
|
||||
|
||||
@@ -94,6 +94,10 @@ export class BlameRequest {
|
||||
@Prop({ type: 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. */
|
||||
@Prop({ default: false })
|
||||
registrarInitiated?: boolean;
|
||||
|
||||
@@ -38,6 +38,9 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
||||
(service as any).claimCaseDbService = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
(service as any).fanavaranLocationService = {
|
||||
assertMakerReviewerBranchCompatible: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return { service, blameRequestDbService };
|
||||
}
|
||||
|
||||
@@ -89,6 +92,22 @@ describe("RequestManagementService FileReviewer inbox", () => {
|
||||
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 () => {
|
||||
const olderFile = {
|
||||
...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 { isOtpExpiryActive } from "src/helpers/user-otp-expiry";
|
||||
import { parseIranLocalDateTime } from "src/helpers/iran-datetime";
|
||||
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
|
||||
import {
|
||||
applyListQueryV2,
|
||||
isInListDateRange,
|
||||
@@ -1125,6 +1126,22 @@ export class RequestManagementService {
|
||||
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
|
||||
* window (see `ClientService.getCarBodyAccidentMaxAgeDays`). The check is
|
||||
@@ -1171,7 +1188,8 @@ export class RequestManagementService {
|
||||
if (ageMs < 0) {
|
||||
throw new BadRequestException({
|
||||
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,
|
||||
time?: string,
|
||||
): 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
|
||||
if (request.type === "CAR_BODY") {
|
||||
this.assertAccidentDateTimeNotInFuture({
|
||||
accidentDate: body.accidentDate,
|
||||
accidentTime: body.accidentTime,
|
||||
});
|
||||
if (body.accidentDate) {
|
||||
updatePayload.$set["firstPartyDetails.firstPartyFile.accidentDate"] =
|
||||
body.accidentDate;
|
||||
@@ -5378,6 +5440,15 @@ export class RequestManagementService {
|
||||
throw new ForbiddenException("FileReviewer account not found.");
|
||||
}
|
||||
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) {
|
||||
// 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;
|
||||
let expertBranchId: Types.ObjectId | undefined;
|
||||
if (isFileMakerRole) {
|
||||
const fileMaker = await this.fileMakerDbService.findById(String(expert.sub));
|
||||
if (!fileMaker) {
|
||||
throw new ForbiddenException("FileMaker account not found.");
|
||||
}
|
||||
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({
|
||||
publicId,
|
||||
@@ -5708,6 +5786,7 @@ export class RequestManagementService {
|
||||
history: [],
|
||||
expertInitiated: true,
|
||||
initiatedByFieldExpertId: expertId,
|
||||
...(expertBranchId ? { branchId: expertBranchId } : {}),
|
||||
creationMethod: dto.creationMethod,
|
||||
filledBy:
|
||||
dto.creationMethod === CreationMethod.IN_PERSON
|
||||
@@ -8553,6 +8632,11 @@ export class RequestManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
this.assertAccidentDateTimeNotInFuture({
|
||||
accidentDate: formData.firstPartyDescription?.accidentDate,
|
||||
accidentTime: formData.firstPartyDescription?.accidentTime,
|
||||
});
|
||||
|
||||
try {
|
||||
// Get or create user for first party phone number
|
||||
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
|
||||
@@ -11693,6 +11777,16 @@ export class RequestManagementService {
|
||||
const role = this.resolvePartyRoleV3(req, partyRole);
|
||||
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);
|
||||
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);
|
||||
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.");
|
||||
}
|
||||
const makerId = new Types.ObjectId(fileMaker.sub);
|
||||
const files = await this.blameRequestDbService.find({
|
||||
const ownFiles = await this.blameRequestDbService.find({
|
||||
isMadeByFileMaker: true,
|
||||
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 claims =
|
||||
blameIds.length > 0
|
||||
@@ -13021,6 +13141,17 @@ export class RequestManagementService {
|
||||
}
|
||||
const req = await this.blameRequestDbService.findById(requestId);
|
||||
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 (
|
||||
!req.isMadeByFileMaker ||
|
||||
String((req as any).initiatedByFieldExpertId) !== String(fileMaker.sub)
|
||||
@@ -13149,7 +13280,7 @@ export class RequestManagementService {
|
||||
{ assignedFileReviewerId: reviewerId },
|
||||
],
|
||||
});
|
||||
const visibleFiles = (files || []).filter((file: any) => {
|
||||
const tenantAndAssignmentVisible = (files || []).filter((file: any) => {
|
||||
const assignedReviewerId = file.assignedFileReviewerId
|
||||
? String(file.assignedFileReviewerId)
|
||||
: null;
|
||||
@@ -13163,6 +13294,26 @@ export class RequestManagementService {
|
||||
(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
|
||||
.map((f: any) => f._id)
|
||||
@@ -13230,6 +13381,15 @@ export class RequestManagementService {
|
||||
"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
|
||||
? String((req as any).assignedFileReviewerId)
|
||||
: null;
|
||||
|
||||
Reference in New Issue
Block a user