diff --git a/src/Types&Enums/blame-request-management/caseStatus.enum.ts b/src/Types&Enums/blame-request-management/caseStatus.enum.ts index 8ad2c3b..5e9a849 100644 --- a/src/Types&Enums/blame-request-management/caseStatus.enum.ts +++ b/src/Types&Enums/blame-request-management/caseStatus.enum.ts @@ -17,6 +17,19 @@ 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). + */ + WAITING_FOR_FINANCIAL_EXPERT = "WAITING_FOR_FINANCIAL_EXPERT", + + /** + * V5 flow only. FinancialExpert rejected the file back to FileReviewer + * for correction (adjust data or send back to user and re-submit). + */ + FINANCIAL_EXPERT_REJECTED = "FINANCIAL_EXPERT_REJECTED", + COMPLETED = "COMPLETED", CANCELLED = "CANCELLED", diff --git a/src/Types&Enums/role.enum.ts b/src/Types&Enums/role.enum.ts index c71b816..394eb16 100644 --- a/src/Types&Enums/role.enum.ts +++ b/src/Types&Enums/role.enum.ts @@ -9,4 +9,5 @@ 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 3d87a8c..d6fd56f 100644 --- a/src/auth/auth-services/actor.auth.service.ts +++ b/src/auth/auth-services/actor.auth.service.ts @@ -29,6 +29,7 @@ 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"; @@ -57,6 +58,7 @@ export class ActorAuthService { private readonly captchaChallengeService: CaptchaChallengeService, private readonly fileMakerDbService: FileMakerDbService, private readonly fileReviewerDbService: FileReviewerDbService, + private readonly financialExpertDbService: FinancialExpertDbService, private readonly superAdminDbService: SuperAdminDbService, ) {} @@ -116,6 +118,15 @@ 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/expert-insurer/dto/create-insurer-expert.dto.ts b/src/expert-insurer/dto/create-insurer-expert.dto.ts index 05e668b..2858cc1 100644 --- a/src/expert-insurer/dto/create-insurer-expert.dto.ts +++ b/src/expert-insurer/dto/create-insurer-expert.dto.ts @@ -97,3 +97,11 @@ 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 9d11423..e17405c 100644 --- a/src/expert-insurer/expert-insurer.controller.ts +++ b/src/expert-insurer/expert-insurer.controller.ts @@ -38,6 +38,7 @@ import { CreateClaimExpertByInsurerDto, CreateFileMakerByInsurerDto, CreateFileReviewerByInsurerDto, + CreateFinancialExpertByInsurerDto, } from "./dto/create-insurer-expert.dto"; @Controller("expert-insurer") @@ -182,6 +183,23 @@ 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 0bf526d..cf8a7fe 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -14,6 +14,7 @@ import { CreateClaimExpertByInsurerDto, CreateFileMakerByInsurerDto, CreateFileReviewerByInsurerDto, + CreateFinancialExpertByInsurerDto, CreateInsurerExpertDto, } from "./dto/create-insurer-expert.dto"; import { @@ -29,6 +30,7 @@ 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"; @@ -95,6 +97,7 @@ export class ExpertInsurerService { private readonly claimSignDbService: ClaimSignDbService, private readonly fileMakerDbService: FileMakerDbService, private readonly fileReviewerDbService: FileReviewerDbService, + private readonly financialExpertDbService: FinancialExpertDbService, ) {} /** @@ -1766,6 +1769,58 @@ 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-blame-v5.controller.ts b/src/request-management/file-maker-blame-v5.controller.ts new file mode 100644 index 0000000..5d1c782 --- /dev/null +++ b/src/request-management/file-maker-blame-v5.controller.ts @@ -0,0 +1,390 @@ +import { extname } from "node:path"; +import { + Body, + Controller, + Get, + Param, + Post, + Put, + UploadedFile, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiParam, + ApiTags, +} from "@nestjs/swagger"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { diskStorage } from "multer"; +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 { MediaPolicyService } from "src/media-policy/media-policy.service"; +import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { CreationMethod } from "./entities/schema/request-management.schema"; +import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; +import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto"; +import { RunInquiriesV3Dto } from "./dto/run-inquiries-v3.dto"; +import { + CarBodyFormDto, + DescriptionDto, + LocationDto, +} from "./dto/create-request-management.dto"; +import { RequestManagementService } from "./request-management.service"; +import { PartyRole } from "./entities/schema/partyRole.enum"; +import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service"; +import { + UploadRequiredDocumentV2Dto, + UploadRequiredDocumentV2ResponseDto, +} from "src/claim-request-management/dto/upload-document-v2.dto"; +import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto"; + +/** + * V5 FileMaker flow — identical to V4 but under the /v5/ prefix. + * + * The only behavioural difference in the V5 flow is in the FileReviewer's final + * step: instead of moving directly to WAITING_FOR_EXPERT, the blame video moves + * the file to WAITING_FOR_FINANCIAL_EXPERT so a FinancialExpert can approve or + * reject before fanavaran submission. + * + * The FileMaker side is unchanged; all sequence and endpoints are the same. + * + * THIRD_PARTY sequence: + * create → send-party-otp (guilty) → verify-party-otp (guilty) + * → [car-body-form if CAR_BODY] → run-inquiries (guilty + auto claim) + * → add-detail-location / add-detail-description / upload-voice (guilty) + * → sign (guilty) + * → send-party-otp (damaged) → verify-party-otp (damaged) + * → run-inquiries (damaged) + * → add-detail-location / add-detail-description / upload-voice (damaged) + * → sign (damaged) ← FileMaker job done here + */ +@ApiTags("v5 FileMaker — blame file creation (with FinancialExpert approval)") +@Controller("v5/file-maker/blame-request-management") +@ApiBearerAuth() +@UseGuards(LocalActorAuthGuard, RolesGuard) +@Roles(RoleEnum.FILE_MAKER) +export class FileMakerBlameV5Controller { + constructor( + private readonly requestManagementService: RequestManagementService, + private readonly mediaPolicyService: MediaPolicyService, + private readonly claimRequestManagementService: ClaimRequestManagementService, + ) {} + + // ─── File creation ─────────────────────────────────────────────────────────── + + @Post() + @ApiOperation({ summary: "Create IN_PERSON blame file (FileMaker V5)" }) + @ApiBody({ type: CreateExpertInitiatedFileDto }) + async create( + @CurrentUser() fileMaker: any, + @Body() dto: CreateExpertInitiatedFileDto, + ) { + return this.requestManagementService.createExpertInitiatedBlameV2( + fileMaker, + { ...dto, creationMethod: CreationMethod.IN_PERSON }, + ); + } + + @Get("claim-id/:requestId") + @ApiParam({ name: "requestId", description: "Blame request ID" }) + @ApiOperation({ + summary: "Get linked claim ID", + description: + "Returns the claim auto-created during guilty-party run-inquiries. " + + "Share this claim ID with the FileReviewer.", + }) + async getLinkedClaimId( + @Param("requestId") requestId: string, + @CurrentUser() fileMaker: any, + ) { + return this.requestManagementService.getV3LinkedClaimForExpert( + fileMaker, + requestId, + ); + } + + // ─── OTP ───────────────────────────────────────────────────────────────────── + + @Post("send-party-otp/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: SendPartyOtpDto }) + @ApiOperation({ + summary: "Send OTP to one party", + description: + "Guilty party first; damaged party after guilty party has signed (THIRD_PARTY).", + }) + async sendPartyOtp( + @CurrentUser() fileMaker: any, + @Param("requestId") requestId: string, + @Body() dto: SendPartyOtpDto, + ) { + return this.requestManagementService.sendPartyOtpV2( + fileMaker, + requestId, + dto, + ); + } + + @Post("verify-party-otp/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: VerifyPartyOtpDto }) + @ApiOperation({ + summary: "Verify one party OTP", + description: "FIRST (guilty) then SECOND (damaged) on THIRD_PARTY files.", + }) + async verifyPartyOtp( + @CurrentUser() fileMaker: any, + @Param("requestId") requestId: string, + @Body() dto: VerifyPartyOtpDto, + ) { + return this.requestManagementService.verifyPartyOtpV2( + fileMaker, + requestId, + dto, + ); + } + + // ─── Pre-inquiry form ──────────────────────────────────────────────────────── + + @Post("car-body-form/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: CarBodyFormDto }) + @ApiOperation({ + summary: "CAR_BODY only — accident type before inquiries", + description: "Submit after guilty-party OTP verify, before run-inquiries.", + }) + async carBodyForm( + @Param("requestId") requestId: string, + @Body() body: CarBodyFormDto, + @CurrentUser() fileMaker: any, + ) { + return this.requestManagementService.carBodyAccidentTypeFormV3( + requestId, + body, + fileMaker, + ); + } + + @Post("run-inquiries/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: RunInquiriesV3Dto }) + @ApiOperation({ + summary: "Run external inquiries for the current party", + description: + "1st call = guilty party (+ auto claim). 2nd call = damaged party (THIRD_PARTY, after guilty sign).", + }) + async runInquiries( + @CurrentUser() fileMaker: any, + @Param("requestId") requestId: string, + @Body() dto: RunInquiriesV3Dto, + ) { + return this.requestManagementService.runInquiriesV3( + requestId, + fileMaker, + dto, + ); + } + + // ─── Party details ──────────────────────────────────────────────────────────── + + @Post("add-detail-location/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: LocationDto }) + @ApiOperation({ + summary: "Add location for current party (partyRole FIRST or SECOND)", + }) + async addLocation( + @Param("requestId") requestId: string, + @Body() body: LocationDto, + @CurrentUser() fileMaker: any, + @Body("partyRole") partyRole?: string, + ) { + return this.requestManagementService.addPartyLocationV3( + requestId, + fileMaker, + body, + partyRole, + ); + } + + @Post("add-detail-description/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: DescriptionDto }) + @ApiOperation({ summary: "Add description for current party" }) + async addDescription( + @Param("requestId") requestId: string, + @Body() body: DescriptionDto, + @CurrentUser() fileMaker: any, + @Body("partyRole") partyRole?: string, + ) { + return this.requestManagementService.addPartyDescriptionV3( + requestId, + fileMaker, + body, + partyRole, + ); + } + + @ApiBody({ + schema: { + type: "object", + properties: { + file: { type: "string", format: "binary" }, + partyRole: { type: "string", enum: ["FIRST", "SECOND"] }, + }, + required: ["file"], + }, + }) + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/voice", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + callback(null, `v5-filemaker-voice-${unique}${ex}`); + }, + }), + }), + ) + @Post("upload-voice/:requestId") + @ApiParam({ name: "requestId" }) + @ApiOperation({ summary: "Upload voice for current party" }) + async uploadVoice( + @Param("requestId") requestId: string, + @CurrentUser() fileMaker: any, + @UploadedFile() voice: Express.Multer.File, + @Body("partyRole") partyRole?: string, + ) { + await this.mediaPolicyService.assertForBlame(voice, requestId, "voice"); + return this.requestManagementService.addPartyVoiceV3( + requestId, + fileMaker, + voice, + partyRole, + ); + } + + // ─── Signatures (final FileMaker step) ─────────────────────────────────────── + + @Put("sign/:requestId") + @ApiParam({ name: "requestId" }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "Party on-site signature (FileMaker final step)", + description: + "Collect FIRST (guilty) then SECOND (damaged) signatures. " + + "After the last signature the file is ready for FileReviewer pickup.", + }) + @ApiBody({ + schema: { + type: "object", + required: ["partyRole", "sign"], + properties: { + partyRole: { type: "string", enum: ["FIRST", "SECOND"] }, + isAccept: { type: "boolean", default: true }, + sign: { type: "string", format: "binary" }, + }, + }, + }) + @UseInterceptors( + FileInterceptor("sign", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/signs", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + callback(null, `v5-filemaker-party-${unique}${ex}`); + }, + }), + }), + ) + async sign( + @Param("requestId") requestId: string, + @Body() body: { partyRole?: string; isAccept?: string | boolean }, + @CurrentUser() fileMaker: any, + @UploadedFile() sign: Express.Multer.File, + ) { + await this.mediaPolicyService.assertForBlame(sign, requestId, "image"); + const partyRole = + body?.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST; + const isAccept = !(body?.isAccept === false || body?.isAccept === "false"); + return this.requestManagementService.expertUploadPartySignatureV3( + fileMaker, + requestId, + partyRole, + isAccept, + sign, + ); + } + + // ─── Claim document upload ──────────────────────────────────────────────────── + + @Post("upload-document/:claimRequestId") + @ApiParam({ name: "claimRequestId" }) + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/claim-documents", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + callback(null, `${file.originalname.split(".")[0]}-${unique}${ex}`); + }, + }), + }), + ) + @ApiOperation({ + summary: "Upload one required claim document (FileMaker)", + description: + "Upload licenses and car cards against the auto-created claim. " + + "Use claim-id/:requestId to obtain the claimRequestId after run-inquiries.", + }) + async uploadDocument( + @Param("claimRequestId") claimRequestId: string, + @Body() body: UploadRequiredDocumentV2Dto, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() fileMaker: any, + ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); + return this.claimRequestManagementService.uploadRequiredDocumentV3( + claimRequestId, + body, + file, + fileMaker.sub, + fileMaker, + ); + } + + @Get("capture-requirements/:claimRequestId") + @ApiParam({ name: "claimRequestId" }) + @ApiOperation({ + summary: "Capture requirements (step-aware)", + description: + "Initial documents phase: pre-capture docs only (no chassis/engine/metal plate). " + + "CAPTURE_PART_DAMAGES phase: damaged parts + angles via capture-part, then chassis/engine/metal plate via upload-document. " + + "Use `captureSequencePhase` and `captureSequenceHint` to drive the UI.", + }) + async getCaptureRequirements( + @Param("claimRequestId") claimRequestId: string, + @CurrentUser() fileMaker: any, + ): Promise { + return this.claimRequestManagementService.getCaptureRequirementsV3( + claimRequestId, + fileMaker.sub, + fileMaker, + ); + } +} diff --git a/src/request-management/file-reviewer-blame-v5.controller.ts b/src/request-management/file-reviewer-blame-v5.controller.ts new file mode 100644 index 0000000..16db087 --- /dev/null +++ b/src/request-management/file-reviewer-blame-v5.controller.ts @@ -0,0 +1,426 @@ +import { extname } from "node:path"; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiParam, + ApiTags, +} from "@nestjs/swagger"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { diskStorage } from "multer"; +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 { MediaPolicyService } from "src/media-policy/media-policy.service"; +import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto"; +import { RequestManagementService } from "./request-management.service"; +import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service"; +import { + SelectOuterPartsV2Dto, + SelectOuterPartsV2ResponseDto, +} from "src/claim-request-management/dto/select-outer-parts-v2.dto"; +import { SelectOtherPartsV2ResponseDto } from "src/claim-request-management/dto/select-other-parts-v2.dto"; +import { SelectOtherPartsV3Dto } from "src/claim-request-management/dto/select-other-parts-v3.dto"; +import { + UploadRequiredDocumentV2Dto, + UploadRequiredDocumentV2ResponseDto, +} from "src/claim-request-management/dto/upload-document-v2.dto"; +import { + CapturePartV2Dto, + CapturePartV2ResponseDto, +} from "src/claim-request-management/dto/capture-part-v2.dto"; +import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto"; + +/** + * V5 FileReviewer flow — same as V4 except the final blame accident video moves + * the file to WAITING_FOR_FINANCIAL_EXPERT instead of WAITING_FOR_EXPERT. + * + * A FinancialExpert must then approve the file (→ WAITING_FOR_EXPERT) or + * reject it back (→ FINANCIAL_EXPERT_REJECTED) for the FileReviewer to correct. + * + * Sequence: + * accident-fields + * → capture-requirements (via linked claimRequestId) + * → upload-document (licenses, car cards) + * → select-outer-parts + * → select-other-parts + * → capture-part (damaged-part photos + four angles) + * → upload-document (chassis / engine / metal plate — capture phase) + * → car-capture (walk-around video) + * → upload-video (blame accident video — final, moves to WAITING_FOR_FINANCIAL_EXPERT) + */ +@ApiTags("v5 FileReviewer — blame file review & capture (with FinancialExpert approval)") +@Controller("v5/file-reviewer/blame-request-management") +@ApiBearerAuth() +@UseGuards(LocalActorAuthGuard, RolesGuard) +@Roles(RoleEnum.FILE_REVIEWER) +export class FileReviewerBlameV5Controller { + constructor( + private readonly requestManagementService: RequestManagementService, + private readonly claimRequestManagementService: ClaimRequestManagementService, + private readonly mediaPolicyService: MediaPolicyService, + ) {} + + // ─── Linked claim lookup ────────────────────────────────────────────────────── + + @Get("claim-id/:requestId") + @ApiParam({ name: "requestId", description: "Blame request ID" }) + @ApiOperation({ + summary: "Get linked claim ID", + description: + "Returns the claim auto-created during FileMaker's guilty-party run-inquiries. " + + "Use this claim ID for all capture/document/parts endpoints below.", + }) + async getLinkedClaimId( + @Param("requestId") requestId: string, + @CurrentUser() fileReviewer: any, + ) { + return this.requestManagementService.getV3LinkedClaimForExpert( + fileReviewer, + requestId, + ); + } + + // ─── Accident fields ────────────────────────────────────────────────────────── + + @Post("accident-fields/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: ExpertAccidentFieldsDto }) + @ApiOperation({ + summary: "Accident type / fields (FileReviewer first step)", + description: + "Saves accident fields on the sealed file. " + + "Does not move to WAITING_FOR_FINANCIAL_EXPERT — that happens after the final blame accident video.", + }) + async addAccidentFields( + @Param("requestId") requestId: string, + @Body() fields: ExpertAccidentFieldsDto, + @CurrentUser() fileReviewer: any, + ) { + return this.requestManagementService.expertAddAccidentFieldsForBlameV3( + fileReviewer, + requestId, + fields, + ); + } + + // ─── Capture requirements ───────────────────────────────────────────────────── + + @Get("capture-requirements/:claimRequestId") + @ApiParam({ name: "claimRequestId" }) + @ApiOperation({ + summary: "Capture requirements (step-aware)", + description: + "Initial documents phase: pre-capture docs only (no chassis/engine/metal plate). " + + "CAPTURE_PART_DAMAGES phase: damaged parts + angles via capture-part, then chassis/engine/metal plate via upload-document. " + + "Use `captureSequencePhase` and `captureSequenceHint` to drive the UI.", + }) + async getCaptureRequirements( + @Param("claimRequestId") claimRequestId: string, + @CurrentUser() fileReviewer: any, + ): Promise { + return this.claimRequestManagementService.getCaptureRequirementsV3( + claimRequestId, + fileReviewer.sub, + fileReviewer, + ); + } + + // ─── Document upload ────────────────────────────────────────────────────────── + + @Post("upload-document/:claimRequestId") + @ApiParam({ name: "claimRequestId" }) + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/claim-documents", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + callback(null, `${file.originalname.split(".")[0]}-${unique}${ex}`); + }, + }), + }), + ) + @ApiOperation({ + summary: "Upload one required claim document", + description: + "Initial phase (UPLOAD_REQUIRED_DOCUMENTS): licenses and car cards only. " + + "Capture phase (CAPTURE_PART_DAMAGES, after parts + angles): chassis, engine, metal plate only.", + }) + async uploadDocument( + @Param("claimRequestId") claimRequestId: string, + @Body() body: UploadRequiredDocumentV2Dto, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() fileReviewer: any, + ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); + return this.claimRequestManagementService.uploadRequiredDocumentV3( + claimRequestId, + body, + file, + fileReviewer.sub, + fileReviewer, + ); + } + + // ─── Part selection ─────────────────────────────────────────────────────────── + + @Patch("select-outer-parts/:claimRequestId") + @ApiParam({ name: "claimRequestId" }) + @ApiBody({ type: SelectOuterPartsV2Dto }) + @ApiOperation({ summary: "Select damaged outer parts" }) + async selectOuterParts( + @Param("claimRequestId") claimRequestId: string, + @Body() body: SelectOuterPartsV2Dto, + @CurrentUser() fileReviewer: any, + ): Promise { + return this.claimRequestManagementService.selectOuterPartsV3( + claimRequestId, + body, + fileReviewer.sub, + fileReviewer, + ); + } + + @Patch("select-other-parts/:claimRequestId") + @ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" }) + @ApiOperation({ + summary: "Select other damaged parts (V5)", + description: + "Other parts only — sheba and national code were collected during FileMaker's run-inquiries. Optional car green card file.", + }) + @ApiBody({ + description: + "Other parts selection. Bank info is already on the claim from run-inquiries.", + schema: { + type: "object", + properties: { + otherParts: { + oneOf: [ + { + type: "array", + items: { + type: "string", + enum: [ + "engine", + "suspension", + "brake_system", + "electrical", + "radiator", + "transmission", + "exhaust", + "headlight", + "taillight", + "mirror", + "glass", + ], + }, + }, + { type: "string", description: "JSON string array for multipart" }, + ], + example: ["engine", "suspension"], + }, + }, + }, + }) + async selectOtherParts( + @Param("claimRequestId") claimRequestId: string, + @Body() body: SelectOtherPartsV3Dto, + @CurrentUser() fileReviewer: any, + ): Promise { + return this.claimRequestManagementService.selectOtherPartsV3( + claimRequestId, + body, + fileReviewer.sub, + fileReviewer, + ); + } + + // ─── Photo capture ──────────────────────────────────────────────────────────── + + @Post("capture-part/:claimRequestId") + @ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" }) + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/claim-captures", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + callback(null, `v5-capture-${unique}${ex}`); + }, + }), + }), + ) + @ApiOperation({ + summary: "Car angles and damaged-part photos", + description: + "CAPTURE_PART_DAMAGES: (1) all damaged-part photos, (2) four car angles, " + + "(3) chassis/engine/metal-plate via upload-document. Then car-capture walk-around video.", + }) + @ApiBody({ + schema: { + type: "object", + required: ["captureType", "captureKey", "file"], + properties: { + captureType: { + type: "string", + enum: ["angle", "part"], + example: "angle", + }, + captureKey: { + type: "string", + example: "front", + description: + "For angle: front/back/left/right. For part: hood/front_bumper/etc.", + }, + file: { type: "string", format: "binary" }, + }, + }, + }) + async capturePart( + @Param("claimRequestId") claimRequestId: string, + @Body() body: CapturePartV2Dto, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() fileReviewer: any, + ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); + return this.claimRequestManagementService.capturePartV3( + claimRequestId, + body, + file, + fileReviewer.sub, + fileReviewer, + ); + } + + // ─── Walk-around video ──────────────────────────────────────────────────────── + + @Patch("car-capture/:claimRequestId") + @ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" }) + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/car-capture-videos/", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + callback(null, `v5-claim-video-${unique}${ex}`); + }, + }), + }), + ) + @ApiBody({ + schema: { + type: "object", + required: ["file"], + properties: { file: { type: "string", format: "binary" } }, + }, + }) + @ApiOperation({ + summary: "Walk-around video", + description: + "Upload after capture-part is complete (parts, angles, capture-phase docs). Required before the final blame accident video.", + }) + async carCapture( + @Param("claimRequestId") claimRequestId: string, + @UploadedFile("file") file: Express.Multer.File, + @CurrentUser() fileReviewer: any, + ) { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video"); + return this.claimRequestManagementService.setVideoCaptureV3( + claimRequestId, + file, + fileReviewer.sub, + fileReviewer, + ); + } + + // ─── Final blame video ───────────────────────────────────────────────────────── + // + // V5 difference: moves file to WAITING_FOR_FINANCIAL_EXPERT, not WAITING_FOR_EXPERT. + + @ApiBody({ + schema: { + type: "object", + required: ["file"], + properties: { file: { type: "string", format: "binary" } }, + }, + }) + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/video", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + callback(null, `v5-blame-accident-${unique}${ex}`); + }, + }), + }), + ) + @Post("upload-video/:requestId") + @ApiParam({ name: "requestId" }) + @ApiOperation({ + summary: "Blame accident video (FileReviewer final step — V5)", + description: + "Last step of the V5 FileReviewer flow. Requires claim walk-around video and completed capture. " + + "Sets blame status to WAITING_FOR_FINANCIAL_EXPERT (pending FinancialExpert approval).", + }) + async uploadBlameVideo( + @Param("requestId") requestId: string, + @CurrentUser() fileReviewer: any, + @UploadedFile() file: Express.Multer.File, + ) { + await this.mediaPolicyService.assertForBlame(file, requestId, "video"); + return this.requestManagementService.expertUploadBlameVideoV5( + fileReviewer, + requestId, + file, + ); + } + + // ─── 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 new file mode 100644 index 0000000..e73034c --- /dev/null +++ b/src/request-management/financial-expert-blame-v5.controller.ts @@ -0,0 +1,113 @@ +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 6de1fe3..2d85074 100644 --- a/src/request-management/request-management.module.ts +++ b/src/request-management/request-management.module.ts @@ -45,6 +45,9 @@ import { RegistrarBlameMirrorController } from "./registrar-blame.mirror.control import { ExpertInitiatedBlameV3MirrorController } from "./expert-initiated-blame-v3.mirror.controller"; 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 { InquiryRefreshController } from "./inquiry-refresh.controller"; import { InquiryRefreshService } from "./inquiry-refresh.service"; @@ -85,6 +88,9 @@ import { InquiryRefreshService } from "./inquiry-refresh.service"; RegistrarBlameMirrorController, FileMakerBlameV4Controller, FileReviewerBlameV4Controller, + FileMakerBlameV5Controller, + FileReviewerBlameV5Controller, + FinancialExpertBlameV5Controller, InquiryRefreshController, ], providers: [ diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 7f68917..5bd7ae7 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -4459,17 +4459,19 @@ 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 V4 FileMaker-sealed files. + // 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) { if (!req?.isMadeByFileMaker || !req?.expertInitiated || req?.creationMethod !== CreationMethod.IN_PERSON) { throw new ForbiddenException( - "FileReviewer can only access V4 FileMaker files.", + "FileReviewer can only access V4/V5 FileMaker files.", ); } 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( @@ -4487,6 +4489,25 @@ export class RequestManagementService { } 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.", + ); + } + return; + } if (req?.expertInitiated && req?.initiatedByFieldExpertId) { if (String(req.initiatedByFieldExpertId) !== String(expert?.sub)) { throw new ForbiddenException( @@ -9646,4 +9667,309 @@ export class RequestManagementService { await (req as any).save(); return { requestId: req._id, publicId: req.publicId }; } + + /** + * 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. + */ + async expertUploadBlameVideoV5( + expert: any, + requestId: string, + file: Express.Multer.File, + ): Promise<{ + requestId: string; + publicId: string; + videoId: string; + status: string; + message: string; + }> { + if (!file) throw new BadRequestException("Video file is required"); + + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + this.assertBlameV3ExpertInPerson(req); + this.verifyExpertAccessForBlameV2(req, expert); + + if (!(req.expert?.decision as any)?.fields?.accidentWay) { + throw new BadRequestException( + "Submit accident fields before uploading the blame accident video.", + ); + } + + const requiredSigs = req.type === BlameRequestType.CAR_BODY ? 1 : 2; + const signed = (req.parties ?? []).filter( + (p: any) => p?.confirmation != null, + ); + if (signed.length < requiredSigs) { + throw new BadRequestException( + "All required party signatures must be collected before the blame accident video.", + ); + } + + const claim = await this.claimCaseDbService.findOne({ + blameRequestId: (req as any)._id, + }); + if (!claim) { + throw new BadRequestException( + "Claim not found. Complete guilty-party inquiries first.", + ); + } + if (!claim.media?.videoCaptureId) { + throw new BadRequestException( + "Upload the claim walk-around video (car-capture) before the blame accident video.", + ); + } + if ( + claim.workflow?.currentStep !== + ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS + ) { + throw new BadRequestException( + "Complete capture-part (parts, angles, and capture-phase documents) before the blame accident video.", + ); + } + + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + if (firstIdx === -1) throw new BadRequestException("First party not found"); + const firstParty = req.parties[firstIdx]; + if (firstParty?.evidence?.videoId) { + throw new ConflictException("Blame accident video already uploaded"); + } + + const videoDocument = await this.blameVideoDbService.create({ + fileName: file.filename, + path: file.path, + requestId: new Types.ObjectId(requestId), + } as any); + + if (!firstParty.evidence) firstParty.evidence = {} as any; + firstParty.evidence.videoId = String((videoDocument as any)._id); + + this.pushWorkflowSteps(req, [ + WorkflowStep.FIRST_VIDEO, + WorkflowStep.WAITING_FOR_GUILT_DECISION, + ]); + req.workflow.currentStep = WorkflowStep.WAITING_FOR_GUILT_DECISION; + req.workflow.nextStep = WorkflowStep.WAITING_FOR_SIGNATURES; + req.status = CaseStatus.WAITING_FOR_FINANCIAL_EXPERT; + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "V5_BLAME_ACCIDENT_VIDEO_UPLOADED", + actor: { + actorId: new Types.ObjectId(expert.sub), + actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), + actorType: "file_reviewer", + }, + metadata: { videoId: firstParty.evidence.videoId }, + } as any); + + if (typeof (req as any).markModified === "function") { + (req as any).markModified("parties"); + } + await (req as any).save(); + + 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.", + }; + } + + /** + * V5 FinancialExpert approves the file. + * Moves blame status from WAITING_FOR_FINANCIAL_EXPERT → WAITING_FOR_EXPERT. + */ + async financialExpertApprove( + financialExpert: 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.verifyFinancialExpertAccess(req, financialExpert); + + if (req.status !== CaseStatus.WAITING_FOR_FINANCIAL_EXPERT) { + throw new BadRequestException( + `File must be in WAITING_FOR_FINANCIAL_EXPERT status to approve. Current: ${req.status}`, + ); + } + + req.status = CaseStatus.WAITING_FOR_EXPERT; + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "V5_FINANCIAL_EXPERT_APPROVED", + actor: { + actorId: new Types.ObjectId(financialExpert.sub), + actorName: `${financialExpert.firstName || ""} ${financialExpert.lastName || ""}`.trim(), + actorType: "financial_expert", + }, + 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, + }, + }, + }, + }, + ); + + await (req as any).save(); + + return { + requestId: String(req._id), + publicId: req.publicId, + status: req.status, + message: "File approved by FinancialExpert. Now waiting for expert review.", + }; + } + + /** + * V5 FinancialExpert rejects the file back to the FileReviewer. + * Moves blame status from WAITING_FOR_FINANCIAL_EXPERT → FINANCIAL_EXPERT_REJECTED. + */ + async financialExpertReject( + financialExpert: any, + requestId: string, + reason?: 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.verifyFinancialExpertAccess(req, financialExpert); + + if (req.status !== CaseStatus.WAITING_FOR_FINANCIAL_EXPERT) { + throw new BadRequestException( + `File must be in WAITING_FOR_FINANCIAL_EXPERT status to reject. Current: ${req.status}`, + ); + } + + req.status = CaseStatus.FINANCIAL_EXPERT_REJECTED; + + 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", + }, + metadata: { reason: reason ?? null }, + } as any); + + await (req as any).save(); + + return { + requestId: String(req._id), + publicId: req.publicId, + status: req.status, + message: "File rejected by FinancialExpert. FileReviewer must correct and re-submit.", + }; + } + + /** + * 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}`, + ); + } + + 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) { + throw new ForbiddenException( + "FinancialExpert can only access V5 FileMaker files.", + ); + } + // Scope to same insurer client + if ( + req.clientKey && + financialExpert.clientKey && + String(req.clientKey) !== String(financialExpert.clientKey) + ) { + throw new ForbiddenException( + "You do not have access to files from a different insurer.", + ); + } + } } diff --git a/src/super-admin/dto/super-admin.dto.ts b/src/super-admin/dto/super-admin.dto.ts index 36f9ab1..dc6e969 100644 --- a/src/super-admin/dto/super-admin.dto.ts +++ b/src/super-admin/dto/super-admin.dto.ts @@ -74,3 +74,42 @@ 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 d102c56..9a3e1d8 100644 --- a/src/super-admin/super-admin.controller.ts +++ b/src/super-admin/super-admin.controller.ts @@ -19,6 +19,7 @@ import { CreateSuperAdminDto, CreateFieldExpertAdminDto, CreateRegistrarAdminDto, + CreateFinancialExpertAdminDto, } from "./dto/super-admin.dto"; import { ClientDto } from "src/client/dto/create-client.dto"; import { @@ -129,6 +130,18 @@ 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 5a91545..f0edd36 100644 --- a/src/super-admin/super-admin.service.ts +++ b/src/super-admin/super-admin.service.ts @@ -19,10 +19,12 @@ 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 { @@ -33,6 +35,7 @@ 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). */ @@ -99,4 +102,29 @@ 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 new file mode 100644 index 0000000..5c96a0a --- /dev/null +++ b/src/users/entities/db-service/financial-expert.db.service.ts @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000..1d2c822 --- /dev/null +++ b/src/users/entities/schema/financial-expert.schema.ts @@ -0,0 +1,69 @@ +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 e2209f0..4f51b2a 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -11,6 +11,7 @@ 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, @@ -41,6 +42,10 @@ import { FileReviewerDbSchema, FileReviewerModel, } from "./entities/schema/file-reviewer.schema"; +import { + FinancialExpertDbSchema, + FinancialExpertModel, +} from "./entities/schema/financial-expert.schema"; @Module({ imports: [ @@ -54,6 +59,7 @@ import { { name: ExpertFileActivity.name, schema: ExpertFileActivitySchema }, { name: FileMakerModel.name, schema: FileMakerDbSchema }, { name: FileReviewerModel.name, schema: FileReviewerDbSchema }, + { name: FinancialExpertModel.name, schema: FinancialExpertDbSchema }, ]), HashModule, ], @@ -68,6 +74,7 @@ import { ExpertFileActivityDbService, FileMakerDbService, FileReviewerDbService, + FinancialExpertDbService, ], exports: [ UserDbService, @@ -79,6 +86,7 @@ import { ExpertFileActivityDbService, FileMakerDbService, FileReviewerDbService, + FinancialExpertDbService, ], }) export class UsersModule {}