diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 286e122..2f85641 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -1226,6 +1226,7 @@ export class ClaimRequestManagementService { claimRequestId: string, expert: any, dto: { + selectedPartIds?: number[]; carPartDamage?: CarDamagePartDto; sheba?: 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) { throw new BadRequestException("Car part damage is already set."); } - this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage); - const trueItems = this.findTrueItems(dto.carPartDamage); + let trueItems: Array>; + if (dto.selectedPartIds?.length) { + const liveCatalog = await this.getLiveFanavaranCatalogItems(); + const byId = new Map( + 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( claimRequestId, )) as any; @@ -1302,6 +1325,7 @@ export class ClaimRequestManagementService { claimRequestId: string, expert: any, dto: { + selectedPartIds?: number[]; carPartDamage?: CarDamagePartDto; sheba?: string; nationalCodeOfInsurer?: string; @@ -1313,7 +1337,7 @@ export class ClaimRequestManagementService { let working = claimCase; - if (dto.carPartDamage) { + if (dto.selectedPartIds?.length || dto.carPartDamage) { if ( working.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS ) { @@ -1327,20 +1351,41 @@ export class ClaimRequestManagementService { ) { throw new BadRequestException("Car part damage is already set."); } - 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.", + let selectedPartObjs: DamageSelectedPartV2[]; + if (dto.selectedPartIds?.length) { + const liveCatalog = await this.getLiveFanavaranCatalogItems(); + const byId = new Map( + liveCatalog.map((part) => [part.id, part]), + ); + 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) => ({ id: p.id ?? undefined, name: p.name, @@ -9318,6 +9363,9 @@ export class ClaimRequestManagementService { blameRequestId: new Types.ObjectId(blameRequestId), blameRequestNo: blameRequest.requestNo, initiatedByFieldExpertId: new Types.ObjectId(expert.sub), + ...((blameRequest as any).branchId + ? { branchId: new Types.ObjectId(String((blameRequest as any).branchId)) } + : {}), status: ClaimCaseStatus.SELECTING_OUTER_PARTS, claimStatus: ClaimStatus.PENDING, inquiries: (blameRequest as any).inquiries ?? {}, diff --git a/src/claim-request-management/entites/schema/claim-cases.schema.ts b/src/claim-request-management/entites/schema/claim-cases.schema.ts index 87e43f8..2f9fe5f 100644 --- a/src/claim-request-management/entites/schema/claim-cases.schema.ts +++ b/src/claim-request-management/entites/schema/claim-cases.schema.ts @@ -239,6 +239,10 @@ export class ClaimCase { @Prop({ type: Types.ObjectId, index: true }) 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. * Stored here so view/list access does not require an extra blame lookup. diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index e840672..068d4b0 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -614,9 +614,24 @@ export class ExpertBlameService { )) as Record[]; // Scope to this reviewer's insurance company via blame party clientId - const visibleCases = allSealed.filter((doc) => + const tenantVisibleCases = allSealed.filter((doc) => 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 => doc != null); const pagedResult = await this.paginateBlameListV2(visibleCases, query); return pagedResult; diff --git a/src/expert-claim/expert-claim.service.spec.ts b/src/expert-claim/expert-claim.service.spec.ts index 1341126..cd4baca 100644 --- a/src/expert-claim/expert-claim.service.spec.ts +++ b/src/expert-claim/expert-claim.service.spec.ts @@ -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]); + }); +}); diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index ef99a9d..847aa11 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -418,14 +418,145 @@ export class ExpertClaimService { claim: any, actor: any, ): Promise { + 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 { + 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 { + 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(); + 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 { 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 = diff --git a/src/fanavaran/fanavaran-location.service.spec.ts b/src/fanavaran/fanavaran-location.service.spec.ts new file mode 100644 index 0000000..6e02697 --- /dev/null +++ b/src/fanavaran/fanavaran-location.service.spec.ts @@ -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(); + }); +}); diff --git a/src/fanavaran/fanavaran-location.service.ts b/src/fanavaran/fanavaran-location.service.ts index 5f0352e..efb6840 100644 --- a/src/fanavaran/fanavaran-location.service.ts +++ b/src/fanavaran/fanavaran-location.service.ts @@ -113,6 +113,54 @@ export class FanavaranLocationService { } } + async fileMakerBranchId( + fileMakerId: string | null | undefined, + ): Promise { + 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 { + 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 { + 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( userId: string | null | undefined, kind: "maker" | "reviewer", diff --git a/src/helpers/iran-datetime.spec.ts b/src/helpers/iran-datetime.spec.ts index 7872d3d..290ebec 100644 --- a/src/helpers/iran-datetime.spec.ts +++ b/src/helpers/iran-datetime.spec.ts @@ -18,9 +18,22 @@ describe("parseIranLocalDateTime", () => { 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", () => { const dateOnly = new Date("2025-05-16T00:00:00.000Z"); const instant = parseIranLocalDateTime(dateOnly, "18:28"); 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(); + }); }); diff --git a/src/helpers/iran-datetime.ts b/src/helpers/iran-datetime.ts index e603910..7f463f3 100644 --- a/src/helpers/iran-datetime.ts +++ b/src/helpers/iran-datetime.ts @@ -26,6 +26,7 @@ function normalizeTimeToHms(time: string): string | null { const hh = m[1].padStart(2, "0"); const mm = m[2]; const ss = m[3] ?? "00"; + if (Number(hh) > 23 || Number(mm) > 59 || Number(ss) > 59) return null; return `${hh}:${mm}:${ss}`; } @@ -56,7 +57,12 @@ export function parseIranLocalDateTime( if (/[zZ]|[+-]\d{2}:?\d{2}$/.test(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); diff --git a/src/request-management/dto/expert-complete-claim-data.dto.ts b/src/request-management/dto/expert-complete-claim-data.dto.ts index 0b4c903..aacc883 100644 --- a/src/request-management/dto/expert-complete-claim-data.dto.ts +++ b/src/request-management/dto/expert-complete-claim-data.dto.ts @@ -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, diff --git a/src/request-management/entities/schema/blame-cases.schema.ts b/src/request-management/entities/schema/blame-cases.schema.ts index 090ddda..86b5cbd 100644 --- a/src/request-management/entities/schema/blame-cases.schema.ts +++ b/src/request-management/entities/schema/blame-cases.schema.ts @@ -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; diff --git a/src/request-management/request-management.file-reviewer.spec.ts b/src/request-management/request-management.file-reviewer.spec.ts index 153b3ac..8424e82 100644 --- a/src/request-management/request-management.file-reviewer.spec.ts +++ b/src/request-management/request-management.file-reviewer.spec.ts @@ -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, diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 5c58de6..b707401 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -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 { + 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;