diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 050fd23..6c4f8c9 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -2477,6 +2477,104 @@ export class ExpertClaimService { throw new HttpException(error.response, error.claimStatus); } + + /** + * FILE_REVIEWER path: atomically assign this reviewer to the linked V4 blame + * file. Operates on the blame document (not the claim workflow lock). + * + * Rules: + * - Blame must be isMadeByFileMaker, IN_PERSON, expertInitiated. + * - Status must be WAITING_FOR_FILE_REVIEWER. + * - No other reviewer may have taken it (assignedFileReviewerId absent/null). + * - Idempotent: same reviewer calling again gets "already_assigned_to_you". + */ + private async assignFileReviewerToV4Blame( + claimRequestId: string, + claim: any, + actor: { sub: string; fullName?: string; clientKey?: string }, + ): Promise { + if (!claim.blameRequestId) { + throw new BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: "This claim has no linked blame file.", + }); + } + + const blame = await this.blameRequestDbService.findById( + String(claim.blameRequestId), + ); + if (!blame) { + throw new NotFoundException("Linked blame file not found."); + } + if (!(blame as any).isMadeByFileMaker) { + throw new BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: "Only V4 FileMaker files can be assigned to a FileReviewer.", + }); + } + if ((blame as any).status !== "WAITING_FOR_FILE_REVIEWER") { + throw new BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: `Blame file is not ready for FileReviewer. Status: ${(blame as any).status}`, + }); + } + + const existing = (blame as any).assignedFileReviewerId; + if (existing) { + if (String(existing) === actor.sub) { + return { + success: true, + status: "already_assigned_to_you", + message: "You have already taken this file.", + }; + } + throw new ConflictException({ + success: false, + status: "locked" satisfies ExpertFileAssignStatus, + message: "Another FileReviewer has already taken this file.", + }); + } + + // Atomically claim it — findOneAndUpdate with null/missing guard + const reviewerOid = new Types.ObjectId(actor.sub); + const updated = await this.blameRequestDbService.findOneAndUpdate( + { + _id: (blame as any)._id, + $or: [ + { assignedFileReviewerId: { $exists: false } }, + { assignedFileReviewerId: null }, + ], + }, + { $set: { assignedFileReviewerId: reviewerOid } }, + { new: true }, + ); + + if (!updated) { + // Another reviewer won the race + throw new ConflictException({ + success: false, + status: "locked" satisfies ExpertFileAssignStatus, + message: "Another FileReviewer has already taken this file.", + }); + } + + const now = new Date(); + this.logger.log( + `FileReviewer ${actor.sub} assigned to V4 blame ${String((blame as any)._id)} / claim ${claimRequestId}`, + ); + + return { + success: true, + status: "assigned", + assignedAt: now.toISOString(), + message: "File assigned to you. Proceed with accident fields and field capture.", + }; + } + + /** * Assign the current damage expert to a claim for review (V2). * Free cases are locked atomically; already-assigned-to-self is idempotent. @@ -2490,7 +2588,10 @@ export class ExpertClaimService { role?: string; }, ): Promise { - if ((actor as any).role !== RoleEnum.FIELD_EXPERT) + if ( + (actor as any).role !== RoleEnum.FIELD_EXPERT && + (actor as any).role !== RoleEnum.FILE_REVIEWER + ) requireActorClientKey(actor); await this.expireClaimWorkflowLockV2IfStale(claimRequestId); @@ -2499,6 +2600,13 @@ export class ExpertClaimService { throw new NotFoundException("Claim request not found"); } + // FILE_REVIEWER: assign themselves to the linked blame (V4 flow). + // This is a different path from the damage-expert lock — it operates on the + // blame document and is idempotent (same reviewer can re-assign to themselves). + if ((actor as any).role === RoleEnum.FILE_REVIEWER) { + return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor); + } + await this.assertExpertActorOnClaim(claim, actor); const isFieldExpertOwner = @@ -3652,6 +3760,9 @@ export class ExpertClaimService { if (actor.role === RoleEnum.FILE_REVIEWER) { return this.getFileReviewerClaimListV2(actor, query); } + if (actor.role === RoleEnum.FILE_MAKER) { + return this.getFileMakerClaimListV2(actor, query); + } requireActorClientKey(actor); const actorId = actor.sub; const clientKey = actor.clientKey as string; @@ -3924,51 +4035,145 @@ export class ExpertClaimService { } /** - * Claim list for FILE_REVIEWER — returns claims linked to sealed expert-initiated - * IN_PERSON blame files (WAITING_FOR_FILE_REVIEWER, WAITING_FOR_EXPERT, COMPLETED) - * that belong to this reviewer's insurance company. + * Claim list for FILE_REVIEWER. + * + * Visibility rules: + * - WAITING_FOR_FILE_REVIEWER files made by a FileMaker that are either: + * (a) not yet assigned to any reviewer, OR + * (b) already assigned to THIS reviewer + * - WAITING_FOR_EXPERT files that are assigned to THIS reviewer + * (they completed the field work and the file is now in damage-expert queue) + * + * All scoped to this reviewer's insurance company via blame party clientId. */ private async getFileReviewerClaimListV2( actor: any, query: ListQueryV2Dto = {}, ): Promise { const clientKey = requireActorClientKey(actor); + const reviewerOid = new Types.ObjectId(actor.sub); - // Find all sealed blame files (across statuses the reviewer cares about) - const sealedBlames = (await this.blameRequestDbService.find( + // Find V4 blame files visible to this reviewer + const blames = (await this.blameRequestDbService.find( { + isMadeByFileMaker: true, expertInitiated: true, creationMethod: "IN_PERSON", - status: { - $in: [ - "WAITING_FOR_FILE_REVIEWER", - "WAITING_FOR_EXPERT", - "COMPLETED", - ], - }, + $or: [ + // Open: sealed by FileMaker, not yet taken by any reviewer + { + status: "WAITING_FOR_FILE_REVIEWER", + $or: [ + { assignedFileReviewerId: { $exists: false } }, + { assignedFileReviewerId: null }, + ], + }, + // Taken by this reviewer (any post-assignment status) + { assignedFileReviewerId: reviewerOid }, + ], + }, + { + lean: true, + select: + "_id type parties blameStatus status expert.decision assignedFileReviewerId", }, - { lean: true, select: "_id type parties blameStatus status expert.decision" }, )) as any[]; - if (sealedBlames.length === 0) { + if (blames.length === 0) { return this.paginateClaimListV2([], query); } // Scope to this reviewer's insurer via blame party clientId - const scopedBlameIds = sealedBlames - .filter((b) => blameCaseTouchesClient(b, clientKey)) - .map((b) => b._id); + const scopedBlames = blames.filter((b) => + blameCaseTouchesClient(b, clientKey), + ); - if (scopedBlameIds.length === 0) { + if (scopedBlames.length === 0) { return this.paginateClaimListV2([], query); } + const scopedBlameIds = scopedBlames.map((b) => b._id); const claims = (await this.claimCaseDbService.find({ blameRequestId: { $in: scopedBlameIds }, })) as any[]; const blameById = new Map( - sealedBlames.map((b) => [String(b._id), b]), + scopedBlames.map((b) => [String(b._id), b]), + ); + + const list = claims.map((c) => { + const blame = c.blameRequestId + ? blameById.get(c.blameRequestId.toString()) + : undefined; + const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById); + const fileCtx = blame ? this.blameFileContextForExpert(blame) : {}; + const lockActive = !!( + c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c) + ); + const assignedToMe = + blame?.assignedFileReviewerId && + String(blame.assignedFileReviewerId) === actor.sub; + return { + claimRequestId: c._id.toString(), + publicId: c.publicId, + status: c.status, + unifiedFileStatus: resolveUnifiedFileStatus({ + blameStatus: blame?.status, + claimStatus: c.status, + }), + currentStep: c.workflow?.currentStep || "", + locked: lockActive, + lockedBy: + lockActive && c.workflow?.lockedBy + ? { + actorId: c.workflow.lockedBy.actorId?.toString(), + actorName: c.workflow.lockedBy.actorName, + lockedAt: (c.workflow as any).lockedAt?.toISOString?.(), + expiredAt: (c.workflow as any).expiredAt?.toISOString?.(), + } + : undefined, + vehicle: v + ? { carName: v.carName, carModel: v.carModel, carType: v.carType } + : undefined, + ...fileCtx, + blameRequestId: c.blameRequestId?.toString(), + createdAt: c.createdAt, + awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c), + needsFileReviewerCompletion: + String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER", + assignedToMe: !!assignedToMe, + }; + }) as ClaimListItemV2Dto[]; + + return this.paginateClaimListV2(list, query); + } + + /** + * Claim list for FILE_MAKER — returns only the claims linked to blame files + * they personally created (V4 flow). + */ + private async getFileMakerClaimListV2( + actor: any, + query: ListQueryV2Dto = {}, + ): Promise { + const makerOid = new Types.ObjectId(actor.sub); + + const makerBlames = (await this.blameRequestDbService.find( + { isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid }, + { lean: true, select: "_id type parties blameStatus status expert.decision assignedFileReviewerId" }, + )) as any[]; + + if (makerBlames.length === 0) { + return this.paginateClaimListV2([], query); + } + + const blameIds = makerBlames.map((b) => b._id); + const claims = (await this.claimCaseDbService.find({ + blameRequestId: { $in: blameIds }, + })) as any[]; + + const blameById = new Map( + makerBlames.map((b) => [String(b._id), b]), ); const list = claims.map((c) => { @@ -4008,6 +4213,9 @@ export class ExpertClaimService { awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c), needsFileReviewerCompletion: String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER", + assignedFileReviewerId: blame?.assignedFileReviewerId + ? String(blame.assignedFileReviewerId) + : undefined, }; }) as ClaimListItemV2Dto[]; @@ -4193,7 +4401,8 @@ export class ExpertClaimService { }): Promise { if ( (actor as any).role === RoleEnum.FIELD_EXPERT || - (actor as any).role === RoleEnum.FILE_REVIEWER + (actor as any).role === RoleEnum.FILE_REVIEWER || + (actor as any).role === RoleEnum.FILE_MAKER ) return; const clientKey = requireActorClientKey(actor); const lockTtlMs = EXPERT_WORKFLOW_LOCK_TTL_MS; @@ -4354,7 +4563,10 @@ export class ExpertClaimService { actor: any, ): Promise { const actorId = actor.sub; - if (actor.role !== RoleEnum.FIELD_EXPERT) { + if ( + actor.role !== RoleEnum.FIELD_EXPERT && + actor.role !== RoleEnum.FILE_MAKER + ) { await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor); } await this.expireClaimWorkflowLockV2IfStale(claimRequestId); @@ -4374,31 +4586,51 @@ export class ExpertClaimService { const isFactorValidationPending = claimIsAwaitingExpertFactorValidationV2(claim); - // Field experts and FileReviewers can always view IN_PERSON expert-initiated - // claims regardless of claim status (they work across multiple non-standard steps). - if ( + // FileMaker: can always view their own V4 files at any status. + if (actor.role === RoleEnum.FILE_MAKER) { + const isOwn = claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame); + if (!isOwn) { + throw new ForbiddenException( + "FileMakers can only view files they created.", + ); + } + // Fall through to the detail build below + } else if ( + // Field experts and FileReviewers can view IN_PERSON expert-initiated claims + // regardless of claim status (they work across multiple non-standard steps). actor.role === RoleEnum.FIELD_EXPERT || actor.role === RoleEnum.FILE_REVIEWER ) { - if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) { - // For FILE_REVIEWER: also accept if the linked blame is sealed for them - const isFileReviewerEligible = - actor.role === RoleEnum.FILE_REVIEWER && + if (actor.role === RoleEnum.FILE_REVIEWER) { + // FILE_REVIEWER: must be a V4 file AND (they are the assigned reviewer OR + // the file is still open — WAITING_FOR_FILE_REVIEWER with no reviewer yet). + const isV4Blame = + (linkedBlame as any)?.isMadeByFileMaker && linkedBlame?.expertInitiated && - linkedBlame?.creationMethod === "IN_PERSON" && - [ - "WAITING_FOR_FILE_REVIEWER", - "WAITING_FOR_EXPERT", - "COMPLETED", - ].includes(String(linkedBlame?.status ?? "")); - if (!isFileReviewerEligible) { + linkedBlame?.creationMethod === "IN_PERSON"; + if (!isV4Blame) { throw new ForbiddenException( - "This claim is not accessible to you.", + "FileReviewers can only access V4 FileMaker files.", ); } + const assignedReviewerId = (linkedBlame as any)?.assignedFileReviewerId + ? String((linkedBlame as any).assignedFileReviewerId) + : null; + const isAssignedToMe = + assignedReviewerId && assignedReviewerId === actorId; + const isOpen = + !assignedReviewerId && + String(linkedBlame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER"; + if (!isAssignedToMe && !isOpen) { + throw new ForbiddenException( + "This file has been taken by another reviewer.", + ); + } + } else if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) { + throw new ForbiddenException("This claim is not accessible to you."); } // Fall through to the detail build below (skip the damage-expert gate) - } else { + } else if (actor.role !== RoleEnum.FILE_MAKER) { const isResendPending = claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND; diff --git a/src/request-management/entities/schema/blame-cases.schema.ts b/src/request-management/entities/schema/blame-cases.schema.ts index 2df68eb..b6cb33b 100644 --- a/src/request-management/entities/schema/blame-cases.schema.ts +++ b/src/request-management/entities/schema/blame-cases.schema.ts @@ -109,6 +109,20 @@ export class BlameRequest { /** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */ @Prop({ type: String, enum: FilledBy }) filledBy?: FilledBy; + + /** + * True when this file was created by a FileMaker (V4 split flow). + * These files are sealed by the FileMaker and completed by a FileReviewer. + */ + @Prop({ default: false }) + isMadeByFileMaker?: boolean; + + /** + * The FileReviewer who claimed this file for completion (V4 flow only). + * Set when a FileReviewer calls assign on this file; enforces one-reviewer-per-file. + */ + @Prop({ type: Types.ObjectId }) + assignedFileReviewerId?: Types.ObjectId; } export type BlameRequestDocument = HydratedDocument; diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 2ae576a..2c7411e 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -4151,15 +4151,12 @@ export class RequestManagementService { * Verify the current user (field expert) can access this BlameRequest (v2). */ private verifyExpertAccessForBlameV2(req: any, expert: any): void { - // FILE_REVIEWER can access any sealed expert-initiated IN_PERSON file - // (status WAITING_FOR_FILE_REVIEWER or beyond) scoped to their clientKey. + // FILE_REVIEWER can access V4 FileMaker-sealed files. + // They must be the assigned reviewer (or the file is still open for taking). if (expert?.role === RoleEnum.FILE_REVIEWER) { - if ( - !req?.expertInitiated || - req?.creationMethod !== CreationMethod.IN_PERSON - ) { + if (!req?.isMadeByFileMaker || !req?.expertInitiated || req?.creationMethod !== CreationMethod.IN_PERSON) { throw new ForbiddenException( - "FileReviewer can only access expert-initiated IN_PERSON files.", + "FileReviewer can only access V4 FileMaker files.", ); } if ( @@ -4171,6 +4168,15 @@ export class RequestManagementService { "This file has not been sealed by a FileMaker yet.", ); } + // Enforce reviewer assignment: must be assigned to them or open (no reviewer yet) + const assignedId = req.assignedFileReviewerId + ? String(req.assignedFileReviewerId) + : null; + if (assignedId && assignedId !== String(expert.sub)) { + throw new ForbiddenException( + "This file has been taken by another FileReviewer.", + ); + } return; } if (req?.expertInitiated && req?.initiatedByFieldExpertId) { @@ -4441,6 +4447,7 @@ export class RequestManagementService { }); } + const isFileMakerRole = (expert as any)?.role === RoleEnum.FILE_MAKER; const created = await this.blameRequestDbService.create({ publicId, requestNo: publicId, @@ -4460,6 +4467,7 @@ export class RequestManagementService { dto.creationMethod === CreationMethod.IN_PERSON ? FilledBy.EXPERT : FilledBy.CUSTOMER, + ...(isFileMakerRole ? { isMadeByFileMaker: true } : {}), }); const requestId = String((created as any)._id);