From 65e74766420da314412d27815d2d9510b71bd100 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Tue, 30 Jun 2026 13:54:21 +0330 Subject: [PATCH 1/7] Added fileMaker and fileReviewer for new flow --- src/Types&Enums/role.enum.ts | 2 + src/auth/auth-services/actor.auth.service.ts | 20 + src/auth/guards/actor-local.guard.ts | 2 + .../expert-blame.v2.controller.ts | 2 +- .../expert-claim.v2.controller.ts | 2 +- .../dto/create-insurer-expert.dto.ts | 16 + .../expert-insurer.controller.ts | 28 ++ src/expert-insurer/expert-insurer.service.ts | 108 +++++ .../file-maker-blame-v4.controller.ts | 368 ++++++++++++++++ .../file-reviewer-blame-v4.controller.ts | 405 ++++++++++++++++++ .../request-management.module.ts | 4 + .../db-service/file-maker.db.service.ts | 63 +++ .../db-service/file-reviewer.db.service.ts | 63 +++ .../entities/schema/file-maker.schema.ts | 67 +++ .../entities/schema/file-reviewer.schema.ts | 68 +++ src/users/users.module.ts | 16 + 16 files changed, 1232 insertions(+), 2 deletions(-) create mode 100644 src/request-management/file-maker-blame-v4.controller.ts create mode 100644 src/request-management/file-reviewer-blame-v4.controller.ts create mode 100644 src/users/entities/db-service/file-maker.db.service.ts create mode 100644 src/users/entities/db-service/file-reviewer.db.service.ts create mode 100644 src/users/entities/schema/file-maker.schema.ts create mode 100644 src/users/entities/schema/file-reviewer.schema.ts diff --git a/src/Types&Enums/role.enum.ts b/src/Types&Enums/role.enum.ts index 5cb284c..9b6439b 100644 --- a/src/Types&Enums/role.enum.ts +++ b/src/Types&Enums/role.enum.ts @@ -6,4 +6,6 @@ export enum RoleEnum { COMPANY = "company", ADMIN = "admin", USER = "user", + FILE_MAKER = "file_maker", + FILE_REVIEWER = "file_reviewer", } diff --git a/src/auth/auth-services/actor.auth.service.ts b/src/auth/auth-services/actor.auth.service.ts index 8c89c00..185950c 100644 --- a/src/auth/auth-services/actor.auth.service.ts +++ b/src/auth/auth-services/actor.auth.service.ts @@ -27,6 +27,8 @@ import { DamageExpertDbService } from "src/users/entities/db-service/damage-expe import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service"; 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 { HashService } from "src/utils/hash/hash.service"; import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service"; @@ -52,6 +54,8 @@ export class ActorAuthService { private readonly clientDbService: ClientDbService, private readonly otpService: OtpGeneratorService, private readonly captchaChallengeService: CaptchaChallengeService, + private readonly fileMakerDbService: FileMakerDbService, + private readonly fileReviewerDbService: FileReviewerDbService, ) {} // TODO convrt to class for dynamic controller @@ -94,6 +98,22 @@ export class ActorAuthService { case RoleEnum.COMPANY: res = await this.insurerExpertDbService.findOne({ email: username }); break; + case RoleEnum.FILE_MAKER: + if (username == null && userId) + res = await this.fileMakerDbService.findOne({ + _id: new Types.ObjectId(userId), + }); + else res = await this.fileMakerDbService.findByLoginIdentifier(username); + break; + case RoleEnum.FILE_REVIEWER: + if (username == null && userId) + res = await this.fileReviewerDbService.findOne({ + _id: new Types.ObjectId(userId), + }); + else + res = + await this.fileReviewerDbService.findByLoginIdentifier(username); + break; default: return null; } diff --git a/src/auth/guards/actor-local.guard.ts b/src/auth/guards/actor-local.guard.ts index 1a67f69..9c8dc2d 100644 --- a/src/auth/guards/actor-local.guard.ts +++ b/src/auth/guards/actor-local.guard.ts @@ -43,6 +43,8 @@ export class LocalActorAuthGuard implements CanActivate { RoleEnum.COMPANY, RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR, + RoleEnum.FILE_MAKER, + RoleEnum.FILE_REVIEWER, ].includes(payload.role) ) { throw new UnauthorizedException("User role is not authorized"); diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index c0bd256..0268859 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -38,7 +38,7 @@ import { ResendRequestDto } from "./dto/resend.dto"; @Controller("v2/expert-blame") @ApiBearerAuth() @UseGuards(LocalActorAuthGuard, RolesGuard) -@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT) +@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT, RoleEnum.FILE_REVIEWER) export class ExpertBlameV2Controller { constructor(private readonly expertBlameService: ExpertBlameService) {} diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index bd6030a..0dea519 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -62,7 +62,7 @@ class InPersonVisitV2Dto { @Controller("v2/expert-claim") @ApiBearerAuth() @UseGuards(LocalActorAuthGuard, RolesGuard) -@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.FIELD_EXPERT) +@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.FIELD_EXPERT, RoleEnum.FILE_REVIEWER) export class ExpertClaimV2Controller { constructor( private readonly expertClaimService: ExpertClaimService, diff --git a/src/expert-insurer/dto/create-insurer-expert.dto.ts b/src/expert-insurer/dto/create-insurer-expert.dto.ts index b1e7caa..05e668b 100644 --- a/src/expert-insurer/dto/create-insurer-expert.dto.ts +++ b/src/expert-insurer/dto/create-insurer-expert.dto.ts @@ -81,3 +81,19 @@ export class CreateClaimExpertByInsurerDto extends CreateInsurerExpertDto { }) role?: RoleEnum.DAMAGE_EXPERT; } + +export class CreateFileMakerByInsurerDto extends CreateInsurerExpertDto { + @ApiPropertyOptional({ + enum: [RoleEnum.FILE_MAKER], + default: RoleEnum.FILE_MAKER, + }) + role?: RoleEnum.FILE_MAKER; +} + +export class CreateFileReviewerByInsurerDto extends CreateInsurerExpertDto { + @ApiPropertyOptional({ + enum: [RoleEnum.FILE_REVIEWER], + default: RoleEnum.FILE_REVIEWER, + }) + role?: RoleEnum.FILE_REVIEWER; +} diff --git a/src/expert-insurer/expert-insurer.controller.ts b/src/expert-insurer/expert-insurer.controller.ts index 8a2932e..ce51129 100644 --- a/src/expert-insurer/expert-insurer.controller.ts +++ b/src/expert-insurer/expert-insurer.controller.ts @@ -36,6 +36,8 @@ import { CreateBranchDto } from "src/client/dto/create-branch.dto"; import { CreateBlameExpertByInsurerDto, CreateClaimExpertByInsurerDto, + CreateFileMakerByInsurerDto, + CreateFileReviewerByInsurerDto, } from "./dto/create-insurer-expert.dto"; @Controller("expert-insurer") @@ -142,6 +144,32 @@ export class ExpertInsurerController { return this.expertInsurerService.addClaimExpert(insurer.clientKey, body); } + @Post("experts/file-maker") + @ApiBody({ type: CreateFileMakerByInsurerDto }) + @ApiOperation({ summary: "Create a FileMaker account under this insurer" }) + async addFileMaker( + @CurrentUser() insurer, + @Body() body: CreateFileMakerByInsurerDto, + ) { + if (!insurer) { + throw new UnauthorizedException("Could not identify the current user."); + } + return this.expertInsurerService.addFileMaker(insurer.clientKey, body); + } + + @Post("experts/file-reviewer") + @ApiBody({ type: CreateFileReviewerByInsurerDto }) + @ApiOperation({ summary: "Create a FileReviewer account under this insurer" }) + async addFileReviewer( + @CurrentUser() insurer, + @Body() body: CreateFileReviewerByInsurerDto, + ) { + if (!insurer) { + throw new UnauthorizedException("Could not identify the current user."); + } + return this.expertInsurerService.addFileReviewer(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 9da7f6b..ac66e52 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -12,6 +12,8 @@ import { BranchDbService } from "src/client/entities/db-service/branch.db.servic import { CreateBlameExpertByInsurerDto, CreateClaimExpertByInsurerDto, + CreateFileMakerByInsurerDto, + CreateFileReviewerByInsurerDto, CreateInsurerExpertDto, } from "./dto/create-insurer-expert.dto"; import { @@ -25,6 +27,8 @@ import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim- import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; 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 { 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"; @@ -89,6 +93,8 @@ export class ExpertInsurerService { private readonly videoCaptureDbService: VideoCaptureDbService, private readonly clientDbService: ClientDbService, private readonly claimSignDbService: ClaimSignDbService, + private readonly fileMakerDbService: FileMakerDbService, + private readonly fileReviewerDbService: FileReviewerDbService, ) {} /** @@ -1651,6 +1657,108 @@ export class ExpertInsurerService { ); } + async addFileMaker( + insurerClientKey: string, + payload: CreateFileMakerByInsurerDto, + ) { + const clientObjectId = this.getClientId(insurerClientKey); + const branch = await this.assertBranchBelongsToClient( + payload.branchId, + clientObjectId, + ); + + const email = payload.email.trim().toLowerCase(); + const existing = await this.fileMakerDbService.findOne({ email }); + if (existing) { + throw new ConflictException("A FileMaker with this email already exists."); + } + + const nationalCode = payload.nationalCode.trim(); + const existingByNational = await this.fileMakerDbService.findOne({ + nationalCode, + }); + if (existingByNational) { + throw new ConflictException( + "A FileMaker with this national code already exists.", + ); + } + + const hashedPassword = await this.hashService.hash(payload.password); + const created = await this.fileMakerDbService.create({ + ...payload, + email, + nationalCode, + password: hashedPassword, + role: RoleEnum.FILE_MAKER, + clientKey: clientObjectId, + branchId: new Types.ObjectId(payload.branchId), + }); + + return { + fileMaker: this.sanitizeExpertResponse(created), + branch: { + _id: (branch as any)._id, + name: branch.name, + code: branch.code, + city: branch.city, + state: branch.state, + address: branch.address, + }, + }; + } + + async addFileReviewer( + insurerClientKey: string, + payload: CreateFileReviewerByInsurerDto, + ) { + const clientObjectId = this.getClientId(insurerClientKey); + const branch = await this.assertBranchBelongsToClient( + payload.branchId, + clientObjectId, + ); + + const email = payload.email.trim().toLowerCase(); + const existing = await this.fileReviewerDbService.findOne({ email }); + if (existing) { + throw new ConflictException( + "A FileReviewer with this email already exists.", + ); + } + + const nationalCode = payload.nationalCode.trim(); + const existingByNational = await this.fileReviewerDbService.findOne({ + nationalCode, + }); + if (existingByNational) { + throw new ConflictException( + "A FileReviewer with this national code already exists.", + ); + } + + const hashedPassword = await this.hashService.hash(payload.password); + const created = await this.fileReviewerDbService.create({ + ...payload, + email, + nationalCode, + password: hashedPassword, + role: RoleEnum.FILE_REVIEWER, + clientKey: clientObjectId, + branchId: new Types.ObjectId(payload.branchId), + }); + + return { + fileReviewer: 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-v4.controller.ts b/src/request-management/file-maker-blame-v4.controller.ts new file mode 100644 index 0000000..bdb2227 --- /dev/null +++ b/src/request-management/file-maker-blame-v4.controller.ts @@ -0,0 +1,368 @@ +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"; + +/** + * V4 FileMaker flow — first half of the split blame workflow. + * + * The FileMaker is responsible for everything up to and including the final + * party signature. After both signatures are collected the file is considered + * "sealed" and a FileReviewer can pick it up. + * + * 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 + * + * CAR_BODY sequence: same but single party — omit second-party steps. + */ +@ApiTags("v4 FileMaker — blame file creation") +@Controller("v4/file-maker/blame-request-management") +@ApiBearerAuth() +@UseGuards(LocalActorAuthGuard, RolesGuard) +@Roles(RoleEnum.FILE_MAKER) +export class FileMakerBlameV4Controller { + constructor( + private readonly requestManagementService: RequestManagementService, + private readonly mediaPolicyService: MediaPolicyService, + private readonly claimRequestManagementService: ClaimRequestManagementService, + ) {} + + // ─── File creation ─────────────────────────────────────────────────────────── + + @Post() + @ApiOperation({ summary: "Create IN_PERSON blame file (FileMaker)" }) + @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, `v4-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, `v4-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, + ); + } +} diff --git a/src/request-management/file-reviewer-blame-v4.controller.ts b/src/request-management/file-reviewer-blame-v4.controller.ts new file mode 100644 index 0000000..c5cbf8a --- /dev/null +++ b/src/request-management/file-reviewer-blame-v4.controller.ts @@ -0,0 +1,405 @@ +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"; + +/** + * V4 FileReviewer flow — second half of the split blame workflow. + * + * The FileReviewer picks up a sealed file (both signatures collected by the + * FileMaker) and completes the damage-assessment portion: accident fields, + * documents, part selection, photo capture, walk-around video, and the final + * blame accident video that moves the file to WAITING_FOR_EXPERT. + * + * 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_EXPERT) + */ +@ApiTags("v4 FileReviewer — blame file review & capture") +@Controller("v4/file-reviewer/blame-request-management") +@ApiBearerAuth() +@UseGuards(LocalActorAuthGuard, RolesGuard) +@Roles(RoleEnum.FILE_REVIEWER) +export class FileReviewerBlameV4Controller { + 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_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 (V4)", + 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, `v4-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, `v4-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 ──────────────────────────────────────────────────────── + + @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, `v4-blame-accident-${unique}${ex}`); + }, + }), + }), + ) + @Post("upload-video/:requestId") + @ApiParam({ name: "requestId" }) + @ApiOperation({ + summary: "Blame accident video (FileReviewer final step)", + description: + "Last step of the V4 flow. Requires claim walk-around video and completed capture. " + + "Sets blame status to WAITING_FOR_EXPERT.", + }) + async uploadBlameVideo( + @Param("requestId") requestId: string, + @CurrentUser() fileReviewer: any, + @UploadedFile() file: Express.Multer.File, + ) { + await this.mediaPolicyService.assertForBlame(file, requestId, "video"); + return this.requestManagementService.expertUploadBlameVideoV3( + fileReviewer, + requestId, + file, + ); + } +} diff --git a/src/request-management/request-management.module.ts b/src/request-management/request-management.module.ts index 43ccfb1..6de1fe3 100644 --- a/src/request-management/request-management.module.ts +++ b/src/request-management/request-management.module.ts @@ -43,6 +43,8 @@ import { ExpertInitiatedBlameMirrorController } from "./expert-initiated-blame.m import { RegistrarInitiatedController } from "./registrar-initiated.controller"; import { RegistrarBlameMirrorController } from "./registrar-blame.mirror.controller"; 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 { InquiryRefreshController } from "./inquiry-refresh.controller"; import { InquiryRefreshService } from "./inquiry-refresh.service"; @@ -81,6 +83,8 @@ import { InquiryRefreshService } from "./inquiry-refresh.service"; ExpertInitiatedBlameV3MirrorController, RegistrarInitiatedController, RegistrarBlameMirrorController, + FileMakerBlameV4Controller, + FileReviewerBlameV4Controller, InquiryRefreshController, ], providers: [ diff --git a/src/users/entities/db-service/file-maker.db.service.ts b/src/users/entities/db-service/file-maker.db.service.ts new file mode 100644 index 0000000..988b030 --- /dev/null +++ b/src/users/entities/db-service/file-maker.db.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { FilterQuery, Model, Types } from "mongoose"; +import { FileMakerModel } from "../schema/file-maker.schema"; + +@Injectable() +export class FileMakerDbService { + constructor( + @InjectModel(FileMakerModel.name) + private readonly fileMakerModel: Model, + ) {} + + async create( + user: Partial & { password: string }, + ): Promise { + return await this.fileMakerModel.create(user); + } + + async findOne( + filter: FilterQuery, + ): Promise { + return await this.fileMakerModel.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.fileMakerModel.findOne({ $or: or }); + } + + async findById(userId: string): Promise { + return await this.fileMakerModel.findOne({ + _id: new Types.ObjectId(userId), + }); + } + + async findOneAndUpdate( + filter: FilterQuery, + update: FilterQuery, + ) { + return await this.fileMakerModel.findOneAndUpdate(filter, update, { + new: true, + }); + } + + async findAll( + filter: FilterQuery, + ): Promise<(FileMakerModel & { _id: Types.ObjectId })[]> { + return await this.fileMakerModel.find(filter).lean(); + } + + async updateOne(filter: FilterQuery, update: any) { + return this.fileMakerModel.updateOne(filter, update); + } +} diff --git a/src/users/entities/db-service/file-reviewer.db.service.ts b/src/users/entities/db-service/file-reviewer.db.service.ts new file mode 100644 index 0000000..c6556f4 --- /dev/null +++ b/src/users/entities/db-service/file-reviewer.db.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { FilterQuery, Model, Types } from "mongoose"; +import { FileReviewerModel } from "../schema/file-reviewer.schema"; + +@Injectable() +export class FileReviewerDbService { + constructor( + @InjectModel(FileReviewerModel.name) + private readonly fileReviewerModel: Model, + ) {} + + async create( + user: Partial & { password: string }, + ): Promise { + return await this.fileReviewerModel.create(user); + } + + async findOne( + filter: FilterQuery, + ): Promise { + return await this.fileReviewerModel.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.fileReviewerModel.findOne({ $or: or }); + } + + async findById(userId: string): Promise { + return await this.fileReviewerModel.findOne({ + _id: new Types.ObjectId(userId), + }); + } + + async findOneAndUpdate( + filter: FilterQuery, + update: FilterQuery, + ) { + return await this.fileReviewerModel.findOneAndUpdate(filter, update, { + new: true, + }); + } + + async findAll( + filter: FilterQuery, + ): Promise<(FileReviewerModel & { _id: Types.ObjectId })[]> { + return await this.fileReviewerModel.find(filter).lean(); + } + + async updateOne(filter: FilterQuery, update: any) { + return this.fileReviewerModel.updateOne(filter, update); + } +} diff --git a/src/users/entities/schema/file-maker.schema.ts b/src/users/entities/schema/file-maker.schema.ts new file mode 100644 index 0000000..ab03e32 --- /dev/null +++ b/src/users/entities/schema/file-maker.schema.ts @@ -0,0 +1,67 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { Types } from "mongoose"; +import { RoleEnum } from "src/Types&Enums/role.enum"; + +@Schema({ + collection: "file-maker", + versionKey: false, + timestamps: true, +}) +export class FileMakerModel { + _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.FILE_MAKER }) + role: RoleEnum; + + @Prop({ type: "string", default: "" }) + otp: string; + + @Prop({ type: "string", required: false }) + expertCode?: string; + + createdAt: Date; +} + +export const FileMakerDbSchema = SchemaFactory.createForClass(FileMakerModel); + +FileMakerDbSchema.index( + { clientKey: 1, nationalCode: 1 }, + { unique: true, sparse: true }, +); + +FileMakerDbSchema.pre("save", function (next) { + if (!this.username) { + this.username = (this as any).email || (this as any).nationalCode; + } + next(); +}); diff --git a/src/users/entities/schema/file-reviewer.schema.ts b/src/users/entities/schema/file-reviewer.schema.ts new file mode 100644 index 0000000..d8a8b06 --- /dev/null +++ b/src/users/entities/schema/file-reviewer.schema.ts @@ -0,0 +1,68 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { Types } from "mongoose"; +import { RoleEnum } from "src/Types&Enums/role.enum"; + +@Schema({ + collection: "file-reviewer", + versionKey: false, + timestamps: true, +}) +export class FileReviewerModel { + _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.FILE_REVIEWER }) + role: RoleEnum; + + @Prop({ type: "string", default: "" }) + otp: string; + + @Prop({ type: "string", required: false }) + expertCode?: string; + + createdAt: Date; +} + +export const FileReviewerDbSchema = + SchemaFactory.createForClass(FileReviewerModel); + +FileReviewerDbSchema.index( + { clientKey: 1, nationalCode: 1 }, + { unique: true, sparse: true }, +); + +FileReviewerDbSchema.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 12e7e04..e2209f0 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -9,6 +9,8 @@ import { InsurerExpertDbService } from "./entities/db-service/insurer-expert.db. import { RegistrarDbService } from "./entities/db-service/registrar.db.service"; 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 { DamageExpertDbSchema, DamageExpertModel, @@ -31,6 +33,14 @@ import { RegistrarModel, } from "./entities/schema/registrar.schema"; import { UserModel, UserDbSchema } from "./entities/schema/user.schema"; +import { + FileMakerDbSchema, + FileMakerModel, +} from "./entities/schema/file-maker.schema"; +import { + FileReviewerDbSchema, + FileReviewerModel, +} from "./entities/schema/file-reviewer.schema"; @Module({ imports: [ @@ -42,6 +52,8 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema"; { name: InsurerExpertModel.name, schema: InsurerExpertDbSchema }, { name: RegistrarModel.name, schema: RegistrarDbSchema }, { name: ExpertFileActivity.name, schema: ExpertFileActivitySchema }, + { name: FileMakerModel.name, schema: FileMakerDbSchema }, + { name: FileReviewerModel.name, schema: FileReviewerDbSchema }, ]), HashModule, ], @@ -54,6 +66,8 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema"; InsurerExpertDbService, RegistrarDbService, ExpertFileActivityDbService, + FileMakerDbService, + FileReviewerDbService, ], exports: [ UserDbService, @@ -63,6 +77,8 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema"; InsurerExpertDbService, RegistrarDbService, ExpertFileActivityDbService, + FileMakerDbService, + FileReviewerDbService, ], }) export class UsersModule {} From f3c7f6a7e016687b19300d2c2e034c63eedef3c9 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Wed, 1 Jul 2026 11:11:27 +0330 Subject: [PATCH 2/7] Access new roles --- src/expert-blame/expert-blame.v2.controller.ts | 2 +- src/expert-claim/expert-claim.v2.controller.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index 0268859..ecf70ae 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -38,7 +38,7 @@ import { ResendRequestDto } from "./dto/resend.dto"; @Controller("v2/expert-blame") @ApiBearerAuth() @UseGuards(LocalActorAuthGuard, RolesGuard) -@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT, RoleEnum.FILE_REVIEWER) +@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT, RoleEnum.FILE_REVIEWER, RoleEnum.FILE_MAKER) export class ExpertBlameV2Controller { constructor(private readonly expertBlameService: ExpertBlameService) {} diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 0dea519..44e6f3e 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -62,7 +62,7 @@ class InPersonVisitV2Dto { @Controller("v2/expert-claim") @ApiBearerAuth() @UseGuards(LocalActorAuthGuard, RolesGuard) -@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.FIELD_EXPERT, RoleEnum.FILE_REVIEWER) +@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.FIELD_EXPERT, RoleEnum.FILE_REVIEWER, RoleEnum.FILE_MAKER) export class ExpertClaimV2Controller { constructor( private readonly expertClaimService: ExpertClaimService, From edf027acd3dab79b45de21126ed4432a4c5d376d Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Wed, 1 Jul 2026 12:00:29 +0330 Subject: [PATCH 3/7] YARA-1076 --- .../claim-request-management.service.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index e971684..5630d20 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -346,6 +346,14 @@ export class ClaimRequestManagementService { : this.isRequiredDocumentUploadedOnClaim(claimCase, k); if (!ok) return false; } + if (isCarBody) { + const g = ClaimRequiredDocumentType.CAR_GREEN_CARD; + const ok = + assumeUploadedKey === g + ? true + : this.isRequiredDocumentUploadedOnClaim(claimCase, g); + if (!ok) return false; + } return true; } @@ -362,6 +370,14 @@ export class ClaimRequestManagementService { : this.isRequiredDocumentUploadedOnClaim(claimCase, k); if (!ok) n++; } + if (isCarBody) { + const g = ClaimRequiredDocumentType.CAR_GREEN_CARD; + const ok = + assumeUploadedKey === g + ? true + : this.isRequiredDocumentUploadedOnClaim(claimCase, g); + if (!ok) n++; + } return n; } From 493be68b80af7ad8b3636b963542a5ab88e8c5c4 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Wed, 1 Jul 2026 12:04:39 +0330 Subject: [PATCH 4/7] YARA-1025 --- .../request-management.service.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index bcb4bef..a4b8168 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -47,6 +47,7 @@ import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status. import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service"; +import { isOtpExpiryActive } from "src/helpers/user-otp-expiry"; import { parseIranLocalDateTime } from "src/helpers/iran-datetime"; import { applyListQueryV2 } from "src/helpers/list-query-v2"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; @@ -4735,6 +4736,7 @@ export class RequestManagementService { const phone = (dto?.phoneNumber || "").trim(); if (!phone) throw new BadRequestException("phoneNumber is required"); + // --- Cooldown guard (in-memory history, catches already-persisted sends) --- const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000); const recentlySentToPhone = (req.history || []) .slice() @@ -4752,6 +4754,20 @@ export class RequestManagementService { ); } + // --- DB-level cooldown guard (catches burst/concurrent requests before history is saved) --- + // Read the user's otpExpire directly from the users collection. If a live + // OTP already exists we return 400 immediately — no SMS call, no DB writes. + const canonicalPhone = + normalizeIranMobile(phone) ?? phone; + const existingUser = await this.userDbService.findOne( + buildUserLookupByPhone(canonicalPhone), + ); + if (existingUser && isOtpExpiryActive(existingUser.otpExpire)) { + throw new BadRequestException( + `(${phone}): OTP was sent recently. Please wait before requesting again.`, + ); + } + try { await this.userAuthService.sendOtpRequest(phone); } catch (e: any) { From f92cee0575d8574d559212f0837ec1ab412ffd5d Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Wed, 1 Jul 2026 12:11:19 +0330 Subject: [PATCH 5/7] YARA-1075 --- .../request-management.service.ts | 85 ++++++++++++------- 1 file changed, 54 insertions(+), 31 deletions(-) diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index a4b8168..49572d0 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -2092,14 +2092,32 @@ export class RequestManagementService { (req.parties[secondIdx].person as any).phoneNumber = phoneNumber; } + // If a valid OTP is already active for this number, return 200 immediately — + // no duplicate SMS, no unnecessary write, no error shown to the expert. + const canonicalPhoneAdv = normalizeIranMobile(phoneNumber) ?? phoneNumber; + const existingUserAdv = await this.userDbService.findOne( + buildUserLookupByPhone(canonicalPhoneAdv), + ); + if (existingUserAdv && isOtpExpiryActive(existingUserAdv.otpExpire)) { + return { + requestId: req._id, + publicId: req.publicId, + workflow: req.workflow, + message: `OTP already sent to ${phoneNumber} and is still valid. Collect the code and call verify-party-otp.`, + }; + } + // Send OTP to second party — expert collects it from them in person try { await this.userAuthService.sendOtpRequest(phoneNumber); } catch (e: any) { if (e?.message?.includes("Wait for expiry")) { - throw new BadRequestException( - `(${phoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`, - ); + return { + requestId: req._id, + publicId: req.publicId, + workflow: req.workflow, + message: `OTP already sent to ${phoneNumber} and is still valid. Collect the code and call verify-party-otp.`, + }; } throw e; } @@ -4548,29 +4566,32 @@ export class RequestManagementService { ); } const sent: string[] = []; - try { - await this.userAuthService.sendOtpRequest(dto.firstPartyPhoneNumber); - sent.push(dto.firstPartyPhoneNumber); - } catch (e: any) { - if (e?.message?.includes("Wait for expiry")) { - throw new BadRequestException( - `First party (${dto.firstPartyPhoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`, - ); + + // Helper: send OTP or silently succeed when a valid OTP already exists. + const sendOrSkipIfActive = async (phone: string): Promise => { + const canonical = normalizeIranMobile(phone) ?? phone; + const existing = await this.userDbService.findOne( + buildUserLookupByPhone(canonical), + ); + if (existing && isOtpExpiryActive(existing.otpExpire)) { + sent.push(phone); // already has a live OTP — treat as sent + return; } - throw e; - } - if (dto.secondPartyPhoneNumber) { try { - await this.userAuthService.sendOtpRequest(dto.secondPartyPhoneNumber); - sent.push(dto.secondPartyPhoneNumber); + await this.userAuthService.sendOtpRequest(phone); + sent.push(phone); } catch (e: any) { if (e?.message?.includes("Wait for expiry")) { - throw new BadRequestException( - `Second party (${dto.secondPartyPhoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`, - ); + sent.push(phone); // race: sendOtpRequest found it active too — treat as sent + return; } throw e; } + }; + + await sendOrSkipIfActive(dto.firstPartyPhoneNumber); + if (dto.secondPartyPhoneNumber) { + await sendOrSkipIfActive(dto.secondPartyPhoneNumber); } return { sent: true, @@ -4749,32 +4770,34 @@ export class RequestManagementService { new Date(event.timestamp) > twoMinutesAgo, ); if (recentlySentToPhone) { - throw new BadRequestException( - `(${phone}): OTP was sent recently. Please wait 2 minutes before requesting again.`, - ); + return { + sent: true, + message: `OTP already sent to ${phone} and is still valid. Collect the code from the party, then call verify-party-otp.`, + }; } // --- DB-level cooldown guard (catches burst/concurrent requests before history is saved) --- // Read the user's otpExpire directly from the users collection. If a live - // OTP already exists we return 400 immediately — no SMS call, no DB writes. - const canonicalPhone = - normalizeIranMobile(phone) ?? phone; + // OTP already exists return 200 immediately — no duplicate SMS, no DB writes. + const canonicalPhone = normalizeIranMobile(phone) ?? phone; const existingUser = await this.userDbService.findOne( buildUserLookupByPhone(canonicalPhone), ); if (existingUser && isOtpExpiryActive(existingUser.otpExpire)) { - throw new BadRequestException( - `(${phone}): OTP was sent recently. Please wait before requesting again.`, - ); + return { + sent: true, + message: `OTP already sent to ${phone} and is still valid. Collect the code from the party, then call verify-party-otp.`, + }; } try { await this.userAuthService.sendOtpRequest(phone); } catch (e: any) { if (e?.message?.includes("Wait for expiry")) { - throw new BadRequestException( - `(${phone}): OTP was recently sent. Wait for the expiry time before requesting again.`, - ); + return { + sent: true, + message: `OTP already sent to ${phone} and is still valid. Collect the code from the party, then call verify-party-otp.`, + }; } throw e; } From dc006735ba3fc3129914fbc5315a73bf7c67ccd6 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Wed, 1 Jul 2026 12:15:22 +0330 Subject: [PATCH 6/7] Duplicated capture-requirements endpoint for file-maker --- .../file-maker-blame-v4.controller.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/request-management/file-maker-blame-v4.controller.ts b/src/request-management/file-maker-blame-v4.controller.ts index bdb2227..d1bb5eb 100644 --- a/src/request-management/file-maker-blame-v4.controller.ts +++ b/src/request-management/file-maker-blame-v4.controller.ts @@ -43,6 +43,7 @@ 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"; /** * V4 FileMaker flow — first half of the split blame workflow. @@ -365,4 +366,24 @@ export class FileMakerBlameV4Controller { 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, + ); + } } From e2a9232523a7e3bc44b8422f07528ca123621fca Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Wed, 1 Jul 2026 12:23:34 +0330 Subject: [PATCH 7/7] YARA-1058 --- .../claim-request-management.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 5630d20..b85e157 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -6297,8 +6297,8 @@ export class ClaimRequestManagementService { ); } - // Initial flow: each document once. Expert resend: allow replacement. - if (!isResendUpload) { + // Initial flow: each document once. Expert resend and v3 in-person: allow replacement. + if (!isResendUpload && !options?.v3InPersonFlow) { const existingDoc = (claimCase.requiredDocuments as any)?.get?.(body.documentKey) ?? (claimCase.requiredDocuments as any)?.[body.documentKey];