diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 0271de6..0d34d82 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -2558,11 +2558,21 @@ export class ExpertClaimService { if (!blame) { throw new NotFoundException("Linked blame file not found."); } - if (!(blame as any).isMadeByFileMaker) { + const clientKey = requireActorClientKey(actor); + if (!blameCaseTouchesClient(blame, clientKey)) { + throw new ForbiddenException( + "This file does not belong to your organization.", + ); + } + if ( + !(blame as any).isMadeByFileMaker || + !(blame as any).expertInitiated || + (blame as any).creationMethod !== "IN_PERSON" + ) { throw new BadRequestException({ success: false, status: "unavailable" satisfies ExpertFileAssignStatus, - message: "Only V4 FileMaker files can be assigned to a FileReviewer.", + message: "Only V4/V5 FileMaker files can be assigned to a FileReviewer.", }); } if ((blame as any).status !== "WAITING_FOR_FILE_REVIEWER") { @@ -2674,6 +2684,12 @@ export class ExpertClaimService { if (!reviewerBlame) { throw new NotFoundException("Linked blame file not found."); } + const clientKey = requireActorClientKey(actor); + if (!blameCaseTouchesClient(reviewerBlame, clientKey)) { + throw new ForbiddenException( + "This file does not belong to your organization.", + ); + } const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId ? String((reviewerBlame as any).assignedFileReviewerId) : null; @@ -4773,6 +4789,12 @@ export class ExpertClaimService { "FileReviewers can only access V4/V5 FileMaker files.", ); } + const clientKey = requireActorClientKey(actor); + if (!blameCaseTouchesClient(linkedBlame, clientKey)) { + throw new ForbiddenException( + "This file does not belong to your organization.", + ); + } const assignedReviewerId = (linkedBlame as any)?.assignedFileReviewerId ? String((linkedBlame as any).assignedFileReviewerId) : null; diff --git a/src/request-management/file-reviewer-blame-v4.controller.ts b/src/request-management/file-reviewer-blame-v4.controller.ts index 062c71c..fa66740 100644 --- a/src/request-management/file-reviewer-blame-v4.controller.ts +++ b/src/request-management/file-reviewer-blame-v4.controller.ts @@ -86,8 +86,9 @@ export class FileReviewerBlameV4Controller { @Get("my-files") @ApiOperation({ - summary: "List all blame files assigned to this FileReviewer", - description: "Returns all V4 FileMaker blame files that have been assigned to the authenticated FileReviewer.", + summary: "List available and assigned FileMaker blame files", + description: + "Returns V4 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.", }) async getMyFiles(@CurrentUser() fileReviewer: any) { return this.requestManagementService.getMyFileReviewerFiles(fileReviewer); @@ -96,8 +97,9 @@ export class FileReviewerBlameV4Controller { @Get("my-files/:requestId") @ApiParam({ name: "requestId", description: "Blame request ID" }) @ApiOperation({ - summary: "Get a single blame file assigned to this FileReviewer", - description: "Returns full detail — parties, workflow, expert fields, linked claim ID — for one V4 blame file.", + summary: "Get one available or assigned FileMaker blame file", + description: + "Returns full detail for a V4 FileMaker blame file in this reviewer's insurer when it is still available to claim or is assigned to the authenticated FileReviewer.", }) async getMyFileDetail( @Param("requestId") requestId: string, diff --git a/src/request-management/file-reviewer-blame-v5.controller.ts b/src/request-management/file-reviewer-blame-v5.controller.ts index 6d52c41..7be306c 100644 --- a/src/request-management/file-reviewer-blame-v5.controller.ts +++ b/src/request-management/file-reviewer-blame-v5.controller.ts @@ -84,8 +84,9 @@ export class FileReviewerBlameV5Controller { @Get("my-files") @ApiOperation({ - summary: "List all blame files assigned to this FileReviewer", - description: "Returns all V5 FileMaker blame files that have been assigned to the authenticated FileReviewer.", + summary: "List available and assigned FileMaker blame files", + description: + "Returns V5 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.", }) async getMyFiles(@CurrentUser() fileReviewer: any) { return this.requestManagementService.getMyFileReviewerFiles(fileReviewer); @@ -94,8 +95,9 @@ export class FileReviewerBlameV5Controller { @Get("my-files/:requestId") @ApiParam({ name: "requestId", description: "Blame request ID" }) @ApiOperation({ - summary: "Get a single blame file assigned to this FileReviewer", - description: "Returns full detail — parties, workflow, expert fields, linked claim ID — for one V5 blame file.", + summary: "Get one available or assigned FileMaker blame file", + description: + "Returns full detail for a V5 FileMaker blame file in this reviewer's insurer when it is still available to claim or is assigned to the authenticated FileReviewer.", }) async getMyFileDetail( @Param("requestId") requestId: string, diff --git a/src/request-management/request-management.file-reviewer.spec.ts b/src/request-management/request-management.file-reviewer.spec.ts new file mode 100644 index 0000000..d026a3b --- /dev/null +++ b/src/request-management/request-management.file-reviewer.spec.ts @@ -0,0 +1,140 @@ +import { Types } from "mongoose"; +import { ExpertClaimService } from "src/expert-claim/expert-claim.service"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { RequestManagementService } from "./request-management.service"; + +describe("RequestManagementService FileReviewer inbox", () => { + const reviewerId = new Types.ObjectId(); + const otherReviewerId = new Types.ObjectId(); + const clientId = new Types.ObjectId(); + const otherClientId = new Types.ObjectId(); + + const sealedFile = { + _id: new Types.ObjectId(), + publicId: "BLM-OPEN", + type: "THIRD_PARTY", + status: "WAITING_FOR_FILE_REVIEWER", + isMadeByFileMaker: true, + expertInitiated: true, + creationMethod: "IN_PERSON", + parties: [ + { + role: "FIRST", + person: { clientId, userId: new Types.ObjectId() }, + }, + ], + expert: { decision: { guiltyPartyId: new Types.ObjectId() } }, + }; + + function createService(files: any[]) { + const blameRequestDbService = { + find: jest.fn().mockResolvedValue(files), + }; + const service = new (RequestManagementService as any)( + undefined, + blameRequestDbService, + ) as RequestManagementService; + return { service, blameRequestDbService }; + } + + it("lists a FileMaker-sealed, unassigned file for a reviewer in the same tenant", async () => { + const { service, blameRequestDbService } = createService([sealedFile]); + + const result = await service.getMyFileReviewerFiles({ + sub: String(reviewerId), + role: RoleEnum.FILE_REVIEWER, + clientKey: String(clientId), + }); + + expect(result).toEqual([ + expect.objectContaining({ _id: sealedFile._id, publicId: "BLM-OPEN" }), + ]); + expect(blameRequestDbService.find).toHaveBeenCalledWith( + expect.objectContaining({ + isMadeByFileMaker: true, + expertInitiated: true, + creationMethod: "IN_PERSON", + $or: expect.arrayContaining([ + expect.objectContaining({ status: "WAITING_FOR_FILE_REVIEWER" }), + expect.objectContaining({ assignedFileReviewerId: reviewerId }), + ]), + }), + ); + }); + + it("does not list another tenant's open file or a file assigned to another reviewer", async () => { + const { service } = createService([ + { + ...sealedFile, + _id: new Types.ObjectId(), + parties: [{ role: "FIRST", person: { clientId: otherClientId } }], + }, + { + ...sealedFile, + _id: new Types.ObjectId(), + assignedFileReviewerId: otherReviewerId, + }, + ]); + + const result = await service.getMyFileReviewerFiles({ + sub: String(reviewerId), + role: RoleEnum.FILE_REVIEWER, + clientKey: String(clientId), + }); + + expect(result).toEqual([]); + }); + + it("does not expose an open file's details to a reviewer from another tenant", async () => { + const { service } = createService([]); + (service as any).blameRequestDbService.findById = jest.fn().mockResolvedValue({ + ...sealedFile, + parties: [{ role: "FIRST", person: { clientId: otherClientId } }], + }); + + await expect( + service.getMyFileReviewerFileDetail( + { + sub: String(reviewerId), + role: RoleEnum.FILE_REVIEWER, + clientKey: String(clientId), + }, + String(sealedFile._id), + ), + ).rejects.toThrow("does not belong to your organization"); + }); + + it("does not let a reviewer claim another tenant's file through its linked claim ID", async () => { + const blameRequestDbService = { + findById: jest.fn().mockResolvedValue({ + ...sealedFile, + parties: [{ role: "FIRST", person: { clientId: otherClientId } }], + }), + findOneAndUpdate: jest.fn(), + }; + const expertClaimService = new (ExpertClaimService as any)( + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + blameRequestDbService, + ) as ExpertClaimService; + + await expect( + (expertClaimService as any).assignFileReviewerToV4Blame( + String(new Types.ObjectId()), + { blameRequestId: sealedFile._id }, + { + sub: String(reviewerId), + role: RoleEnum.FILE_REVIEWER, + clientKey: String(clientId), + }, + ), + ).rejects.toThrow("does not belong to your organization"); + expect(blameRequestDbService.findOneAndUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 3c48ac1..0f795f9 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -109,6 +109,10 @@ import { buildBlamePartyAccessOrConditions, collectUserIdVariants, } from "src/helpers/party-access-queries"; +import { + blameCaseTouchesClient, + requireActorClientKey, +} from "src/helpers/tenant-scope"; import { resolveLinkedUserIdStrings } from "src/helpers/user-access-resolver"; import { normalizePlateText } from "src/utils/plate-normalizer/plate-normalizer.service"; @@ -4671,6 +4675,12 @@ export class RequestManagementService { "FileReviewer can only access V4/V5 FileMaker files.", ); } + const clientKey = requireActorClientKey(expert); + if (!blameCaseTouchesClient(req, clientKey)) { + throw new ForbiddenException( + "This file does not belong to your organization.", + ); + } if ( req.status !== CaseStatus.WAITING_FOR_FILE_REVIEWER && req.status !== CaseStatus.WAITING_FOR_EXPERT && @@ -11415,12 +11425,41 @@ export class RequestManagementService { if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) { throw new ForbiddenException("Only FileReviewers can use this endpoint."); } + const clientKey = requireActorClientKey(fileReviewer); const reviewerId = new Types.ObjectId(fileReviewer.sub); const files = await this.blameRequestDbService.find({ isMadeByFileMaker: true, - assignedFileReviewerId: reviewerId, + expertInitiated: true, + creationMethod: CreationMethod.IN_PERSON, + $or: [ + // A FileMaker-sealed file must be discoverable before a reviewer can + // claim it. Once another reviewer takes it, only that reviewer sees it. + { + status: CaseStatus.WAITING_FOR_FILE_REVIEWER, + $or: [ + { assignedFileReviewerId: { $exists: false } }, + { assignedFileReviewerId: null }, + ], + }, + { assignedFileReviewerId: reviewerId }, + ], }); - return (files || []).map((f: any) => ({ + const visibleFiles = (files || []).filter((file: any) => { + const assignedReviewerId = file.assignedFileReviewerId + ? String(file.assignedFileReviewerId) + : null; + const isOpen = + file.status === CaseStatus.WAITING_FOR_FILE_REVIEWER && + !assignedReviewerId; + const isAssignedToReviewer = + assignedReviewerId === String(fileReviewer.sub); + return ( + blameCaseTouchesClient(file, clientKey) && + (isOpen || isAssignedToReviewer) + ); + }); + + return visibleFiles.map((f: any) => ({ _id: f._id, publicId: f.publicId, requestNo: f.requestNo, @@ -11442,17 +11481,33 @@ export class RequestManagementService { if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) { throw new ForbiddenException("Only FileReviewers can use this endpoint."); } + const clientKey = requireActorClientKey(fileReviewer); const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Blame request not found"); - if (!req.isMadeByFileMaker) { + if ( + !req.isMadeByFileMaker || + !req.expertInitiated || + req.creationMethod !== CreationMethod.IN_PERSON + ) { throw new ForbiddenException("FileReviewer can only access V4/V5 FileMaker files."); } + if (!blameCaseTouchesClient(req, clientKey)) { + throw new ForbiddenException( + "This file does not belong to your organization.", + ); + } const assignedId = (req as any).assignedFileReviewerId ? String((req as any).assignedFileReviewerId) : null; if (assignedId && assignedId !== String(fileReviewer.sub)) { throw new ForbiddenException("This file has been taken by another FileReviewer."); } + if ( + !assignedId && + req.status !== CaseStatus.WAITING_FOR_FILE_REVIEWER + ) { + throw new ForbiddenException("This file is not available for review."); + } const plain = typeof (req as any).toObject === "function" ? (req as any).toObject({ versionKey: false }) : { ...(req as any) };