diff --git a/src/Types&Enums/blame-request-management/caseStatus.enum.ts b/src/Types&Enums/blame-request-management/caseStatus.enum.ts index 5e9a849..1978f00 100644 --- a/src/Types&Enums/blame-request-management/caseStatus.enum.ts +++ b/src/Types&Enums/blame-request-management/caseStatus.enum.ts @@ -18,17 +18,16 @@ export enum CaseStatus { WAITING_FOR_FILE_REVIEWER = "WAITING_FOR_FILE_REVIEWER", /** - * V5 flow only. FileReviewer has uploaded the blame accident video; - * the file is now awaiting FinancialExpert approval before moving - * to WAITING_FOR_EXPERT (fanavaran submission). + * V5 flow only. FileReviewer has completed the claim and the owner has signed; + * the FileMaker who created the file must now approve before fanavaran submission. */ - WAITING_FOR_FINANCIAL_EXPERT = "WAITING_FOR_FINANCIAL_EXPERT", + WAITING_FOR_FILE_MAKER_APPROVAL = "WAITING_FOR_FILE_MAKER_APPROVAL", /** - * V5 flow only. FinancialExpert rejected the file back to FileReviewer - * for correction (adjust data or send back to user and re-submit). + * V5 flow only. FileMaker rejected the file back to FileReviewer + * for correction (adjust pricing / back-and-forth with user and re-submit). */ - FINANCIAL_EXPERT_REJECTED = "FINANCIAL_EXPERT_REJECTED", + FILE_MAKER_REJECTED = "FILE_MAKER_REJECTED", COMPLETED = "COMPLETED", diff --git a/src/Types&Enums/claim-request-management/claim-case-status.enum.ts b/src/Types&Enums/claim-request-management/claim-case-status.enum.ts index 0c0905f..fb6a426 100644 --- a/src/Types&Enums/claim-request-management/claim-case-status.enum.ts +++ b/src/Types&Enums/claim-request-management/claim-case-status.enum.ts @@ -43,6 +43,18 @@ export enum ClaimCaseStatus { */ WAITING_FOR_FILE_REVIEWER = "WAITING_FOR_FILE_REVIEWER", + /** + * V5 split flow only. The claim is fully evaluated and owner has signed; + * the FileMaker who created the file must approve before fanavaran submission. + */ + WAITING_FOR_FILE_MAKER_APPROVAL = "WAITING_FOR_FILE_MAKER_APPROVAL", + + /** + * V5 split flow only. FileMaker rejected the completed claim back to FileReviewer + * for correction (adjust pricing, re-do expert review, back-and-forth with user). + */ + FILE_MAKER_REJECTED = "FILE_MAKER_REJECTED", + // Final states COMPLETED = "COMPLETED", CANCELLED = "CANCELLED", diff --git a/src/Types&Enums/role.enum.ts b/src/Types&Enums/role.enum.ts index 394eb16..c71b816 100644 --- a/src/Types&Enums/role.enum.ts +++ b/src/Types&Enums/role.enum.ts @@ -9,5 +9,4 @@ export enum RoleEnum { FILE_MAKER = "file_maker", FILE_REVIEWER = "file_reviewer", SUPER_ADMIN = "super_admin", - FINANCIAL_EXPERT = "financial_expert", } diff --git a/src/auth/auth-services/actor.auth.service.ts b/src/auth/auth-services/actor.auth.service.ts index d6fd56f..3d87a8c 100644 --- a/src/auth/auth-services/actor.auth.service.ts +++ b/src/auth/auth-services/actor.auth.service.ts @@ -29,7 +29,6 @@ import { FieldExpertDbService } from "src/users/entities/db-service/field-expert import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service"; import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service"; import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service"; -import { FinancialExpertDbService } from "src/users/entities/db-service/financial-expert.db.service"; import { HashService } from "src/utils/hash/hash.service"; import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service"; import { SuperAdminDbService } from "src/super-admin/entities/db-service/super-admin.db.service"; @@ -58,7 +57,6 @@ export class ActorAuthService { private readonly captchaChallengeService: CaptchaChallengeService, private readonly fileMakerDbService: FileMakerDbService, private readonly fileReviewerDbService: FileReviewerDbService, - private readonly financialExpertDbService: FinancialExpertDbService, private readonly superAdminDbService: SuperAdminDbService, ) {} @@ -118,15 +116,6 @@ export class ActorAuthService { res = await this.fileReviewerDbService.findByLoginIdentifier(username); break; - case RoleEnum.FINANCIAL_EXPERT: - if (username == null && userId) - res = await this.financialExpertDbService.findOne({ - _id: new Types.ObjectId(userId), - }); - else - res = - await this.financialExpertDbService.findByLoginIdentifier(username); - break; case RoleEnum.SUPER_ADMIN: if (username == null && userId) res = await this.superAdminDbService.findOne({ diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 1c94982..cec1b6a 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -6167,6 +6167,30 @@ export class ClaimRequestManagementService { }; } + // V5 flow: FileMaker approval required before fanavaran. + // Instead of submitting, hold the claim at WAITING_FOR_FILE_MAKER_APPROVAL. + if ((claimCase as any).requiresFileMakerApproval) { + await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, { + $set: { status: ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL }, + $push: { + history: { + type: "V5_HELD_FOR_FILE_MAKER_APPROVAL", + actor: { actorType: "system" }, + timestamp: new Date(), + metadata: { claimCaseId }, + }, + }, + }); + return { + attempted: false, + submitted: false, + skipped: true, + skipReason: + "V5 flow: FileMaker approval required before Fanavaran submission. " + + "Claim is now in WAITING_FOR_FILE_MAKER_APPROVAL.", + }; + } + const skipReason = await this.getFanavaranAutoSubmitSkipReason(claimCase); if (skipReason) { return { 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 c66db0b..6b6845d 100644 --- a/src/claim-request-management/entites/schema/claim-cases.schema.ts +++ b/src/claim-request-management/entites/schema/claim-cases.schema.ts @@ -242,6 +242,20 @@ export class ClaimCase { @Prop({ type: Types.ObjectId, index: true }) createdByRegistrarId?: Types.ObjectId; + /** + * V5 split flow: when true, the claim must be approved by the FileMaker + * who created the file before fanavaran submission is allowed. + */ + @Prop({ type: Boolean, default: false }) + requiresFileMakerApproval?: boolean; + + /** + * V5 split flow: ObjectId of the FileMaker who must approve this claim. + * Set when the FileReviewer uploads the blame accident video in the V5 flow. + */ + @Prop({ type: Types.ObjectId, index: true }) + fileMakerApprovalActorId?: Types.ObjectId; + /** * Legacy fields kept optional to simplify progressive migration. * If you choose to migrate later, we can remove these. diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index fdbee26..b80c82d 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -388,6 +388,12 @@ export class ExpertClaimService { claim: any, actor: any, ): Promise { + // 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); assertClaimCaseForExpertActor(claim, actor, blame); } @@ -2635,11 +2641,55 @@ 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). + // FILE_REVIEWER: two-phase lock behaviour. + // Phase 1 — blame is WAITING_FOR_FILE_REVIEWER: atomically set + // assignedFileReviewerId on the blame document. + // Phase 2 — blame is already past WAITING_FOR_FILE_REVIEWER (i.e. the + // reviewer finished field work and blame moved to WAITING_FOR_EXPERT): + // the reviewer now needs the standard damage-expert workflow lock + // so they can perform damage assessment. Fall through to the + // damage-expert path below; assertExpertActorOnClaim will verify + // tenant scope via the reviewer's clientKey. if ((actor as any).role === RoleEnum.FILE_REVIEWER) { - return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor); + if (!claim.blameRequestId) { + throw new BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: "This claim has no linked blame file.", + }); + } + const reviewerBlame = await this.blameRequestDbService.findById( + String(claim.blameRequestId), + ); + if (!reviewerBlame) { + throw new NotFoundException("Linked blame file not found."); + } + const blameStatus = (reviewerBlame as any).status as string; + if (blameStatus === "WAITING_FOR_FILE_REVIEWER") { + // Phase 1: blame-assign path + return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor); + } + // Phase 2: blame is past the initial assignment step. + // Only the reviewer who was assigned during Phase 1 may proceed. + const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId + ? String((reviewerBlame as any).assignedFileReviewerId) + : null; + if (!assignedReviewerId) { + throw new BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: "No reviewer has been assigned to this file yet.", + }); + } + if (assignedReviewerId !== actor.sub) { + throw new ConflictException({ + success: false, + status: "locked" satisfies ExpertFileAssignStatus, + message: "This file is assigned to another reviewer.", + }); + } + // Assigned reviewer — fall through to the damage-expert workflow lock below. + // (actor.role stays FILE_REVIEWER; assertExpertActorOnClaim checks clientKey scope) } await this.assertExpertActorOnClaim(claim, actor); @@ -4613,7 +4663,8 @@ export class ExpertClaimService { const actorId = actor.sub; if ( actor.role !== RoleEnum.FIELD_EXPERT && - actor.role !== RoleEnum.FILE_MAKER + actor.role !== RoleEnum.FILE_MAKER && + actor.role !== RoleEnum.FILE_REVIEWER ) { await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor); } @@ -4625,7 +4676,12 @@ export class ExpertClaimService { } const linkedBlame = await this.loadBlameForClaim(claim); - assertClaimCaseForExpertActor(claim, actor, linkedBlame); + // FILE_REVIEWER access is validated by the role-specific gate below + // (isAssignedToMe || isOpen). Skip the generic tenant-scope assert which + // would incorrectly reject them via the initiatedByFieldExpertId path. + if (actor.role !== RoleEnum.FILE_REVIEWER) { + assertClaimCaseForExpertActor(claim, actor, linkedBlame); + } // Variables used both in the gate block and in the detail-build section below const isDamageExpertPhase = @@ -4650,15 +4706,15 @@ export class ExpertClaimService { 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 + // FILE_REVIEWER: must be a V4/V5 file AND (they are the assigned reviewer OR // the file is still open — WAITING_FOR_FILE_REVIEWER with no reviewer yet). - const isV4Blame = + const isV4V5Blame = (linkedBlame as any)?.isMadeByFileMaker && linkedBlame?.expertInitiated && linkedBlame?.creationMethod === "IN_PERSON"; - if (!isV4Blame) { + if (!isV4V5Blame) { throw new ForbiddenException( - "FileReviewers can only access V4 FileMaker files.", + "FileReviewers can only access V4/V5 FileMaker files.", ); } const assignedReviewerId = (linkedBlame as any)?.assignedFileReviewerId diff --git a/src/expert-insurer/dto/create-insurer-expert.dto.ts b/src/expert-insurer/dto/create-insurer-expert.dto.ts index 2858cc1..05e668b 100644 --- a/src/expert-insurer/dto/create-insurer-expert.dto.ts +++ b/src/expert-insurer/dto/create-insurer-expert.dto.ts @@ -97,11 +97,3 @@ export class CreateFileReviewerByInsurerDto extends CreateInsurerExpertDto { }) role?: RoleEnum.FILE_REVIEWER; } - -export class CreateFinancialExpertByInsurerDto extends CreateInsurerExpertDto { - @ApiPropertyOptional({ - enum: [RoleEnum.FINANCIAL_EXPERT], - default: RoleEnum.FINANCIAL_EXPERT, - }) - role?: RoleEnum.FINANCIAL_EXPERT; -} diff --git a/src/expert-insurer/expert-insurer.controller.ts b/src/expert-insurer/expert-insurer.controller.ts index e17405c..9d11423 100644 --- a/src/expert-insurer/expert-insurer.controller.ts +++ b/src/expert-insurer/expert-insurer.controller.ts @@ -38,7 +38,6 @@ import { CreateClaimExpertByInsurerDto, CreateFileMakerByInsurerDto, CreateFileReviewerByInsurerDto, - CreateFinancialExpertByInsurerDto, } from "./dto/create-insurer-expert.dto"; @Controller("expert-insurer") @@ -183,23 +182,6 @@ export class ExpertInsurerController { return this.expertInsurerService.addFileReviewer(insurer.clientKey, body); } - @Post("experts/financial-expert") - @ApiBody({ type: CreateFinancialExpertByInsurerDto }) - @ApiOperation({ - summary: "Create a FinancialExpert account under this insurer (V5 flow)", - description: - "FinancialExperts are the final approvers in the V5 blame flow before fanavaran submission.", - }) - async addFinancialExpert( - @CurrentUser() insurer, - @Body() body: CreateFinancialExpertByInsurerDto, - ) { - if (!insurer) { - throw new UnauthorizedException("Could not identify the current user."); - } - return this.expertInsurerService.addFinancialExpert(insurer.clientKey, body); - } - @ApiQuery({ name: "page", type: Number }) @ApiQuery({ name: "response_count", type: Number }) @Get("experts/list") diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index cf8a7fe..0bf526d 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -14,7 +14,6 @@ import { CreateClaimExpertByInsurerDto, CreateFileMakerByInsurerDto, CreateFileReviewerByInsurerDto, - CreateFinancialExpertByInsurerDto, CreateInsurerExpertDto, } from "./dto/create-insurer-expert.dto"; import { @@ -30,7 +29,6 @@ import { ExpertDbService } from "src/users/entities/db-service/expert.db.service import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service"; import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service"; import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service"; -import { FinancialExpertDbService } from "src/users/entities/db-service/financial-expert.db.service"; import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service"; import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema"; import { HashService } from "src/utils/hash/hash.service"; @@ -97,7 +95,6 @@ export class ExpertInsurerService { private readonly claimSignDbService: ClaimSignDbService, private readonly fileMakerDbService: FileMakerDbService, private readonly fileReviewerDbService: FileReviewerDbService, - private readonly financialExpertDbService: FinancialExpertDbService, ) {} /** @@ -1769,58 +1766,6 @@ export class ExpertInsurerService { }; } - async addFinancialExpert( - insurerClientKey: string, - payload: CreateFinancialExpertByInsurerDto, - ) { - const clientObjectId = this.getClientId(insurerClientKey); - const branch = await this.assertBranchBelongsToClient( - payload.branchId, - clientObjectId, - ); - - const email = payload.email.trim().toLowerCase(); - const existing = await this.financialExpertDbService.findOne({ email }); - if (existing) { - throw new ConflictException( - "A FinancialExpert with this email already exists.", - ); - } - - const nationalCode = payload.nationalCode.trim(); - const existingByNational = await this.financialExpertDbService.findOne({ - nationalCode, - }); - if (existingByNational) { - throw new ConflictException( - "A FinancialExpert with this national code already exists.", - ); - } - - const hashedPassword = await this.hashService.hash(payload.password); - const created = await this.financialExpertDbService.create({ - ...payload, - email, - nationalCode, - password: hashedPassword, - role: RoleEnum.FINANCIAL_EXPERT, - clientKey: clientObjectId, - branchId: new Types.ObjectId(payload.branchId), - }); - - return { - financialExpert: this.sanitizeExpertResponse(created), - branch: { - _id: (branch as any)._id, - name: branch.name, - code: branch.code, - city: branch.city, - state: branch.state, - address: branch.address, - }, - }; - } - /** * Get comprehensive statistics for all experts of a client * Returns: diff --git a/src/request-management/file-maker-claim-approval-v5.controller.ts b/src/request-management/file-maker-claim-approval-v5.controller.ts new file mode 100644 index 0000000..9183847 --- /dev/null +++ b/src/request-management/file-maker-claim-approval-v5.controller.ts @@ -0,0 +1,109 @@ +import { + Body, + Controller, + Param, + Post, + UseGuards, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiTags, +} from "@nestjs/swagger"; +import { IsOptional, IsString } from "class-validator"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; +import { RolesGuard } from "src/auth/guards/role.guard"; +import { Roles } from "src/decorators/roles.decorator"; +import { CurrentUser } from "src/decorators/user.decorator"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { RequestManagementService } from "./request-management.service"; +import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service"; + +class FileMakerRejectDto { + @ApiPropertyOptional({ + description: "Reason for rejection (visible to FileReviewer)", + example: "Damage assessment is incorrect — please re-evaluate parts X and Y", + }) + @IsOptional() + @IsString() + reason?: string; +} + +/** + * V5 FileMaker approval panel. + * + * After the full claim flow completes (FileReviewer does damage assessment via + * expert-claim APIs, user signs), the claim lands in WAITING_FOR_FILE_MAKER_APPROVAL. + * The FileMaker who created the blame file can then: + * + * approve → triggers fanavaran submission (claim → COMPLETED) + * reject → sends claim back to WAITING_FOR_DAMAGE_EXPERT so the FileReviewer + * can re-lock, adjust pricing, and redo the user interaction + * + * All endpoints operate on `claimRequestId` (the claim case ID, not the blame ID). + * Use `GET v5/file-maker/blame-request-management/claim-id/:requestId` to obtain + * the claimRequestId from the original blame request ID. + */ +@ApiTags("v5 FileMaker — claim approval before fanavaran") +@Controller("v5/file-maker/claim-approval") +@ApiBearerAuth() +@UseGuards(LocalActorAuthGuard, RolesGuard) +@Roles(RoleEnum.FILE_MAKER) +export class FileMakerClaimApprovalV5Controller { + constructor( + private readonly requestManagementService: RequestManagementService, + private readonly claimRequestManagementService: ClaimRequestManagementService, + ) {} + + @Post("approve/:claimRequestId") + @ApiParam({ name: "claimRequestId" }) + @ApiOperation({ + summary: "Approve the completed claim (FileMaker V5)", + description: + "Approves a claim that is in WAITING_FOR_FILE_MAKER_APPROVAL status. " + + "Marks the claim COMPLETED and triggers fanavaran submission.", + }) + async approve( + @Param("claimRequestId") claimRequestId: string, + @CurrentUser() fileMaker: any, + ) { + const result = await this.requestManagementService.fileMakerApproveV5( + fileMaker, + claimRequestId, + ); + + // Trigger fanavaran auto-submit now that the claim is COMPLETED and the gate + // (requiresFileMakerApproval) has been cleared. + const fanavaran = + await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted( + claimRequestId, + ); + + return { ...result, fanavaran }; + } + + @Post("reject/:claimRequestId") + @ApiParam({ name: "claimRequestId" }) + @ApiBody({ type: FileMakerRejectDto }) + @ApiOperation({ + summary: "Reject the completed claim back to FileReviewer (FileMaker V5)", + description: + "Rejects a claim that is in WAITING_FOR_FILE_MAKER_APPROVAL status. " + + "Moves claim to FILE_MAKER_REJECTED so the FileReviewer can re-lock, " + + "adjust the damage assessment, and restart user interaction as needed.", + }) + async reject( + @Param("claimRequestId") claimRequestId: string, + @Body() body: FileMakerRejectDto, + @CurrentUser() fileMaker: any, + ) { + return this.requestManagementService.fileMakerRejectV5( + fileMaker, + claimRequestId, + body?.reason, + ); + } +} diff --git a/src/request-management/file-reviewer-blame-v5.controller.ts b/src/request-management/file-reviewer-blame-v5.controller.ts index 16db087..6159bc5 100644 --- a/src/request-management/file-reviewer-blame-v5.controller.ts +++ b/src/request-management/file-reviewer-blame-v5.controller.ts @@ -404,23 +404,4 @@ export class FileReviewerBlameV5Controller { ); } - // ─── Re-submission after FinancialExpert rejection ──────────────────────────── - - @Post("resubmit-to-financial-expert/:requestId") - @ApiParam({ name: "requestId" }) - @ApiOperation({ - summary: "Re-submit rejected file to FinancialExpert (FileReviewer)", - description: - "Use after the FinancialExpert has rejected the file (FINANCIAL_EXPERT_REJECTED). " + - "Moves status back to WAITING_FOR_FINANCIAL_EXPERT for re-review.", - }) - async resubmitToFinancialExpert( - @Param("requestId") requestId: string, - @CurrentUser() fileReviewer: any, - ) { - return this.requestManagementService.fileReviewerResubmitToFinancialExpert( - fileReviewer, - requestId, - ); - } } diff --git a/src/request-management/financial-expert-blame-v5.controller.ts b/src/request-management/financial-expert-blame-v5.controller.ts deleted file mode 100644 index e73034c..0000000 --- a/src/request-management/financial-expert-blame-v5.controller.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - Body, - Controller, - Get, - Param, - Post, - UseGuards, -} from "@nestjs/common"; -import { - ApiBearerAuth, - ApiBody, - ApiOperation, - ApiParam, - ApiTags, -} from "@nestjs/swagger"; -import { IsOptional, IsString } from "class-validator"; -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; -import { RolesGuard } from "src/auth/guards/role.guard"; -import { Roles } from "src/decorators/roles.decorator"; -import { CurrentUser } from "src/decorators/user.decorator"; -import { RoleEnum } from "src/Types&Enums/role.enum"; -import { RequestManagementService } from "./request-management.service"; - -class FinancialExpertRejectDto { - @ApiPropertyOptional({ - description: "Reason for rejection (shown to FileReviewer)", - example: "Documents are incomplete or incorrect", - }) - @IsOptional() - @IsString() - reason?: string; -} - -/** - * V5 FinancialExpert panel. - * - * The FinancialExpert is the final gatekeeper before a V5 blame file is forwarded - * to fanavaran for completion. After the FileReviewer uploads the blame accident - * video the file lands in WAITING_FOR_FINANCIAL_EXPERT. The FinancialExpert can: - * - * approve → WAITING_FOR_EXPERT (file proceeds to damage-expert / fanavaran) - * reject → FINANCIAL_EXPERT_REJECTED (FileReviewer must fix and re-submit) - * - * Re-submission by FileReviewer is handled via the V5 FileReviewer controller - * (POST resubmit-to-financial-expert/:requestId). - */ -@ApiTags("v5 FinancialExpert — blame file financial approval") -@Controller("v5/financial-expert/blame-request-management") -@ApiBearerAuth() -@UseGuards(LocalActorAuthGuard, RolesGuard) -@Roles(RoleEnum.FINANCIAL_EXPERT) -export class FinancialExpertBlameV5Controller { - constructor( - private readonly requestManagementService: RequestManagementService, - ) {} - - @Get("claim-id/:requestId") - @ApiParam({ name: "requestId", description: "Blame request ID" }) - @ApiOperation({ - summary: "Get linked claim ID", - description: - "Returns the claim linked to this V5 blame file. Useful for reviewing claim details before approving.", - }) - async getLinkedClaimId( - @Param("requestId") requestId: string, - @CurrentUser() financialExpert: any, - ) { - return this.requestManagementService.getV3LinkedClaimForExpert( - financialExpert, - requestId, - ); - } - - @Post("approve/:requestId") - @ApiParam({ name: "requestId" }) - @ApiOperation({ - summary: "Approve blame file (FinancialExpert)", - description: - "Moves the file from WAITING_FOR_FINANCIAL_EXPERT to WAITING_FOR_EXPERT, " + - "enabling fanavaran submission by the damage expert.", - }) - async approve( - @Param("requestId") requestId: string, - @CurrentUser() financialExpert: any, - ) { - return this.requestManagementService.financialExpertApprove( - financialExpert, - requestId, - ); - } - - @Post("reject/:requestId") - @ApiParam({ name: "requestId" }) - @ApiBody({ type: FinancialExpertRejectDto }) - @ApiOperation({ - summary: "Reject blame file (FinancialExpert)", - description: - "Moves the file from WAITING_FOR_FINANCIAL_EXPERT to FINANCIAL_EXPERT_REJECTED. " + - "The FileReviewer must correct the file and call resubmit-to-financial-expert.", - }) - async reject( - @Param("requestId") requestId: string, - @Body() body: FinancialExpertRejectDto, - @CurrentUser() financialExpert: any, - ) { - return this.requestManagementService.financialExpertReject( - financialExpert, - requestId, - body?.reason, - ); - } -} diff --git a/src/request-management/request-management.module.ts b/src/request-management/request-management.module.ts index 2d85074..9da5a2d 100644 --- a/src/request-management/request-management.module.ts +++ b/src/request-management/request-management.module.ts @@ -47,7 +47,7 @@ import { FileMakerBlameV4Controller } from "./file-maker-blame-v4.controller"; import { FileReviewerBlameV4Controller } from "./file-reviewer-blame-v4.controller"; import { FileMakerBlameV5Controller } from "./file-maker-blame-v5.controller"; import { FileReviewerBlameV5Controller } from "./file-reviewer-blame-v5.controller"; -import { FinancialExpertBlameV5Controller } from "./financial-expert-blame-v5.controller"; +import { FileMakerClaimApprovalV5Controller } from "./file-maker-claim-approval-v5.controller"; import { InquiryRefreshController } from "./inquiry-refresh.controller"; import { InquiryRefreshService } from "./inquiry-refresh.service"; @@ -90,7 +90,7 @@ import { InquiryRefreshService } from "./inquiry-refresh.service"; FileReviewerBlameV4Controller, FileMakerBlameV5Controller, FileReviewerBlameV5Controller, - FinancialExpertBlameV5Controller, + FileMakerClaimApprovalV5Controller, InquiryRefreshController, ], providers: [ diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 5bd7ae7..06b2fb6 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -4458,7 +4458,7 @@ export class RequestManagementService { /** * Verify the current user (field expert) can access this BlameRequest (v2). */ - private verifyExpertAccessForBlameV2(req: any, expert: any): void { + private async verifyExpertAccessForBlameV2(req: any, expert: any): Promise { // FILE_REVIEWER can access V4/V5 FileMaker-sealed files. // They must be the assigned reviewer (or the file is still open for taking). if (expert?.role === RoleEnum.FILE_REVIEWER) { @@ -4470,15 +4470,18 @@ export class RequestManagementService { if ( req.status !== CaseStatus.WAITING_FOR_FILE_REVIEWER && req.status !== CaseStatus.WAITING_FOR_EXPERT && - req.status !== CaseStatus.WAITING_FOR_FINANCIAL_EXPERT && - req.status !== CaseStatus.FINANCIAL_EXPERT_REJECTED && req.status !== CaseStatus.COMPLETED ) { throw new ForbiddenException( "This file has not been sealed by a FileMaker yet.", ); } - // Enforce reviewer assignment: must be assigned to them or open (no reviewer yet) + // Enforce reviewer assignment: must be assigned to them or open (no reviewer yet). + // If the file is open (no assignedFileReviewerId) we atomically claim it now — + // this handles the case where a reviewer skipped the explicit lock step and went + // straight to working on the file (e.g. accident-fields before calling the lock + // endpoint). Without this, assignedFileReviewerId is never written and the file + // disappears from the reviewer's list after the blame moves to WAITING_FOR_EXPERT. const assignedId = req.assignedFileReviewerId ? String(req.assignedFileReviewerId) : null; @@ -4487,23 +4490,19 @@ export class RequestManagementService { "This file has been taken by another FileReviewer.", ); } - return; - } - // FINANCIAL_EXPERT can read V5 FileMaker-sealed files from the same insurer. - if (expert?.role === RoleEnum.FINANCIAL_EXPERT) { - if (!req?.isMadeByFileMaker || !req?.expertInitiated || req?.creationMethod !== CreationMethod.IN_PERSON) { - throw new ForbiddenException( - "FinancialExpert can only access V5 FileMaker files.", - ); - } - // Scope to same insurer client - if ( - req.clientKey && - expert.clientKey && - String(req.clientKey) !== String(expert.clientKey) - ) { - throw new ForbiddenException( - "You do not have access to files from a different insurer.", + if (!assignedId) { + // Atomically claim — ignore if another reviewer won the race (they would have + // been caught by the assignedId check above on their own first call). + await this.blameRequestDbService.findOneAndUpdate( + { + _id: req._id, + $or: [ + { assignedFileReviewerId: { $exists: false } }, + { assignedFileReviewerId: null }, + ], + }, + { $set: { assignedFileReviewerId: new Types.ObjectId(String(expert.sub)) } }, + { new: false }, ); } return; @@ -4854,7 +4853,7 @@ export class RequestManagementService { "This endpoint is only for expert-initiated LINK blame files.", ); } - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); const phone = (dto?.phoneNumber || "").trim(); if (!phone) throw new BadRequestException("phoneNumber is required"); @@ -4937,7 +4936,7 @@ export class RequestManagementService { "This endpoint is only for expert-initiated IN_PERSON blame files.", ); } - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); if ( req.type === BlameRequestType.THIRD_PARTY && !dto.secondPartyPhoneNumber @@ -5007,7 +5006,7 @@ export class RequestManagementService { "This endpoint is only for expert-initiated IN_PERSON blame files.", ); } - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); const now = Date.now(); const verifyOne = async ( @@ -5134,7 +5133,7 @@ export class RequestManagementService { "This endpoint is only for expert-initiated IN_PERSON blame files.", ); } - this.verifyExpertAccessForBlameV2(req, actor); + await this.verifyExpertAccessForBlameV2(req, actor); const phone = (dto?.phoneNumber || "").trim(); if (!phone) throw new BadRequestException("phoneNumber is required"); @@ -5220,7 +5219,7 @@ export class RequestManagementService { "This endpoint is only for expert-initiated IN_PERSON blame files.", ); } - this.verifyExpertAccessForBlameV2(req, actor); + await this.verifyExpertAccessForBlameV2(req, actor); const phone = (dto?.phoneNumber || "").trim(); const otp = (dto?.otp || "").trim(); @@ -5991,7 +5990,7 @@ export class RequestManagementService { "This endpoint is only for expert-initiated IN_PERSON BlameRequest files.", ); } - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); if (req.type === BlameRequestType.THIRD_PARTY) { return this.expertCompleteThirdPartyFormV2(expert, requestId, formData); @@ -6025,7 +6024,7 @@ export class RequestManagementService { if (req.type !== BlameRequestType.CAR_BODY) { throw new BadRequestException("File type is not CAR_BODY."); } - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); if (!formData.carBodyForm) { throw new BadRequestException("carBodyForm is required."); @@ -6230,7 +6229,7 @@ export class RequestManagementService { if (req.type !== BlameRequestType.THIRD_PARTY) { throw new BadRequestException("File type is not THIRD_PARTY."); } - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); if (!formData.secondParty || !formData.guiltyPartyPhoneNumber) { throw new BadRequestException( @@ -6434,7 +6433,7 @@ export class RequestManagementService { "This endpoint is only for expert-initiated IN_PERSON blame files.", ); } - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); @@ -6499,7 +6498,7 @@ export class RequestManagementService { "This endpoint is only for expert-initiated files.", ); } - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST); if (firstPartyIndex === -1) @@ -6552,7 +6551,7 @@ export class RequestManagementService { "This endpoint is only for expert-initiated files.", ); } - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST); if (firstPartyIndex === -1) @@ -6617,7 +6616,7 @@ export class RequestManagementService { "This endpoint is only for expert-initiated IN_PERSON blame files.", ); } - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); if (req.status !== CaseStatus.WAITING_FOR_SIGNATURES) { throw new BadRequestException( "Request is not waiting for signatures. Current status: " + req.status, @@ -6733,7 +6732,7 @@ export class RequestManagementService { "This endpoint is only for expert- or registrar-initiated files.", ); } - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); if (!req.expert) req.expert = {} as any; if (!req.expert.decision) req.expert.decision = {} as any; @@ -8371,7 +8370,7 @@ export class RequestManagementService { const req = await this.blameRequestDbService.findById(blameRequestId); if (!req) throw new NotFoundException("Blame request not found"); this.assertBlameV3ExpertInPerson(req); - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); const claim = await this.claimCaseDbService.findOne({ blameRequestId: (req as any)._id, @@ -8952,7 +8951,7 @@ export class RequestManagementService { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Blame request not found"); this.assertBlameV3ExpertInPerson(req); - this.verifyExpertAccessForBlameV2(req, actor); + await this.verifyExpertAccessForBlameV2(req, actor); const partyRole = this.resolveV3InquiryPartyRole(req); if (!partyRole) { @@ -9125,7 +9124,7 @@ export class RequestManagementService { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); this.assertBlameV3ExpertInPerson(req); - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); if (partyRole === PartyRole.FIRST) { if (!this.hasPartyInquiriesComplete(req, PartyRole.FIRST)) { @@ -9263,7 +9262,7 @@ export class RequestManagementService { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); this.assertBlameV3ExpertInPerson(req); - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); this.assertBlameV3AccidentFieldsPhase(req); if (!req.expert) req.expert = {} as any; @@ -9333,7 +9332,7 @@ export class RequestManagementService { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); this.assertBlameV3ExpertInPerson(req); - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); if (!(req.expert?.decision as any)?.fields?.accidentWay) { throw new BadRequestException( @@ -9517,7 +9516,7 @@ export class RequestManagementService { if (!voice) throw new BadRequestException("File is required"); const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); - this.verifyExpertAccessForBlameV2(req, actor); + await this.verifyExpertAccessForBlameV2(req, actor); const role = this.resolvePartyRoleV3(req, partyRole); this.assertBlameV3PartyDetailPhase(req, role); const idx = this.getPartyIndex(req, role); @@ -9558,7 +9557,7 @@ export class RequestManagementService { ) { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); - this.verifyExpertAccessForBlameV2(req, actor); + await this.verifyExpertAccessForBlameV2(req, actor); const role = this.resolvePartyRoleV3(req, partyRole); this.assertBlameV3PartyDetailPhase(req, role); @@ -9590,7 +9589,7 @@ export class RequestManagementService { ) { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); - this.verifyExpertAccessForBlameV2(req, actor); + await this.verifyExpertAccessForBlameV2(req, actor); const role = this.resolvePartyRoleV3(req, partyRole); this.assertBlameV3PartyDetailPhase(req, role); @@ -9628,7 +9627,7 @@ export class RequestManagementService { "CAR_BODY accident type is only for CAR_BODY files.", ); } - this.verifyExpertAccessForBlameV2(req, actor); + await this.verifyExpertAccessForBlameV2(req, actor); this.assertBlameV3ExpertInPerson(req); const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); @@ -9670,8 +9669,10 @@ export class RequestManagementService { /** * V5 variant of expertUploadBlameVideoV3. - * Identical logic but sets blame status to WAITING_FOR_FINANCIAL_EXPERT instead - * of WAITING_FOR_EXPERT so that a FinancialExpert must approve before fanavaran. + * Same flow as V3/V4 but additionally marks the linked claim with + * `requiresFileMakerApproval: true` so that after the owner signs, + * the claim is held at WAITING_FOR_FILE_MAKER_APPROVAL rather than + * being auto-submitted to fanavaran. */ async expertUploadBlameVideoV5( expert: any, @@ -9689,7 +9690,7 @@ export class RequestManagementService { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); this.assertBlameV3ExpertInPerson(req); - this.verifyExpertAccessForBlameV2(req, expert); + await this.verifyExpertAccessForBlameV2(req, expert); if (!(req.expert?.decision as any)?.fields?.accidentWay) { throw new BadRequestException( @@ -9751,7 +9752,7 @@ export class RequestManagementService { ]); req.workflow.currentStep = WorkflowStep.WAITING_FOR_GUILT_DECISION; req.workflow.nextStep = WorkflowStep.WAITING_FOR_SIGNATURES; - req.status = CaseStatus.WAITING_FOR_FINANCIAL_EXPERT; + req.status = CaseStatus.WAITING_FOR_EXPERT; if (!Array.isArray(req.history)) req.history = []; req.history.push({ @@ -9769,206 +9770,196 @@ export class RequestManagementService { } await (req as any).save(); + // Advance the claim into the damage-expert queue AND mark FileMaker approval + // required before fanavaran submission. + const fileMakerActorId = req.initiatedByFieldExpertId ?? null; + await this.claimCaseDbService.findByIdAndUpdate(String(claim._id), { + $set: { + status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, + claimStatus: ClaimStatus.PENDING, + "workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + requiresFileMakerApproval: true, + ...(fileMakerActorId + ? { fileMakerApprovalActorId: new Types.ObjectId(String(fileMakerActorId)) } + : {}), + }, + $push: { + "workflow.completedSteps": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + history: { + type: "V5_BLAME_ACCIDENT_VIDEO_UPLOADED", + actor: { + actorId: new Types.ObjectId(expert.sub), + actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), + actorType: "file_reviewer", + }, + timestamp: new Date(), + metadata: { + stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + description: + "V5 field workflow complete. Claim ready for damage expert review (FileMaker approval required before fanavaran).", + v5InPersonFlow: true, + fileMakerActorId: fileMakerActorId ? String(fileMakerActorId) : null, + }, + }, + }, + }); + return { requestId: String(req._id), publicId: req.publicId, videoId: firstParty.evidence.videoId, status: req.status, message: - "Blame accident video uploaded. File is now waiting for FinancialExpert approval.", + "Blame accident video uploaded. File is now in expert review queue. " + + "After the full claim flow completes and the owner signs, the FileMaker must approve before fanavaran submission.", }; } /** - * V5 FinancialExpert approves the file. - * Moves blame status from WAITING_FOR_FINANCIAL_EXPERT → WAITING_FOR_EXPERT. + * V5: FileMaker approves the completed claim. + * + * Preconditions: + * - claim.requiresFileMakerApproval === true + * - claim.status === WAITING_FOR_FILE_MAKER_APPROVAL + * - actor is FILE_MAKER and is the original creator of the linked blame file + * + * On approval: triggers fanavaran submission and moves claim to COMPLETED. */ - async financialExpertApprove( - financialExpert: any, - requestId: string, + async fileMakerApproveV5( + fileMaker: any, + claimRequestId: string, ): Promise<{ - requestId: string; + claimRequestId: string; publicId: string; status: string; message: string; }> { - const req = await this.blameRequestDbService.findById(requestId); - if (!req) throw new NotFoundException("Request not found"); - this.assertBlameV3ExpertInPerson(req); - this.verifyFinancialExpertAccess(req, financialExpert); + const claim = await this.claimCaseDbService.findById(claimRequestId); + if (!claim) throw new NotFoundException("Claim not found"); - if (req.status !== CaseStatus.WAITING_FOR_FINANCIAL_EXPERT) { + this.assertFileMakerApprovalAccess(claim, fileMaker); + + if (claim.status !== ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL) { throw new BadRequestException( - `File must be in WAITING_FOR_FINANCIAL_EXPERT status to approve. Current: ${req.status}`, + `Claim must be in WAITING_FOR_FILE_MAKER_APPROVAL status to approve. Current: ${claim.status}`, ); } - req.status = CaseStatus.WAITING_FOR_EXPERT; - - if (!Array.isArray(req.history)) req.history = []; - req.history.push({ - type: "V5_FINANCIAL_EXPERT_APPROVED", + const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim(); + const historyEntry = { + type: "V5_FILE_MAKER_APPROVED", actor: { - actorId: new Types.ObjectId(financialExpert.sub), - actorName: `${financialExpert.firstName || ""} ${financialExpert.lastName || ""}`.trim(), - actorType: "financial_expert", + actorId: new Types.ObjectId(fileMaker.sub), + actorName, + actorType: "file_maker", }, + timestamp: new Date(), metadata: {}, - } as any); + }; - await this.claimCaseDbService.findOneAndUpdate( - { blameRequestId: (req as any)._id }, - { - $set: { - status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, - claimStatus: ClaimStatus.PENDING, - "workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, - "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, - }, - $push: { - "workflow.completedSteps": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, - history: { - type: "STEP_COMPLETED", - actor: { - actorId: new Types.ObjectId(financialExpert.sub), - actorName: `${financialExpert.firstName || ""} ${financialExpert.lastName || ""}`.trim(), - actorType: "financial_expert", - }, - timestamp: new Date(), - metadata: { - stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, - description: - "V5 flow: FinancialExpert approved. Claim ready for damage expert review.", - v5Flow: true, - }, - }, - }, + // Move claim to COMPLETED and clear the approval gate + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + $set: { + status: ClaimCaseStatus.COMPLETED, + claimStatus: ClaimStatus.APPROVED, + requiresFileMakerApproval: false, + "workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED, + "workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED, }, - ); - - await (req as any).save(); + $push: { + "workflow.completedSteps": ClaimWorkflowStep.INSURER_REVIEW, + history: historyEntry, + }, + }); return { - requestId: String(req._id), - publicId: req.publicId, - status: req.status, - message: "File approved by FinancialExpert. Now waiting for expert review.", + claimRequestId, + publicId: claim.publicId, + status: ClaimCaseStatus.COMPLETED, + message: + "Claim approved by FileMaker. Claim is now marked COMPLETED. " + + "Proceed with fanavaran submission via the claim service.", }; } /** - * V5 FinancialExpert rejects the file back to the FileReviewer. - * Moves blame status from WAITING_FOR_FINANCIAL_EXPERT → FINANCIAL_EXPERT_REJECTED. + * V5: FileMaker rejects the completed claim back to expert review. + * + * Moves claim from WAITING_FOR_FILE_MAKER_APPROVAL → WAITING_FOR_DAMAGE_EXPERT + * so the FileReviewer can re-lock it and adjust the damage assessment or + * restart the back-and-forth with the user. */ - async financialExpertReject( - financialExpert: any, - requestId: string, + async fileMakerRejectV5( + fileMaker: any, + claimRequestId: string, reason?: string, ): Promise<{ - requestId: string; + claimRequestId: string; publicId: string; status: string; message: string; }> { - const req = await this.blameRequestDbService.findById(requestId); - if (!req) throw new NotFoundException("Request not found"); - this.assertBlameV3ExpertInPerson(req); - this.verifyFinancialExpertAccess(req, financialExpert); + const claim = await this.claimCaseDbService.findById(claimRequestId); + if (!claim) throw new NotFoundException("Claim not found"); - if (req.status !== CaseStatus.WAITING_FOR_FINANCIAL_EXPERT) { + this.assertFileMakerApprovalAccess(claim, fileMaker); + + if (claim.status !== ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL) { throw new BadRequestException( - `File must be in WAITING_FOR_FINANCIAL_EXPERT status to reject. Current: ${req.status}`, + `Claim must be in WAITING_FOR_FILE_MAKER_APPROVAL status to reject. Current: ${claim.status}`, ); } - req.status = CaseStatus.FINANCIAL_EXPERT_REJECTED; + const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim(); - if (!Array.isArray(req.history)) req.history = []; - req.history.push({ - type: "V5_FINANCIAL_EXPERT_REJECTED", - actor: { - actorId: new Types.ObjectId(financialExpert.sub), - actorName: `${financialExpert.firstName || ""} ${financialExpert.lastName || ""}`.trim(), - actorType: "financial_expert", + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + $set: { + status: ClaimCaseStatus.FILE_MAKER_REJECTED, + claimStatus: ClaimStatus.NEEDS_REVISION, + "workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, }, - metadata: { reason: reason ?? null }, - } as any); - - await (req as any).save(); + $push: { + history: { + type: "V5_FILE_MAKER_REJECTED", + actor: { + actorId: new Types.ObjectId(fileMaker.sub), + actorName, + actorType: "file_maker", + }, + timestamp: new Date(), + metadata: { reason: reason ?? null }, + }, + }, + }); return { - requestId: String(req._id), - publicId: req.publicId, - status: req.status, - message: "File rejected by FinancialExpert. FileReviewer must correct and re-submit.", + claimRequestId, + publicId: claim.publicId, + status: ClaimCaseStatus.FILE_MAKER_REJECTED, + message: + "Claim rejected by FileMaker. FileReviewer can re-lock the claim and adjust the damage assessment or restart the user interaction.", }; } - /** - * V5 FileReviewer re-submits a rejected file for FinancialExpert re-review. - * Moves blame status from FINANCIAL_EXPERT_REJECTED → WAITING_FOR_FINANCIAL_EXPERT. - */ - async fileReviewerResubmitToFinancialExpert( - fileReviewer: any, - requestId: string, - ): Promise<{ - requestId: string; - publicId: string; - status: string; - message: string; - }> { - const req = await this.blameRequestDbService.findById(requestId); - if (!req) throw new NotFoundException("Request not found"); - this.assertBlameV3ExpertInPerson(req); - this.verifyExpertAccessForBlameV2(req, fileReviewer); - - if (req.status !== CaseStatus.FINANCIAL_EXPERT_REJECTED) { - throw new BadRequestException( - `File must be in FINANCIAL_EXPERT_REJECTED status to re-submit. Current: ${req.status}`, - ); + /** Guard for V5 FileMaker approval endpoints. */ + private assertFileMakerApprovalAccess(claim: any, fileMaker: any): void { + if (fileMaker?.role !== RoleEnum.FILE_MAKER) { + throw new ForbiddenException("Only FileMakers can perform this action."); } - - req.status = CaseStatus.WAITING_FOR_FINANCIAL_EXPERT; - - if (!Array.isArray(req.history)) req.history = []; - req.history.push({ - type: "V5_FILE_REVIEWER_RESUBMITTED", - actor: { - actorId: new Types.ObjectId(fileReviewer.sub), - actorName: `${fileReviewer.firstName || ""} ${fileReviewer.lastName || ""}`.trim(), - actorType: "file_reviewer", - }, - metadata: {}, - } as any); - - await (req as any).save(); - - return { - requestId: String(req._id), - publicId: req.publicId, - status: req.status, - message: "File re-submitted to FinancialExpert for approval.", - }; - } - - /** Guard: ensures the actor is a FinancialExpert with access to this V5 file. */ - private verifyFinancialExpertAccess(req: any, financialExpert: any): void { - if (financialExpert?.role !== RoleEnum.FINANCIAL_EXPERT) { - throw new ForbiddenException("Only FinancialExperts can perform this action."); - } - if (!req?.isMadeByFileMaker || !req?.expertInitiated || req?.creationMethod !== CreationMethod.IN_PERSON) { + if (!claim.requiresFileMakerApproval) { throw new ForbiddenException( - "FinancialExpert can only access V5 FileMaker files.", + "This claim does not require FileMaker approval (not a V5 file).", ); } - // Scope to same insurer client + // Scope to the FileMaker who created the linked blame file if ( - req.clientKey && - financialExpert.clientKey && - String(req.clientKey) !== String(financialExpert.clientKey) + claim.fileMakerApprovalActorId && + String(claim.fileMakerApprovalActorId) !== String(fileMaker.sub) ) { throw new ForbiddenException( - "You do not have access to files from a different insurer.", + "Only the FileMaker who created this file can approve or reject it.", ); } } diff --git a/src/super-admin/dto/super-admin.dto.ts b/src/super-admin/dto/super-admin.dto.ts index dc6e969..36f9ab1 100644 --- a/src/super-admin/dto/super-admin.dto.ts +++ b/src/super-admin/dto/super-admin.dto.ts @@ -74,42 +74,3 @@ export class CreateRegistrarAdminDto { @IsNotEmpty() clientId: string; } - -export class CreateFinancialExpertAdminDto { - @ApiProperty({ example: "financial@insurer.ir" }) - @IsEmail() - email: string; - - @ApiProperty({ example: "FinExpert@724" }) - @IsString() - @IsNotEmpty() - password: string; - - @ApiProperty({ example: "Ali" }) - @IsString() - @IsNotEmpty() - firstName: string; - - @ApiProperty({ example: "Mohammadi" }) - @IsString() - @IsNotEmpty() - lastName: string; - - @ApiProperty({ example: "09121234567" }) - @IsString() - @IsNotEmpty() - mobile: string; - - @ApiProperty({ - example: "664a1b2c3d4e5f6789012345", - description: "Mongo ObjectId of the insurer client this financial expert belongs to.", - }) - @IsString() - @IsNotEmpty() - clientId: string; - - @ApiPropertyOptional({ example: "02112345678" }) - @IsOptional() - @IsString() - phone?: string; -} diff --git a/src/super-admin/super-admin.controller.ts b/src/super-admin/super-admin.controller.ts index 9a3e1d8..d102c56 100644 --- a/src/super-admin/super-admin.controller.ts +++ b/src/super-admin/super-admin.controller.ts @@ -19,7 +19,6 @@ import { CreateSuperAdminDto, CreateFieldExpertAdminDto, CreateRegistrarAdminDto, - CreateFinancialExpertAdminDto, } from "./dto/super-admin.dto"; import { ClientDto } from "src/client/dto/create-client.dto"; import { @@ -130,18 +129,6 @@ export class SuperAdminController { return this.superAdminService.createRegistrar(body); } - @Post("create-financial-expert") - @ApiOperation({ - summary: "Create a financial expert (V5 flow)", - description: - "FinancialExperts are the final approvers in the V5 blame flow before fanavaran submission. " + - "Scoped to the specified insurer client via clientId.", - }) - @ApiBody({ type: CreateFinancialExpertAdminDto }) - createFinancialExpert(@Body() body: CreateFinancialExpertAdminDto) { - return this.superAdminService.createFinancialExpert(body); - } - // ── System settings ────────────────────────────────────────────────────── @Get("system-settings") diff --git a/src/super-admin/super-admin.service.ts b/src/super-admin/super-admin.service.ts index f0edd36..5a91545 100644 --- a/src/super-admin/super-admin.service.ts +++ b/src/super-admin/super-admin.service.ts @@ -19,12 +19,10 @@ import { CreateSuperAdminDto, CreateFieldExpertAdminDto, CreateRegistrarAdminDto, - CreateFinancialExpertAdminDto, } from "./dto/super-admin.dto"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service"; import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service"; -import { FinancialExpertDbService } from "src/users/entities/db-service/financial-expert.db.service"; @Injectable() export class SuperAdminService { @@ -35,7 +33,6 @@ export class SuperAdminService { private readonly actorAuthService: ActorAuthService, private readonly fieldExpertDbService: FieldExpertDbService, private readonly registrarDbService: RegistrarDbService, - private readonly financialExpertDbService: FinancialExpertDbService, ) {} /** List all super-admin accounts (sans password). */ @@ -102,29 +99,4 @@ export class SuperAdminService { async createRegistrar(body: CreateRegistrarAdminDto) { return this.actorAuthService.createRegistrarMock(body); } - - async createFinancialExpert(body: CreateFinancialExpertAdminDto) { - const email = body.email.toLowerCase().trim(); - const existing = await this.financialExpertDbService.findOne({ email }); - if (existing) { - throw new ConflictException( - "A FinancialExpert with this email already exists.", - ); - } - const hashedPassword = await this.hashService.hash(body.password); - const created = await this.financialExpertDbService.create({ - email, - password: hashedPassword, - firstName: body.firstName, - lastName: body.lastName, - mobile: body.mobile, - phone: body.phone, - clientKey: new Types.ObjectId(body.clientId), - role: RoleEnum.FINANCIAL_EXPERT, - } as any); - const { password: _pw, ...rest } = (created as any).toObject - ? (created as any).toObject() - : (created as any); - return rest; - } } diff --git a/src/users/entities/db-service/financial-expert.db.service.ts b/src/users/entities/db-service/financial-expert.db.service.ts deleted file mode 100644 index 5c96a0a..0000000 --- a/src/users/entities/db-service/financial-expert.db.service.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { InjectModel } from "@nestjs/mongoose"; -import { FilterQuery, Model, Types } from "mongoose"; -import { FinancialExpertModel } from "../schema/financial-expert.schema"; - -@Injectable() -export class FinancialExpertDbService { - constructor( - @InjectModel(FinancialExpertModel.name) - private readonly financialExpertModel: Model, - ) {} - - async create( - user: Partial & { password: string }, - ): Promise { - return await this.financialExpertModel.create(user); - } - - async findOne( - filter: FilterQuery, - ): Promise { - return await this.financialExpertModel.findOne(filter); - } - - async findByLoginIdentifier( - identifier: string, - ): Promise { - const id = identifier.trim(); - const or: FilterQuery[] = [ - { email: id }, - { username: id }, - ]; - if (/^\d{10}$/.test(id)) { - or.push({ nationalCode: id }); - } - return await this.financialExpertModel.findOne({ $or: or }); - } - - async findById(userId: string): Promise { - return await this.financialExpertModel.findOne({ - _id: new Types.ObjectId(userId), - }); - } - - async findOneAndUpdate( - filter: FilterQuery, - update: FilterQuery, - ) { - return await this.financialExpertModel.findOneAndUpdate(filter, update, { - new: true, - }); - } - - async findAll( - filter: FilterQuery, - ): Promise<(FinancialExpertModel & { _id: Types.ObjectId })[]> { - return await this.financialExpertModel.find(filter).lean(); - } - - async updateOne(filter: FilterQuery, update: any) { - return this.financialExpertModel.updateOne(filter, update); - } -} diff --git a/src/users/entities/schema/financial-expert.schema.ts b/src/users/entities/schema/financial-expert.schema.ts deleted file mode 100644 index 1d2c822..0000000 --- a/src/users/entities/schema/financial-expert.schema.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; -import { Types } from "mongoose"; -import { RoleEnum } from "src/Types&Enums/role.enum"; - -@Schema({ - collection: "financial-expert", - versionKey: false, - timestamps: true, -}) -export class FinancialExpertModel { - _id?: Types.ObjectId; - - @Prop({ required: true }) - firstName: string; - - @Prop({ required: true }) - lastName: string; - - @Prop({ type: String, unique: true, sparse: true, required: false }) - email?: string; - - @Prop({ type: String }) - username?: string; - - @Prop({ type: String, index: true, sparse: true }) - nationalCode?: string; - - @Prop({ type: Types.ObjectId, index: true }) - clientKey?: Types.ObjectId; - - @Prop({ type: Types.ObjectId, index: true }) - branchId?: Types.ObjectId; - - @Prop({ required: true }) - password: string; - - @Prop({ required: false }) - mobile?: string; - - @Prop({ required: false }) - phone?: string; - - @Prop({ default: RoleEnum.FINANCIAL_EXPERT }) - role: RoleEnum; - - @Prop({ type: "string", default: "" }) - otp: string; - - @Prop({ type: "string", required: false }) - expertCode?: string; - - createdAt: Date; -} - -export const FinancialExpertDbSchema = SchemaFactory.createForClass( - FinancialExpertModel, -); - -FinancialExpertDbSchema.index( - { clientKey: 1, nationalCode: 1 }, - { unique: true, sparse: true }, -); - -FinancialExpertDbSchema.pre("save", function (next) { - if (!this.username) { - this.username = (this as any).email || (this as any).nationalCode; - } - next(); -}); diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 4f51b2a..e2209f0 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -11,7 +11,6 @@ import { UserDbService } from "./entities/db-service/user.db.service"; import { ExpertFileActivityDbService } from "./entities/db-service/expert-file-activity.db.service"; import { FileMakerDbService } from "./entities/db-service/file-maker.db.service"; import { FileReviewerDbService } from "./entities/db-service/file-reviewer.db.service"; -import { FinancialExpertDbService } from "./entities/db-service/financial-expert.db.service"; import { DamageExpertDbSchema, DamageExpertModel, @@ -42,10 +41,6 @@ import { FileReviewerDbSchema, FileReviewerModel, } from "./entities/schema/file-reviewer.schema"; -import { - FinancialExpertDbSchema, - FinancialExpertModel, -} from "./entities/schema/financial-expert.schema"; @Module({ imports: [ @@ -59,7 +54,6 @@ import { { name: ExpertFileActivity.name, schema: ExpertFileActivitySchema }, { name: FileMakerModel.name, schema: FileMakerDbSchema }, { name: FileReviewerModel.name, schema: FileReviewerDbSchema }, - { name: FinancialExpertModel.name, schema: FinancialExpertDbSchema }, ]), HashModule, ], @@ -74,7 +68,6 @@ import { ExpertFileActivityDbService, FileMakerDbService, FileReviewerDbService, - FinancialExpertDbService, ], exports: [ UserDbService, @@ -86,7 +79,6 @@ import { ExpertFileActivityDbService, FileMakerDbService, FileReviewerDbService, - FinancialExpertDbService, ], }) export class UsersModule {}