From 8f29bb564cb3b6b22f6ca930e9d01b2a597b6184 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Mon, 22 Jun 2026 13:05:11 +0330 Subject: [PATCH] Added v3 of field-expert --- .../claim-request-management.controller.ts | 243 ++-- .../claim-request-management.service.ts | 498 +++++++ .../dto/select-other-parts-v3.dto.ts | 29 + .../registrar-claim.v1.controller.ts | 15 +- src/expert-blame/expert-blame.controller.ts | 52 +- src/expert-claim/expert-claim.controller.ts | 68 +- .../expert-initiated.controller.ts | 3 +- .../dto/run-inquiries-v3.dto.ts | 91 ++ ...rt-initiated-blame-v3.mirror.controller.ts | 584 ++++++++ .../expert-initiated.controller.ts | 235 ++-- .../expert-initiated.v2.controller.ts | 519 ++++--- .../registrar-initiated.controller.ts | 25 +- .../request-management.controller.ts | 4 +- .../request-management.module.ts | 2 + .../request-management.service.ts | 1242 ++++++++++++++++- 15 files changed, 2988 insertions(+), 622 deletions(-) create mode 100644 src/claim-request-management/dto/select-other-parts-v3.dto.ts create mode 100644 src/request-management/dto/run-inquiries-v3.dto.ts create mode 100644 src/request-management/expert-initiated-blame-v3.mirror.controller.ts diff --git a/src/claim-request-management/claim-request-management.controller.ts b/src/claim-request-management/claim-request-management.controller.ts index 255507f..a9d37e1 100644 --- a/src/claim-request-management/claim-request-management.controller.ts +++ b/src/claim-request-management/claim-request-management.controller.ts @@ -23,6 +23,7 @@ import { ApiQuery, ApiTags, ApiOperation, + ApiExcludeController, } from "@nestjs/swagger"; import { diskStorage } from "multer"; import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard"; @@ -39,8 +40,9 @@ import { UserObjectionDto } from "./dto/user-objection.dto"; import { InPersonVisitDto } from "./dto/in-person-visit.dto"; import { UserRatingDto } from "./dto/user-rating.dto"; +@ApiExcludeController() @Controller("claim-request-management") -@ApiTags("claim-request-management") +// @ApiTags("claim-request-management") @Roles(RoleEnum.USER, RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT) @UseGuards(ClaimAccessGuard, RolesGuard) @ApiBearerAuth() @@ -49,8 +51,8 @@ export class ClaimRequestManagementController { private readonly claimRequestManagementService: ClaimRequestManagementService, ) {} - @ApiParam({ name: "blameId" }) - @ApiOperation({ deprecated: true }) + // @ApiParam({ name: "blameId" }) + // @ApiOperation({ deprecated: true }) @Post("/:blameId") async createClaimRequest( @Param("blameId") requestId: string, @@ -63,10 +65,10 @@ export class ClaimRequestManagementController { ); } - @ApiBody({ type: CarDamagePartDto }) - @ApiOperation({ deprecated: true }) + // @ApiBody({ type: CarDamagePartDto }) + // @ApiOperation({ deprecated: true }) @Patch("/car-part-damage/:claimRequestID") - @ApiParam({ name: "claimRequestID" }) + // @ApiParam({ name: "claimRequestID" }) async carPartDamage( @Param("claimRequestID") requestId: string, @Body() body: CarDamagePartDto, @@ -79,7 +81,7 @@ export class ClaimRequestManagementController { ); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("/car-other-part") async getCarOtherParts() { const carOtherPart = await readFile( @@ -89,8 +91,8 @@ export class ClaimRequestManagementController { return carOtherPart; } - @ApiBody({ type: OtherCarDamagePartDto }) - @ApiParam({ name: "claimRequestID" }) + // @ApiBody({ type: OtherCarDamagePartDto }) + // @ApiParam({ name: "claimRequestID" }) @UseInterceptors( FileInterceptor("file", { limits: { @@ -108,7 +110,7 @@ export class ClaimRequestManagementController { }), ) @ApiConsumes("multipart/form-data") - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Patch("/car-other-part-damage/:claimRequestID") async carOtherPartDamage( @Param("claimRequestID") requestId: string, @@ -124,32 +126,32 @@ export class ClaimRequestManagementController { ); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("required-documents-status/:claimRequestID") - @ApiParam({ name: "claimRequestID" }) + // @ApiParam({ name: "claimRequestID" }) async getRequiredDocumentsStatus(@Param("claimRequestID") requestId: string) { return await this.claimRequestManagementService.getRequiredDocumentsStatus( requestId, ); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("car-part-image-required/:claimRequestID") - @ApiParam({ name: "claimRequestID" }) + // @ApiParam({ name: "claimRequestID" }) async getImageRequired(@Param("claimRequestID") requestId) { return await this.claimRequestManagementService.getImageRequiredList( requestId, ); } - @ApiBody({ - schema: { - type: "object", - properties: { - file: { type: "string", format: "binary" }, - }, - }, - }) + // @ApiBody({ + // schema: { + // type: "object", + // properties: { + // file: { type: "string", format: "binary" }, + // }, + // }, + // }) @UseInterceptors( FileInterceptor("file", { limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, @@ -169,14 +171,14 @@ export class ClaimRequestManagementController { }), }), ) - @ApiConsumes("multipart/form-data") - @ApiParam({ name: "claimRequestID" }) - @ApiQuery({ - name: "documentType", - enum: ClaimRequiredDocumentType, - description: "Type of required document to upload", - }) - @ApiOperation({ deprecated: true }) + // @ApiConsumes("multipart/form-data") + // // @ApiParam({ name: "claimRequestID" }) + // @ApiQuery({ + // name: "documentType", + // enum: ClaimRequiredDocumentType, + // description: "Type of required document to upload", + // }) + // @ApiOperation({ deprecated: true }) @Patch("upload-required-document/:claimRequestID") async uploadRequiredDocument( @Param("claimRequestID") requestId: string, @@ -195,14 +197,14 @@ export class ClaimRequestManagementController { ); } - @ApiBody({ - schema: { - type: "object", - properties: { - file: { type: "string", format: "binary" }, - }, - }, - }) + // @ApiBody({ + // schema: { + // type: "object", + // properties: { + // file: { type: "string", format: "binary" }, + // }, + // }, + // }) @UseInterceptors( FileInterceptor("file", { limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, @@ -223,13 +225,13 @@ export class ClaimRequestManagementController { }), }), ) - @ApiConsumes("multipart/form-data") - @ApiParam({ name: "claimRequestID" }) - @ApiParam({ - name: "partId", - description: "The ID of the specific car part being photographed.", - }) - @ApiOperation({ deprecated: true }) + // @ApiConsumes("multipart/form-data") + // @ApiParam({ name: "claimRequestID" }) + // @ApiParam({ + // name: "partId", + // description: "The ID of the specific car part being photographed.", + // }) + // @ApiOperation({ deprecated: true }) @Patch("capture-car-part-damage/:claimRequestID/:partId") async captureCarPartDamage( @Param("partId") partId: string, @@ -246,14 +248,14 @@ export class ClaimRequestManagementController { ); } - @ApiBody({ - schema: { - type: "object", - properties: { - file: { type: "string", format: "binary" }, - }, - }, - }) + // @ApiBody({ + // schema: { + // type: "object", + // properties: { + // file: { type: "string", format: "binary" }, + // }, + // }, + // }) @UseInterceptors( FileInterceptor("file", { limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, @@ -268,9 +270,9 @@ export class ClaimRequestManagementController { }), }), ) - @ApiConsumes("multipart/form-data") - @ApiParam({ name: "claimRequestID" }) - @ApiOperation({ deprecated: true }) + // @ApiConsumes("multipart/form-data") + // @ApiParam({ name: "claimRequestID" }) + // @ApiOperation({ deprecated: true }) @Patch("car-capture/:claimRequestID") async captureVideoCapture( @Param("claimRequestID") requestId: string, @@ -282,22 +284,22 @@ export class ClaimRequestManagementController { ); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("requests/") async getRequest(@CurrentUser() currentUser) { return await this.claimRequestManagementService.myRequests(currentUser); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("request/:claimRequestId") - @ApiParam({ name: "claimRequestId" }) + // @ApiParam({ name: "claimRequestId" }) myRequests(@Param("claimRequestId") requestId: string, @CurrentUser() user) { return this.claimRequestManagementService.requestDetails(requestId, user); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Put("request/reply/:claimRequestId") - @ApiParam({ name: "claimRequestId" }) + // @ApiParam({ name: "claimRequestId" }) @UseInterceptors( FileInterceptor("file", { limits: { @@ -314,12 +316,12 @@ export class ClaimRequestManagementController { }), }), ) - @ApiBody({ - type: UserCommentDto, - description: "if partId null , you can upload video capture", - }) - @ApiConsumes("multipart/form-data") - @ApiParam({ name: "claimRequestId" }) + // @ApiBody({ + // type: UserCommentDto, + // description: "if partId null , you can upload video capture", + // }) + // @ApiConsumes("multipart/form-data") + // @ApiParam({ name: "claimRequestId" }) async submitReply( @Param("claimRequestId") requestId, @Body() body, @@ -334,14 +336,14 @@ export class ClaimRequestManagementController { // ); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Put("request/resend/:claimRequestId/objection") - @ApiParam({ name: "claimRequestId" }) - @ApiConsumes("application/json") - @ApiBody({ - type: UserObjectionDto, - description: "Objection details with optional new parts", - }) + // @ApiParam({ name: "claimRequestId" }) + // @ApiConsumes("application/json") + // @ApiBody({ + // type: UserObjectionDto, + // description: "Objection details with optional new parts", + // }) async handleUserObjection( @Param("claimRequestId") claimRequestId: string, @Body() userObjectionDto: UserObjectionDto, @@ -352,25 +354,25 @@ export class ClaimRequestManagementController { ); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Patch("request/resend/:claimRequestId") - @ApiConsumes("multipart/form-data") - @ApiParam({ name: "claimRequestId" }) - @ApiQuery({ name: "fields", enum: ["resendDocuments", "resendCarParts"] }) - @ApiQuery({ name: "partId", required: false }) - @ApiQuery({ name: "documentName", required: false }) - @ApiQuery({ name: "side", required: false }) - @ApiBody({ - schema: { - type: "object", - properties: { - file: { - type: "string", - format: "binary", - }, - }, - }, - }) + // @ApiConsumes("multipart/form-data") + // @ApiParam({ name: "claimRequestId" }) + // @ApiQuery({ name: "fields", enum: ["resendDocuments", "resendCarParts"] }) + // @ApiQuery({ name: "partId", required: false }) + // @ApiQuery({ name: "documentName", required: false }) + // @ApiQuery({ name: "side", required: false }) + // @ApiBody({ + // schema: { + // type: "object", + // properties: { + // file: { + // type: "string", + // format: "binary", + // }, + // }, + // }, + // }) @UseInterceptors( FileInterceptor("file", { storage: diskStorage({ @@ -403,10 +405,10 @@ export class ClaimRequestManagementController { * User satisfaction rating for a completed claim file. * Only the damaged user (claim owner) can rate their claim after it is closed. */ - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Put("request/:claimRequestId/user-rating") - @ApiParam({ name: "claimRequestId" }) - @ApiBody({ type: UserRatingDto }) + // @ApiParam({ name: "claimRequestId" }) + // @ApiBody({ type: UserRatingDto }) async addUserRating( @Param("claimRequestId") claimRequestId: string, @Body() ratingDto: UserRatingDto, @@ -419,22 +421,22 @@ export class ClaimRequestManagementController { ); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Patch("request/reply/:claimRequestId/:partId/upload-factor") - @ApiConsumes("multipart/form-data") - @ApiParam({ name: "claimRequestId" }) - @ApiParam({ name: "partId" }) - @ApiBody({ - schema: { - type: "object", - properties: { - file: { - type: "string", - format: "binary", - }, - }, - }, - }) + // @ApiConsumes("multipart/form-data") + // @ApiParam({ name: "claimRequestId" }) + // @ApiParam({ name: "partId" }) + // @ApiBody({ + // schema: { + // type: "object", + // properties: { + // file: { + // type: "string", + // format: "binary", + // }, + // }, + // }, + // }) @UseInterceptors( FileInterceptor("file", { storage: diskStorage({ @@ -462,9 +464,9 @@ export class ClaimRequestManagementController { ); } - @ApiBody({ type: InPersonVisitDto }) - @ApiParam({ name: "id" }) - @ApiOperation({ deprecated: true }) + // @ApiBody({ type: InPersonVisitDto }) + // @ApiParam({ name: "id" }) + // @ApiOperation({ deprecated: true }) @Patch(":id/visit") async inPersonVisit( @Param("id") requestId: string, @@ -479,26 +481,25 @@ export class ClaimRequestManagementController { ); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("branches/:insuranceId") + // @ApiParam({ name: "insuranceId" }) async insuranceBranches(@Param("insuranceId") insuranceId: string) { - return await this.claimRequestManagementService.retrieveInsuranceBranches( - insuranceId, - ); + return await this.claimRequestManagementService.retrieveInsuranceBranches(insuranceId); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("fanavaran-submit/:claimRequestId") - @ApiParam({ name: "claimRequestId" }) + // @ApiParam({ name: "claimRequestId" }) async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) { return await this.claimRequestManagementService.fanavaranSubmit( claimRequestId, ); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Post("fanavaran-submit/:claimRequestId") - @ApiParam({ name: "claimRequestId" }) + // @ApiParam({ name: "claimRequestId" }) async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) { return await this.claimRequestManagementService.submitToFanavaran( claimRequestId, diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 8699764..e931595 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -43,6 +43,7 @@ import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto, } from "./dto/select-other-parts-v2.dto"; +import { SelectOtherPartsV3Dto } from "./dto/select-other-parts-v3.dto"; import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto"; import { UploadRequiredDocumentV2Dto, @@ -82,6 +83,7 @@ import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-managem import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { CaseStatus as BlameCaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; +import { WorkflowStep } from "src/Types&Enums/blame-request-management/blameWorkflow-steps.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service"; import { DamageImageDbService } from "./entites/db-service/damage-image.db.service"; @@ -5060,6 +5062,101 @@ export class ClaimRequestManagementService { }; } + /** + * V2: Registrar creates a claim from a registrar-initiated IN_PERSON completed blame. + * Mirrors createClaimFromBlameForRegistrarV1 but uses V2 naming conventions. + */ + async createClaimFromBlameForRegistrarV2( + blameRequestId: string, + registrar: { sub: string; firstName?: string; lastName?: string }, + ): Promise { + const blameRequest = + await this.blameRequestDbService.findById(blameRequestId); + if (!blameRequest) throw new NotFoundException("Blame request not found"); + if (blameRequest.status !== BlameCaseStatus.COMPLETED) { + throw new BadRequestException( + `Blame request must be COMPLETED. Current status: ${blameRequest.status}`, + ); + } + if ( + !blameRequest.registrarInitiated || + blameRequest.creationMethod !== "IN_PERSON" || + !blameRequest.initiatedByRegistrarId + ) { + throw new BadRequestException( + "This endpoint is only for registrar-initiated IN_PERSON blame files.", + ); + } + if (String(blameRequest.initiatedByRegistrarId) !== String(registrar.sub)) { + throw new ForbiddenException( + "Only the registrar who created this blame file can create the claim.", + ); + } + const ownerFields = resolveClaimOwnerFieldsFromBlame(blameRequest); + if (!ownerFields) { + throw new BadRequestException( + "Could not resolve claim owner (damaged party) from blame", + ); + } + const existingClaim = await this.claimCaseDbService.findOne({ + blameRequestId: new Types.ObjectId(blameRequestId), + }); + if (existingClaim) + throw new ConflictException("A claim for this blame case already exists"); + const claimNo = await this.generateUniqueClaimNumber(); + const newClaim = await this.claimCaseDbService.create({ + requestNo: claimNo, + publicId: blameRequest.publicId, + blameRequestId: new Types.ObjectId(blameRequestId), + blameRequestNo: blameRequest.requestNo, + status: ClaimCaseStatus.SELECTING_OUTER_PARTS, + claimStatus: ClaimStatus.PENDING, + inquiries: (blameRequest as any).inquiries ?? {}, + workflow: { + currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, + nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, + completedSteps: [ClaimWorkflowStep.CLAIM_CREATED], + locked: false, + }, + damagedPartyUserId: new Types.ObjectId(ownerFields.userId), + owner: { + userId: new Types.ObjectId(ownerFields.userId), + userRole: ownerFields.userRole as any, + ...(ownerFields.clientId + ? { + clientId: new Types.ObjectId(ownerFields.clientId), + userClientKey: ownerFields.clientId, + } + : {}), + }, + createdByRegistrarId: new Types.ObjectId(registrar.sub), + history: [ + { + type: "CLAIM_CREATED", + actor: { + actorId: new Types.ObjectId(registrar.sub), + actorName: + `${registrar.firstName || ""} ${registrar.lastName || ""}`.trim(), + actorType: "registrar", + }, + timestamp: new Date(), + metadata: { + blameRequestId, + blamePublicId: blameRequest.publicId, + createdByRegistrar: true, + }, + }, + ], + } as any); + return { + claimRequestId: String(newClaim._id), + publicId: blameRequest.publicId, + status: ClaimCaseStatus.SELECTING_OUTER_PARTS, + message: + "Claim created successfully. Registrar can now fill claim data on behalf of the damaged party.", + }; + } + /** * V2 API: Select damaged outer car parts for claim (Step 2 of claim workflow) * @@ -7660,4 +7757,405 @@ export class ClaimRequestManagementService { const randomPart = Math.floor(10000 + Math.random() * 90000); // 5 digits return `${prefix}${randomPart}`; } + + private async loadBlameForV3Claim(claimCase: any) { + if (!claimCase?.blameRequestId) return null; + return this.blameRequestDbService.findById( + claimCase.blameRequestId.toString(), + ); + } + + private hasPartyInquiriesOnBlame(blame: any, role: "FIRST" | "SECOND"): boolean { + const step = + role === "FIRST" + ? WorkflowStep.FIRST_INITIAL_FORM + : WorkflowStep.SECOND_INITIAL_FORM; + return (blame.workflow?.completedSteps ?? []).includes(step); + } + + private partyHasSignedOnBlame(blame: any, role: "FIRST" | "SECOND"): boolean { + const party = (blame.parties ?? []).find((p: any) => p?.role === role); + return !!party?.confirmation; + } + + private assertV3ClaimDocumentsPhase(blame: any): void { + if (!(blame.expert?.decision as any)?.fields?.accidentWay) { + throw new BadRequestException( + "Submit accident fields before uploading required documents.", + ); + } + const isCarBody = + blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY"; + if (isCarBody) { + if (!this.partyHasSignedOnBlame(blame, "FIRST")) { + throw new BadRequestException( + "Upload documents after the party has signed.", + ); + } + return; + } + if ( + !this.partyHasSignedOnBlame(blame, "FIRST") || + !this.partyHasSignedOnBlame(blame, "SECOND") + ) { + throw new BadRequestException( + "Both parties must sign before uploading required documents.", + ); + } + } + + private assertV3ClaimPartsPhase(blame: any): void { + if (!(blame.expert?.decision as any)?.fields?.accidentWay) { + throw new BadRequestException( + "Submit accident fields before part selection.", + ); + } + const isCarBody = + blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY"; + if (isCarBody) { + if (!this.partyHasSignedOnBlame(blame, "FIRST")) { + throw new BadRequestException( + "Part selection requires the party signature.", + ); + } + return; + } + if ( + !this.partyHasSignedOnBlame(blame, "FIRST") || + !this.partyHasSignedOnBlame(blame, "SECOND") + ) { + throw new BadRequestException( + "Both parties must sign before part selection.", + ); + } + } + + private claimV3StepCompleted( + claimCase: any, + step: ClaimWorkflowStep, + ): boolean { + return (claimCase.workflow?.completedSteps ?? []).includes(step); + } + + private assertV3ClaimWorkflowStep( + claimCase: any, + expected: ClaimWorkflowStep | ClaimWorkflowStep[], + hint?: string, + ): void { + const allowed = Array.isArray(expected) ? expected : [expected]; + const current = claimCase.workflow?.currentStep; + if (!allowed.includes(current)) { + throw new BadRequestException( + hint ?? + `Invalid workflow step. Expected ${allowed.join(" or ")}, but current step is ${current}`, + ); + } + } + + private async advanceV3ClaimToOuterPartsIfReady( + claimCase: any, + blame: any, + ): Promise { + if (claimCase.workflow?.currentStep === ClaimWorkflowStep.SELECT_OUTER_PARTS) { + return; + } + if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) { + throw new BadRequestException( + `Complete required documents before selecting outer parts. Current step: ${claimCase.workflow?.currentStep}`, + ); + } + if (!(blame.expert?.decision as any)?.fields?.accidentWay) { + throw new BadRequestException( + "Submit accident fields before selecting outer parts.", + ); + } + await this.claimCaseDbService.findByIdAndUpdate(String(claimCase._id), { + $set: { + status: ClaimCaseStatus.SELECTING_OUTER_PARTS, + "workflow.currentStep": ClaimWorkflowStep.SELECT_OUTER_PARTS, + "workflow.nextStep": ClaimWorkflowStep.SELECT_OTHER_PARTS, + }, + }); + } + + private async assertV3InPersonClaim(claimCase: any) { + const blame = await this.loadBlameForV3Claim(claimCase); + if (!blame) { + throw new BadRequestException("V3 claim flow requires a linked blame file."); + } + if (blame.creationMethod !== CreationMethod.IN_PERSON) { + throw new BadRequestException("V3 claim flow is only for IN_PERSON blame files."); + } + if (!blame.expertInitiated) { + throw new BadRequestException("V3 claim flow requires an expert-initiated blame file."); + } + return blame; + } + + async uploadRequiredDocumentV3( + claimRequestId: string, + body: UploadRequiredDocumentV2Dto, + file: Express.Multer.File, + currentUserId: string, + actor?: { sub: string; role?: string }, + ): Promise { + const claimCase = await this.claimCaseDbService.findById(claimRequestId); + if (!claimCase) { + throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`); + } + const blame = await this.assertV3InPersonClaim(claimCase); + this.assertV3ClaimDocumentsPhase(blame); + + const step = claimCase.workflow?.currentStep; + if (step !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) { + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + $set: { + status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, + "workflow.currentStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + }, + }); + } + + return this.uploadRequiredDocumentV2( + claimRequestId, + body, + file, + currentUserId, + actor, + ); + } + + async getCaptureRequirementsV3( + claimRequestId: string, + currentUserId: string, + actor?: { sub: string; role?: string }, + ): Promise { + const claimCase = await this.claimCaseDbService.findById(claimRequestId); + if (!claimCase) { + throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`); + } + const blame = await this.assertV3InPersonClaim(claimCase); + this.assertV3ClaimDocumentsPhase(blame); + return this.getCaptureRequirementsV2(claimRequestId, currentUserId, actor); + } + + async selectOuterPartsV3( + claimRequestId: string, + body: SelectOuterPartsV2Dto, + currentUserId: string, + actor?: { sub: string; role?: string }, + ): Promise { + const claimCase = await this.claimCaseDbService.findById(claimRequestId); + if (!claimCase) { + throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`); + } + const blame = await this.assertV3InPersonClaim(claimCase); + this.assertV3ClaimPartsPhase(blame); + await this.advanceV3ClaimToOuterPartsIfReady(claimCase, blame); + + const refreshed = await this.claimCaseDbService.findById(claimRequestId); + this.assertV3ClaimWorkflowStep( + refreshed, + ClaimWorkflowStep.SELECT_OUTER_PARTS, + "Select outer parts after accident fields and required documents.", + ); + + return this.selectOuterPartsV2(claimRequestId, body, currentUserId, actor); + } + + async selectOtherPartsV3( + claimRequestId: string, + body: SelectOtherPartsV3Dto, + currentUserId: string, + actor?: { sub: string; role?: string }, + file?: Express.Multer.File, + ): Promise { + const claimCase = await this.claimCaseDbService.findById(claimRequestId); + if (!claimCase) { + throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`); + } + const blame = await this.assertV3InPersonClaim(claimCase); + this.assertV3ClaimPartsPhase(blame); + this.assertV3ClaimWorkflowStep( + claimCase, + ClaimWorkflowStep.SELECT_OTHER_PARTS, + "Select outer parts before other parts.", + ); + if (!this.claimV3StepCompleted(claimCase, ClaimWorkflowStep.SELECT_OUTER_PARTS)) { + throw new BadRequestException( + "Complete outer part selection before selecting other parts.", + ); + } + + let otherParts = Array.isArray((body as any)?.otherParts) + ? (body as any).otherParts + : []; + if ( + !Array.isArray((body as any)?.otherParts) && + typeof (body as any)?.otherParts === "string" + ) { + try { + const parsed = JSON.parse((body as any).otherParts); + otherParts = Array.isArray(parsed) ? parsed : []; + } catch { + otherParts = []; + } + } + + const shebaNumber = claimCase.money?.sheba; + const nationalCode = claimCase.money?.nationalCodeOfInsurer; + if (!shebaNumber || !nationalCode) { + throw new BadRequestException( + "Bank information is missing on the claim. Complete run-inquiries for both parties first.", + ); + } + + const updatePayload: any = { + "damage.otherParts": otherParts.length > 0 ? otherParts : undefined, + status: ClaimCaseStatus.CAPTURING_PART_DAMAGES, + "workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES, + "workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + $push: { + "workflow.completedSteps": ClaimWorkflowStep.SELECT_OTHER_PARTS, + history: { + type: "V3_STEP_COMPLETED", + actor: { + actorId: new Types.ObjectId(currentUserId), + actorName: claimCase.owner?.fullName || "Actor", + actorType: actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert", + }, + timestamp: new Date(), + metadata: { + stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS, + otherParts, + v3SelectionOnly: true, + }, + }, + }, + }; + + if (file) { + const docRef = await this.claimRequiredDocumentDbService.create({ + path: file.path, + fileName: file.filename, + claimId: new Types.ObjectId(claimRequestId), + documentType: ClaimRequiredDocumentType.CAR_GREEN_CARD, + uploadedAt: new Date(), + }); + updatePayload[`requiredDocuments.${ClaimRequiredDocumentType.CAR_GREEN_CARD}`] = { + fileId: docRef._id, + filePath: file.path, + fileName: file.filename, + uploaded: true, + uploadedAt: new Date(), + }; + } + + const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate( + claimRequestId, + updatePayload, + ); + if (!updatedClaim) { + throw new InternalServerErrorException("Failed to update claim case"); + } + + return { + claimRequestId: updatedClaim._id.toString(), + publicId: updatedClaim.publicId, + otherParts, + shebaNumber: String(shebaNumber).replace(/^(.{4})(.*)(.{4})$/, "IR$1************$3"), + nationalCodeOfOwner: String(nationalCode).replace(/^(.{2})(.*)(.{2})$/, "$1******$3"), + currentStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, + nextStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + message: + "Other parts saved. Proceed to damaged-part photos and car angles (capture-part).", + }; + } + + async setVideoCaptureV3( + claimRequestId: string, + fileDetail: Express.Multer.File, + currentUserId: string, + actor?: { sub: string; role?: string }, + ): Promise { + const claimCase = await this.claimCaseDbService.findById(claimRequestId); + if (!claimCase) { + throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`); + } + await this.assertV3InPersonClaim(claimCase); + + if (!fileDetail) { + throw new BadRequestException("Video file is required."); + } + if (claimCase.media?.videoCaptureId) { + throw new ConflictException( + "A walk-around video has already been uploaded for this claim.", + ); + } + + if ( + claimCase.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS + ) { + throw new BadRequestException( + `Complete all part/angle captures first. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, but current step is ${claimCase.workflow?.currentStep}`, + ); + } + + const captureProgress = getClaimCaptureProgress(claimCase); + if (!captureProgress.partsComplete) { + throw new BadRequestException( + "Upload photos for all selected damaged parts before the walk-around video.", + ); + } + if (!captureProgress.anglesComplete) { + throw new BadRequestException( + "Capture all four car angles before the walk-around video.", + ); + } + if (!isClaimCaptureStepComplete(claimCase)) { + throw new BadRequestException( + "Upload capture-phase vehicle documents (chassis, engine, metal plate) before the walk-around video.", + ); + } + + return this.setVideoCaptureV2( + claimRequestId, + fileDetail, + currentUserId, + actor, + ); + } + + async capturePartV3( + claimRequestId: string, + body: CapturePartV2Dto, + file: Express.Multer.File, + currentUserId: string, + actor?: { sub: string; role?: string }, + ): Promise { + const claimCase = await this.claimCaseDbService.findById(claimRequestId); + if (!claimCase) { + throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`); + } + const blame = await this.assertV3InPersonClaim(claimCase); + this.assertV3ClaimPartsPhase(blame); + this.assertV3ClaimWorkflowStep( + claimCase, + ClaimWorkflowStep.CAPTURE_PART_DAMAGES, + "Select other parts before capturing damaged parts and angles.", + ); + if (!this.claimV3StepCompleted(claimCase, ClaimWorkflowStep.SELECT_OTHER_PARTS)) { + throw new BadRequestException( + "Complete other part selection before capture.", + ); + } + + return this.capturePartV2( + claimRequestId, + body, + file, + currentUserId, + actor, + ); + } } diff --git a/src/claim-request-management/dto/select-other-parts-v3.dto.ts b/src/claim-request-management/dto/select-other-parts-v3.dto.ts new file mode 100644 index 0000000..60a4a24 --- /dev/null +++ b/src/claim-request-management/dto/select-other-parts-v3.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { + ArrayMaxSize, + IsArray, + IsEnum, + IsOptional, +} from "class-validator"; +import { OtherCarPart } from "./select-other-parts-v2.dto"; + +/** + * V3 in-person expert flow: other parts only (sheba/national code collected during run-inquiries). + */ +export class SelectOtherPartsV3Dto { + @ApiPropertyOptional({ + description: "Array of selected other damaged parts (non-body parts)", + example: ["engine", "suspension", "headlight"], + enum: OtherCarPart, + isArray: true, + maxItems: 11, + }) + @IsOptional() + @IsArray({ message: "otherParts must be an array" }) + @ArrayMaxSize(11, { message: "Maximum 11 other parts can be selected" }) + @IsEnum(OtherCarPart, { + each: true, + message: "Invalid part name. Must be one of the valid other car parts", + }) + otherParts?: OtherCarPart[]; +} diff --git a/src/claim-request-management/registrar-claim.v1.controller.ts b/src/claim-request-management/registrar-claim.v1.controller.ts index 0cedaab..472f21d 100644 --- a/src/claim-request-management/registrar-claim.v1.controller.ts +++ b/src/claim-request-management/registrar-claim.v1.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Get, Param, Patch, Post, UploadedFile, UseGuards, Use import { FileInterceptor } from "@nestjs/platform-express"; import { diskStorage } from "multer"; import { extname } from "path"; -import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiConsumes, ApiExcludeController } 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"; @@ -15,7 +15,8 @@ import { UploadRequiredDocumentV2Dto } from "./dto/upload-document-v2.dto"; import { CapturePartV2Dto } from "./dto/capture-part-v2.dto"; import { ClaimRequestManagementService } from "./claim-request-management.service"; -@ApiTags("registrar-claim (v1)") +@ApiExcludeController() +// @ApiTags("registrar-claim (v1)") @Controller("registrar-claim") @ApiBearerAuth() @UseGuards(LocalActorAuthGuard, RolesGuard) @@ -24,7 +25,7 @@ export class RegistrarClaimV1Controller { constructor(private readonly claimRequestManagementService: ClaimRequestManagementService) {} @Post("create-from-blame/:blameRequestId") - @ApiParam({ name: "blameRequestId" }) + // @ApiParam({ name: "blameRequestId" }) createFromBlame(@Param("blameRequestId") blameRequestId: string, @CurrentUser() registrar: any) { return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1( blameRequestId, @@ -42,7 +43,7 @@ export class RegistrarClaimV1Controller { } @Patch("select-outer-parts/:claimRequestId") - @ApiBody({ type: SelectOuterPartsV2Dto }) + // @ApiBody({ type: SelectOuterPartsV2Dto }) selectOuter( @Param("claimRequestId") claimRequestId: string, @Body() body: SelectOuterPartsV2Dto, @@ -57,7 +58,7 @@ export class RegistrarClaimV1Controller { } @Patch("select-other-parts/:claimRequestId") - @ApiBody({ type: SelectOtherPartsV2Dto }) + // @ApiBody({ type: SelectOtherPartsV2Dto }) selectOther( @Param("claimRequestId") claimRequestId: string, @Body() body: SelectOtherPartsV2Dto, @@ -91,7 +92,7 @@ export class RegistrarClaimV1Controller { }), }), ) - @ApiOperation({ summary: "Registrar uploads required claim document" }) + // @ApiOperation({ summary: "Registrar uploads required claim document" }) uploadDoc( @Param("claimRequestId") claimRequestId: string, @Body() body: UploadRequiredDocumentV2Dto, @@ -108,7 +109,7 @@ export class RegistrarClaimV1Controller { } @Post("capture-part/:claimRequestId") - @ApiConsumes("multipart/form-data") + // @ApiConsumes("multipart/form-data") @UseInterceptors( FileInterceptor("file", { limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, diff --git a/src/expert-blame/expert-blame.controller.ts b/src/expert-blame/expert-blame.controller.ts index 4418273..168c772 100644 --- a/src/expert-blame/expert-blame.controller.ts +++ b/src/expert-blame/expert-blame.controller.ts @@ -19,6 +19,7 @@ import { ApiProduces, ApiTags, ApiOperation, + ApiExcludeController, } from "@nestjs/swagger"; import { Response, Request } from "express"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; @@ -35,7 +36,8 @@ import { } from "./dto/reply.dto"; import { ExpertBlameService } from "./expert-blame.service"; -@ApiTags("expert-blame-panel") +@ApiExcludeController() +// @ApiTags("expert-blame-panel") @Controller("expert-blame") @ApiBearerAuth() @UseGuards(LocalActorAuthGuard, RolesGuard) @@ -45,35 +47,35 @@ export class ExpertBlameController { // TODO role guard for expert fix @Roles(RoleEnum.EXPERT) - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get() async findAll(@CurrentUser() actor, @ClientKey() client) { return await this.expertBlameService.findAll(actor); } @Roles(RoleEnum.EXPERT) - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get(":id") async findOne(@Param("id") id: string, @CurrentUser() actor) { return await this.expertBlameService.findOne(id, actor.sub); } @Roles(RoleEnum.EXPERT) - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Put("lock/:id") async lockRequest(@Param("id") id: string, @CurrentUser() actor) { return await this.expertBlameService.lockRequest(id, actor); } @Roles(RoleEnum.EXPERT) - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("request/accident-fields") async getAccidentFields() { return await this.expertBlameService.getAccidentField(); } @Roles(RoleEnum.EXPERT) - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Put("reply/submit/:id") @ApiBody({ type: SubmitReplyDto }) @ApiParam({ name: "id" }) @@ -100,10 +102,10 @@ export class ExpertBlameController { } @Roles(RoleEnum.EXPERT) - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Put("reply/resend/first/:id") - @ApiBody({ type: ResendFirstPartyDto }) - @ApiParam({ name: "id" }) + // @ApiBody({ type: ResendFirstPartyDto }) + // @ApiParam({ name: "id" }) async resendFirstParty( @Param("id") id: string, @Body() body: SendAginIF, @@ -113,11 +115,11 @@ export class ExpertBlameController { return this.handleResendRequest(id, body, actor.sub, req); } @Roles(RoleEnum.EXPERT) - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Put("reply/resend/second/:id") @Roles(RoleEnum.EXPERT) - @ApiBody({ type: ResendSecondPartyDto }) - @ApiParam({ name: "id" }) + // @ApiBody({ type: ResendSecondPartyDto }) + // @ApiParam({ name: "id" }) async resendSecondParty( @Param("id") id: string, @Body() body: SendAginIF, @@ -127,25 +129,25 @@ export class ExpertBlameController { return this.handleResendRequest(id, body, actor.sub, req); } @Roles(RoleEnum.EXPERT) - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("stream/:requestId") @ApiParam({ name: "requestId" }) async streamVideo(@Param("requestId") requestId: string) { return this.expertBlameService.streamVideo(requestId); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("voice/:requestId/:voiceId") @Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT) - @ApiParam({ name: "requestId" }) - @ApiParam({ name: "voiceId" }) - @ApiOkResponse({ - schema: { - type: "string", - format: "binary", - }, - }) - @ApiProduces("mp3") + // @ApiParam({ name: "requestId" }) + // @ApiParam({ name: "voiceId" }) + // @ApiOkResponse({ + // schema: { + // type: "string", + // format: "binary", + // }, + // }) + // @ApiProduces("mp3") async downloadVoice( @Param("voiceId") voiceId, @Param("requestId") requestId: string, @@ -156,8 +158,8 @@ export class ExpertBlameController { return await this.expertBlameService.streamVoice(requestId, voiceId); } - @ApiParam({ name: "id" }) - @ApiOperation({ deprecated: true }) + // @ApiParam({ name: "id" }) + // @ApiOperation({ deprecated: true }) @Patch(":id/visit") async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) { return await this.expertBlameService.inPersonVisit(requestId, actor); diff --git a/src/expert-claim/expert-claim.controller.ts b/src/expert-claim/expert-claim.controller.ts index 0eab237..dbcb1f0 100644 --- a/src/expert-claim/expert-claim.controller.ts +++ b/src/expert-claim/expert-claim.controller.ts @@ -18,6 +18,7 @@ import { ApiQuery, ApiTags, ApiOperation, + ApiExcludeController, } from "@nestjs/swagger"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { RolesGuard } from "src/auth/guards/role.guard"; @@ -33,7 +34,8 @@ import { ParseJsonPipe } from "src/utils/pipes/parse-json.pipe"; import { ExpertClaimService } from "./expert-claim.service"; import { FactorValidationDto } from "./dto/factor-validation.dto"; -@ApiTags("expert-claim-panel") +@ApiExcludeController() +// @ApiTags("expert-claim-panel") @Controller("expert-claim") @ApiBearerAuth() @UseGuards(LocalActorAuthGuard, RolesGuard) @@ -41,20 +43,20 @@ import { FactorValidationDto } from "./dto/factor-validation.dto"; export class ExpertClaimController { constructor(private readonly expertClaimService: ExpertClaimService) {} - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get() async getAllClaimRequests(@CurrentUser() actor, @ClientKey() client) { return await this.expertClaimService.getClaimRequestsListForExpert(actor); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("/:id") - @ApiParam({ name: "id" }) - @ApiQuery({ - name: "updateDropPrice", - required: false, - description: "A stringified JSON object with price drop override values.", - }) + // @ApiParam({ name: "id" }) + // @ApiQuery({ + // name: "updateDropPrice", + // required: false, + // description: "A stringified JSON object with price drop override values.", + // }) async getClaimRequestPerId( @Param("id") id: string, @CurrentUser() currentUser, @@ -67,16 +69,16 @@ export class ExpertClaimController { ); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("imageRequired/:id") - @ApiParam({ name: "id" }) + // @ApiParam({ name: "id" }) async getImageRequired(@Param("id") id: string, @CurrentUser() currentUser) { return await this.expertClaimService.imageRequired(id, currentUser); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("required-documents/:id") - @ApiParam({ name: "id" }) + // @ApiParam({ name: "id" }) async getRequiredDocuments( @Param("id") id: string, @CurrentUser() currentUser, @@ -84,7 +86,7 @@ export class ExpertClaimController { return await this.expertClaimService.getRequiredDocuments(id, currentUser); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Put("lock/:id") async lockClaimRequest(@Param("id") requestId: string, @CurrentUser() actor) { return await this.expertClaimService.lockClaimRequest(requestId, actor); @@ -92,7 +94,7 @@ export class ExpertClaimController { @ApiOperation({ deprecated: true }) @Put("reply/submit/:id") - @ApiParam({ name: "id" }) + // @ApiParam({ name: "id" }) async submitReplyRequest( @Param("id") requestId, @Body() body: ClaimSubmitReplyDto, @@ -105,9 +107,9 @@ export class ExpertClaimController { ); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Put("resend/submit/:id") - @ApiParam({ name: "id" }) + // @ApiParam({ name: "id" }) async submitResend( @Param("id") requestId, @Body() body: ClaimSubmitResendDto, @@ -120,12 +122,12 @@ export class ExpertClaimController { ); } - @ApiQuery({ - name: "query", - enum: ["car-capture", "accident"], - required: true, - }) - @ApiOperation({ deprecated: true }) + // @ApiQuery({ + // name: "query", + //enum: ["car-capture", "accident"], + //required: true, + //}) + // @ApiOperation({ deprecated: true }) @Get("stream/:id/video") @Header("Accept-Ranges", "bytes") @Header("Content-Type", "video/mp4") @@ -143,7 +145,7 @@ export class ExpertClaimController { enum: ["car-capture", "accident"], required: true, }) - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("link/:id/video") @ApiParam({ name: "id" }) async getClaimVideoLink( @@ -153,17 +155,17 @@ export class ExpertClaimController { return this.expertClaimService.getVideoLink(id, query); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("stream/:id/voice") - @ApiParam({ name: "id" }) + // @ApiParam({ name: "id" }) async getClaimVoiceLink(@Param("id") id: string) { return await this.expertClaimService.getVoiceLink(id); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Patch("validate-factors/:claimId") - @ApiParam({ name: "claimId" }) - @ApiBody({ type: FactorValidationDto }) + // @ApiParam({ name: "claimId" }) + // @ApiBody({ type: FactorValidationDto }) @Roles(RoleEnum.DAMAGE_EXPERT) async validateFactors( @Param("claimId") claimId: string, @@ -177,16 +179,16 @@ export class ExpertClaimController { ); } - @ApiParam({ name: "id" }) - @ApiOperation({ deprecated: true }) + // @ApiParam({ name: "id" }) + // @ApiOperation({ deprecated: true }) @Patch(":id/visit") async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) { return await this.expertClaimService.inPersonVisit(requestId, actor); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("branches/:insuranceId") - @ApiParam({ name: "insuranceId" }) + // @ApiParam({ name: "insuranceId" }) async getInsuranceBranches(@Param("insuranceId") insuranceId: string) { return await this.expertClaimService.retrieveInsuranceBranches(insuranceId); } diff --git a/src/features/expert-initiated/expert-initiated.controller.ts b/src/features/expert-initiated/expert-initiated.controller.ts index 8f2a1e8..01cab49 100644 --- a/src/features/expert-initiated/expert-initiated.controller.ts +++ b/src/features/expert-initiated/expert-initiated.controller.ts @@ -1,8 +1,9 @@ import { Body, Controller, Post } from "@nestjs/common"; -import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiExcludeController, ApiTags } from "@nestjs/swagger"; import { FieldExpertLoginDto } from "./dtos/login.dto"; import { FieldExpertService } from "./expert-initiated.service"; +@ApiExcludeController() @Controller("expert-initiated") @ApiTags("Expert Initiated Flow (Field Expert)") @ApiBearerAuth() diff --git a/src/request-management/dto/run-inquiries-v3.dto.ts b/src/request-management/dto/run-inquiries-v3.dto.ts new file mode 100644 index 0000000..42d1a00 --- /dev/null +++ b/src/request-management/dto/run-inquiries-v3.dto.ts @@ -0,0 +1,91 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; +import { Type } from "class-transformer"; + +class PlateV3Dto { + @ApiProperty({ example: "44", description: "Left two digits" }) + @IsString() + @IsNotEmpty() + leftDigits: string; + + @ApiProperty({ example: "ب", description: "Center alphabet letter" }) + @IsString() + @IsNotEmpty() + centerAlphabet: string; + + @ApiProperty({ example: "111", description: "Center three digits" }) + @IsString() + @IsNotEmpty() + centerDigits: string; + + @ApiProperty({ example: "22", description: "Right two digits (Iran region code)" }) + @IsString() + @IsNotEmpty() + ir: string; +} + +/** + * Body for `POST run-inquiries/:requestId`. + * First call = guilty party (+ auto claim). Second call = damaged party (THIRD_PARTY only). + */ +export class RunInquiriesV3Dto { + @ApiProperty({ + type: PlateV3Dto, + description: "Plate segments — Tejarat block / third-party inquiry.", + }) + @ValidateNested() + @Type(() => PlateV3Dto) + plate: PlateV3Dto; + + @ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" }) + @IsString() + @IsNotEmpty() + nationalCodeOfInsurer: string; + + @ApiProperty({ example: "1234567890", description: "National code of the driver" }) + @IsString() + @IsNotEmpty() + nationalCodeOfDriver: string; + + @ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" }) + @IsBoolean() + driverIsInsurer: boolean; + + @ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" }) + insurerBirthday: number | string; + + @ApiPropertyOptional({ example: 13780624, description: "Driver birth date (Jalali). Required when driverIsInsurer is false." }) + @IsOptional() + driverBirthday?: number | string | null; + + @ApiPropertyOptional({ + example: "IR123456789012345678901234", + description: + "Sheba (IBAN). Required on the damaged party call (CAR_BODY first call; THIRD_PARTY second call).", + }) + @IsOptional() + @IsString() + sheba?: string; + + @ApiPropertyOptional({ + example: "123456789", + description: "Driver license (required when driverIsInsurer is false).", + }) + @IsOptional() + @IsString() + driverLicense?: string; + + @ApiPropertyOptional({ + example: "123456789", + description: "Insurer license (required when driverIsInsurer is true).", + }) + @IsOptional() + @IsString() + insurerLicense?: string; +} diff --git a/src/request-management/expert-initiated-blame-v3.mirror.controller.ts b/src/request-management/expert-initiated-blame-v3.mirror.controller.ts new file mode 100644 index 0000000..e0b1cc4 --- /dev/null +++ b/src/request-management/expert-initiated-blame-v3.mirror.controller.ts @@ -0,0 +1,584 @@ +import { extname } from "node:path"; +import { + Body, + Controller, + Get, + Param, + Patch, + 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 { 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 { PartyRole } from "./entities/schema/partyRole.enum"; +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"; + +/** + * V3 field-expert in-person flow (one party at a time, standard workflow steps). + * + * THIRD_PARTY: + * create → OTP/verify (guilty) → [car-body-form if CAR_BODY] → run-inquiries (guilty + claim) + * → location/description/voice → sign → OTP/verify (damaged) → run-inquiries (damaged) + * → location/description/voice → sign → accident-fields → docs → outer parts → other parts + * → capture-part → car-capture → upload-video (blame accident video, last) → WAITING_FOR_EXPERT + * + * CAR_BODY: same but single party — omit second-party OTP/inquiries/sign steps. + */ +@ApiTags("expert-initiated blame (mirror v3 in-person)") +@Controller("v3/expert-initiated/blame-request-management") +@ApiBearerAuth() +@UseGuards(LocalActorAuthGuard, RolesGuard) +@Roles(RoleEnum.FIELD_EXPERT) +export class ExpertInitiatedBlameV3MirrorController { + constructor( + private readonly requestManagementService: RequestManagementService, + private readonly claimRequestManagementService: ClaimRequestManagementService, + private readonly mediaPolicyService: MediaPolicyService, + ) {} + + @Post() + @ApiOperation({ summary: "Create IN_PERSON blame file" }) + @ApiBody({ type: CreateExpertInitiatedFileDto }) + async create( + @CurrentUser() expert: any, + @Body() dto: CreateExpertInitiatedFileDto, + ) { + return this.requestManagementService.createExpertInitiatedBlameV2(expert, { + ...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. Use this claim ID for capture-requirements, upload-document, and part-selection endpoints.", + }) + async getLinkedClaimId( + @Param("requestId") requestId: string, + @CurrentUser() expert: any, + ) { + return this.requestManagementService.getV3LinkedClaimForExpert( + expert, + requestId, + ); + } + + @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() expert: any, + @Param("requestId") requestId: string, + @Body() dto: SendPartyOtpDto, + ) { + return this.requestManagementService.sendPartyOtpV2(expert, 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() expert: any, + @Param("requestId") requestId: string, + @Body() dto: VerifyPartyOtpDto, + ) { + return this.requestManagementService.verifyPartyOtpV2(expert, requestId, dto); + } + + @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() expert: any, + ) { + return this.requestManagementService.carBodyAccidentTypeFormV3( + requestId, + body, + expert, + ); + } + + @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). " + + "Advances workflow to FIRST_INITIAL_FORM or SECOND_INITIAL_FORM.", + }) + async runInquiries( + @CurrentUser() expert: any, + @Param("requestId") requestId: string, + @Body() dto: RunInquiriesV3Dto, + ) { + return this.requestManagementService.runInquiriesV3(requestId, expert, dto); + } + + @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() expert: any, + @Body("partyRole") partyRole?: string, + ) { + return this.requestManagementService.addPartyLocationV3( + requestId, + expert, + 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() expert: any, + @Body("partyRole") partyRole?: string, + ) { + return this.requestManagementService.addPartyDescriptionV3( + requestId, + expert, + 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, `v3-expert-voice-${unique}${ex}`); + }, + }), + }), + ) + @Post("upload-voice/:requestId") + @ApiParam({ name: "requestId" }) + @ApiOperation({ summary: "Upload voice for current party" }) + async uploadVoice( + @Param("requestId") requestId: string, + @CurrentUser() expert: any, + @UploadedFile() voice: Express.Multer.File, + @Body("partyRole") partyRole?: string, + ) { + await this.mediaPolicyService.assertForBlame(voice, requestId, "voice"); + return this.requestManagementService.addPartyVoiceV3( + requestId, + expert, + voice, + partyRole, + ); + } + + @Put("sign/:requestId") + @ApiParam({ name: "requestId" }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Party on-site signature" }) + @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, `v3-expert-party-${unique}${ex}`); + }, + }), + }), + ) + async sign( + @Param("requestId") requestId: string, + @Body() body: { partyRole?: string; isAccept?: string | boolean }, + @CurrentUser() expert: 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( + expert, + requestId, + partyRole, + isAccept, + sign, + ); + } + + @Post("accident-fields/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: ExpertAccidentFieldsDto }) + @ApiOperation({ + summary: "Accident type / fields (after all signatures)", + description: + "Saves accident fields only. 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() expert: any, + ) { + return this.requestManagementService.expertAddAccidentFieldsForBlameV3( + expert, + requestId, + fields, + ); + } + + @Get("capture-requirements/:claimRequestId") + @ApiParam({ name: "claimRequestId" }) + @ApiOperation({ summary: "List required claim documents" }) + async getCaptureRequirements( + @Param("claimRequestId") claimRequestId: string, + @CurrentUser() expert: any, + ): Promise { + return this.claimRequestManagementService.getCaptureRequirementsV3( + claimRequestId, + expert.sub, + expert, + ); + } + + @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" }) + async uploadDocument( + @Param("claimRequestId") claimRequestId: string, + @Body() body: UploadRequiredDocumentV2Dto, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() expert: any, + ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); + return this.claimRequestManagementService.uploadRequiredDocumentV3( + claimRequestId, + body, + file, + expert.sub, + expert, + ); + } + + @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() expert: any, + ): Promise { + return this.claimRequestManagementService.selectOuterPartsV3( + claimRequestId, + body, + expert.sub, + expert, + ); + } + + @Patch("select-other-parts/:claimRequestId") + @ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" }) + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/claim-required-document", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + callback(null, `v3-other-parts-${unique}${ex}`); + }, + }), + }), + ) + @ApiOperation({ + summary: "Select other damaged parts (V3)", + description: + "Other parts only — sheba and national code were collected during 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"], + }, + file: { type: "string", format: "binary", description: "Optional car green card" }, + }, + }, + }) + async selectOtherParts( + @Param("claimRequestId") claimRequestId: string, + @Body() body: SelectOtherPartsV3Dto, + @CurrentUser() expert: any, + @UploadedFile() file?: Express.Multer.File, + ): Promise { + if (file) { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); + } + return this.claimRequestManagementService.selectOtherPartsV3( + claimRequestId, + body, + expert.sub, + expert, + file, + ); + } + + @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, `v3-capture-${unique}${ex}`); + }, + }), + }), + ) + @ApiOperation({ + summary: "Field — 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() expert: any, + ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); + return this.claimRequestManagementService.capturePartV3( + claimRequestId, + body, + file, + expert.sub, + expert, + ); + } + + @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, `v3-claim-video-${unique}${ex}`); + }, + }), + }), + ) + @ApiBody({ + schema: { + type: "object", + required: ["file"], + properties: { file: { type: "string", format: "binary" } }, + }, + }) + @ApiOperation({ + summary: "Field — 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() expert: any, + ) { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video"); + return this.claimRequestManagementService.setVideoCaptureV3( + claimRequestId, + file, + expert.sub, + 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, `v3-blame-accident-${unique}${ex}`); + }, + }), + }), + ) + @Post("upload-video/:requestId") + @ApiParam({ name: "requestId" }) + @ApiOperation({ + summary: "Blame accident video (final step)", + description: + "Last step of the V3 flow. Requires claim walk-around video and completed capture. Sets blame status to WAITING_FOR_EXPERT.", + }) + async uploadBlameVideo( + @Param("requestId") requestId: string, + @CurrentUser() expert: any, + @UploadedFile() file: Express.Multer.File, + ) { + await this.mediaPolicyService.assertForBlame(file, requestId, "video"); + return this.requestManagementService.expertUploadBlameVideoV3( + expert, + requestId, + file, + ); + } +} diff --git a/src/request-management/expert-initiated.controller.ts b/src/request-management/expert-initiated.controller.ts index 1d52401..8a1f353 100644 --- a/src/request-management/expert-initiated.controller.ts +++ b/src/request-management/expert-initiated.controller.ts @@ -21,6 +21,7 @@ import { ApiOperation, ApiResponse, ApiConsumes, + ApiExcludeController, } from "@nestjs/swagger"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { RolesGuard } from "src/auth/guards/role.guard"; @@ -36,7 +37,8 @@ import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto"; import { ExpertCompleteClaimDataDto } from "./dto/expert-complete-claim-data.dto"; import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service"; -@ApiTags("expert-initiated-blame") +@ApiExcludeController() +// @ApiTags("expert-initiated-blame") @Controller("expert-initiated-blame") @ApiBearerAuth() @UseGuards(LocalActorAuthGuard, RolesGuard) @@ -47,34 +49,26 @@ export class ExpertInitiatedController { private readonly claimRequestManagementService: ClaimRequestManagementService, ) {} - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Get("my-files") - @ApiOperation({ - summary: "List my expert-initiated files", - description: - "Returns all blame files created by the current field expert (their panel).", - }) - @ApiResponse({ status: 200, description: "List of expert-initiated files" }) + // @ApiOperation({ + // summary: "List my expert-initiated files", + // description: + // "Returns all blame files created by the current field expert (their panel).", + // }) + // @ApiResponse({ status: 200, description: "List of expert-initiated files" }) async getMyFiles(@CurrentUser() expert: any) { - return await this.requestManagementService.getMyExpertInitiatedFiles( - expert, - ); + return await this.requestManagementService.getMyExpertInitiatedFiles(expert); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Post("complete-blame-data/:requestId") - @ApiOperation({ - summary: "Submit all blame-needed data", - description: - "Single endpoint to complete blame form: send THIRD_PARTY or CAR_BODY form data according to file type. " + - "Video and voice can be uploaded separately.", - }) - @ApiParam({ name: "requestId", description: "ID of the expert-initiated file" }) - @ApiBody({ - description: - "Send ExpertCompleteThirdPartyFormDto for THIRD_PARTY files or ExpertCompleteCarBodyFormDto for CAR_BODY files (according to file type).", - }) - @ApiResponse({ status: 200, description: "Blame form completed successfully" }) + // @ApiParam({ name: "requestId", description: "ID of the expert-initiated file" }) + // @ApiBody({ + // description: + // "Send ExpertCompleteThirdPartyFormDto for THIRD_PARTY files or ExpertCompleteCarBodyFormDto for CAR_BODY files (according to file type).", + // }) + // @ApiResponse({ status: 200, description: "Blame form completed successfully" }) async completeBlameData( @CurrentUser() expert: any, @Param("requestId") requestId: string, @@ -87,20 +81,20 @@ export class ExpertInitiatedController { ); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Post("complete-claim-data/:claimRequestId") - @ApiOperation({ - summary: "Submit claim-needed data", - description: - "Field expert fills claim data for expert-initiated IN_PERSON files (car part damage, sheba, national code, other parts). " + - "Only the initiating expert can call this.", - }) - @ApiParam({ - name: "claimRequestId", - description: "ID of the claim file (created from the expert-initiated blame file)", - }) - @ApiBody({ type: ExpertCompleteClaimDataDto }) - @ApiResponse({ status: 200, description: "Claim data updated successfully" }) + // @ApiOperation({ + // summary: "Submit claim-needed data", + // description: + // "Field expert fills claim data for expert-initiated IN_PERSON files (car part damage, sheba, national code, other parts). " + + // "Only the initiating expert can call this.", + // }) + // @ApiParam({ + // name: "claimRequestId", + // description: "ID of the claim file (created from the expert-initiated blame file)", + // }) + // @ApiBody({ type: ExpertCompleteClaimDataDto }) + // @ApiResponse({ status: 200, description: "Claim data updated successfully" }) async completeClaimData( @CurrentUser() expert: any, @Param("claimRequestId") claimRequestId: string, @@ -141,24 +135,11 @@ export class ExpertInitiatedController { // ); // } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Post("complete-form-third-party/:requestId") - @ApiOperation({ - summary: "Expert completes all information for IN_PERSON THIRD_PARTY file", - description: - "Expert fills all information at once for IN_PERSON THIRD_PARTY files. " + - "Includes first party, second party, and guilty party selection. " + - "Video and voice uploads are handled separately.", - }) - @ApiParam({ - name: "requestId", - description: "ID of the expert-initiated IN_PERSON THIRD_PARTY file", - }) - @ApiBody({ type: ExpertCompleteThirdPartyFormDto }) - @ApiResponse({ - status: 200, - description: "Form completed successfully. File is now ready for user signatures.", - }) + // @ApiParam({ name: "requestId" }) + // @ApiBody({ type: ExpertCompleteThirdPartyFormDto }) + // @ApiResponse({ status: 200, description: "Form completed successfully. File is now ready for user signatures." }) async completeThirdPartyForm( @CurrentUser() expert: any, @Param("requestId") requestId: string, @@ -171,24 +152,15 @@ export class ExpertInitiatedController { ); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Post("complete-form-car-body/:requestId") - @ApiOperation({ - summary: "Expert completes all information for IN_PERSON CAR_BODY file", - description: - "Expert fills all information at once for IN_PERSON CAR_BODY files. " + - "Includes first party data and carBodyForm. " + - "Video and voice uploads are handled separately.", - }) - @ApiParam({ - name: "requestId", - description: "ID of the expert-initiated IN_PERSON CAR_BODY file", - }) - @ApiBody({ type: ExpertCompleteCarBodyFormDto }) - @ApiResponse({ - status: 200, - description: "Form completed successfully. File is now ready for user signature.", - }) + // @ApiOperation({ + // summary: "Expert completes all information for IN_PERSON CAR_BODY file", + // description: + // "Expert fills all information at once for IN_PERSON CAR_BODY files. " + + // "Includes first party data and carBodyForm. " + + // "Video and voice uploads are handled separately.", + // }) async completeCarBodyForm( @CurrentUser() expert: any, @Param("requestId") requestId: string, @@ -201,31 +173,16 @@ export class ExpertInitiatedController { ); } - @ApiOperation({ deprecated: true }) - @Post("upload-video/:requestId") - @ApiOperation({ - summary: "Expert uploads video for expert-initiated file", - description: - "Expert uploads a single video file for the entire blame file. " + - "Only one video is needed per file (not per party). " + - "Only works for expert-initiated files.", - }) - @ApiParam({ - name: "requestId", - description: "ID of the expert-initiated file", - }) - @ApiConsumes("multipart/form-data") - @ApiBody({ - schema: { - type: "object", - properties: { - file: { - type: "string", - format: "binary", - }, - }, - }, - }) + // @ApiOperation({ deprecated: true }) + @Post("upload-video/:requestId") + // @ApiParam({ name: "requestId" }) + // @ApiConsumes("multipart/form-data") + // @ApiBody({ + // schema: { + // type: "object", + // properties: { file: { type: "string", format: "binary" } }, + // }, + // }) @UseInterceptors( FileInterceptor("file", { limits: { @@ -242,10 +199,10 @@ export class ExpertInitiatedController { }), }), ) - @ApiResponse({ - status: 200, - description: "Video uploaded successfully", - }) + // @ApiResponse({ + // status: 200, + // description: "Video uploaded successfully", + // }) async uploadVideo( @CurrentUser() expert: any, @Param("requestId") requestId: string, @@ -258,31 +215,28 @@ export class ExpertInitiatedController { ); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Post("upload-voice/:requestId") - @ApiOperation({ - summary: "Expert uploads voice for expert-initiated file", - description: - "Expert uploads a single voice file for the entire blame file. " + - "Only one voice is needed per file (not per party). " + - "Only works for expert-initiated files.", - }) - @ApiParam({ - name: "requestId", - description: "ID of the expert-initiated file", - }) - @ApiConsumes("multipart/form-data") - @ApiBody({ - schema: { - type: "object", - properties: { - voice: { - type: "string", - format: "binary", - }, - }, - }, - }) + // @ApiOperation({ + // summary: "Expert uploads voice for expert-initiated file", + // description: + // "Expert uploads a single voice file for the entire blame file. " + + // "Only one voice is needed per file (not per party). " + + // "Only works for expert-initiated files.", + // }) + // @ApiParam({ name: "requestId" }) + // @ApiConsumes("multipart/form-data") + // @ApiBody({ + // schema: { + // type: "object", + // properties: { + // voice: { + // type: "string", + // format: "binary", + // }, + // }, + // }, + // }) @UseInterceptors( FileInterceptor("voice", { limits: { @@ -300,10 +254,7 @@ export class ExpertInitiatedController { }), }), ) - @ApiResponse({ - status: 200, - description: "Voice uploaded successfully", - }) + // @ApiResponse({ status: 200, description: "Voice uploaded successfully" }) async uploadVoice( @CurrentUser() expert: any, @Param("requestId") requestId: string, @@ -316,24 +267,18 @@ export class ExpertInitiatedController { ); } - @ApiOperation({ deprecated: true }) + // @ApiOperation({ deprecated: true }) @Post("add-accident-fields/:requestId") - @ApiOperation({ - summary: "Expert adds accident fields to expert-initiated file", - description: - "Expert adds accident way, reason, and type fields to an expert-initiated file. " + - "Can be used for both IN_PERSON and LINK-based files. " + - "If expertSubmitReply already exists, updates the fields. Otherwise, creates a new expertSubmitReply.", - }) - @ApiParam({ - name: "requestId", - description: "ID of the expert-initiated file", - }) - @ApiBody({ type: ExpertAccidentFieldsDto }) - @ApiResponse({ - status: 200, - description: "Accident fields added successfully", - }) + // @ApiOperation({ + // summary: "Expert adds accident fields to expert-initiated file", + // description: + // "Expert adds accident way, reason, and type fields to an expert-initiated file. " + + // "Can be used for both IN_PERSON and LINK-based files. " + + // "If expertSubmitReply already exists, updates the fields. Otherwise, creates a new expertSubmitReply.", + // }) + // @ApiParam({ name: "requestId" }) + // @ApiBody({ type: ExpertAccidentFieldsDto }) + // @ApiResponse({ status: 200, description: "Accident fields added successfully" }) async addAccidentFields( @CurrentUser() expert: any, @Param("requestId") requestId: string, diff --git a/src/request-management/expert-initiated.v2.controller.ts b/src/request-management/expert-initiated.v2.controller.ts index cf3236f..30ccd68 100644 --- a/src/request-management/expert-initiated.v2.controller.ts +++ b/src/request-management/expert-initiated.v2.controller.ts @@ -46,7 +46,7 @@ import { PartyRole } from "./entities/schema/partyRole.enum"; * Field experts create files via LINK (send link to user) or IN_PERSON (expert fills on-site). * Only the initiating field expert can see/review these files. */ -@ApiTags("expert-initiated-blame (v2)") +// @ApiTags("expert-initiated-blame (v2)") @Controller("v2/expert-initiated-blame") @ApiBearerAuth() @UseGuards(LocalActorAuthGuard, RolesGuard) @@ -59,23 +59,23 @@ export class ExpertInitiatedV2Controller { ) {} @Get("my-files") - @ApiOperation({ - summary: "[V2] List my expert-initiated blame files", - description: - "Returns all BlameRequest (workflow) files created by the current field expert.", - }) - @ApiResponse({ status: 200, description: "List of expert-initiated files" }) + // @ApiOperation({ + // summary: "[V2] List my expert-initiated blame files", + // description: + // "Returns all BlameRequest (workflow) files created by the current field expert.", + // }) + // @ApiResponse({ status: 200, description: "List of expert-initiated files" }) async getMyFilesV2(@CurrentUser() expert: any) { return this.requestManagementService.getMyExpertInitiatedFilesV2(expert); } @Get("blame/:requestId") - @ApiOperation({ - summary: "[V2] Get one expert-initiated blame request", - description: "Returns a single blame request. Only the initiating field expert can access.", - }) - @ApiParam({ name: "requestId", description: "Blame request ID" }) - @ApiResponse({ status: 200, description: "Blame request" }) + // @ApiOperation({ + // summary: "[V2] Get one expert-initiated blame request", + // description: "Returns a single blame request. Only the initiating field expert can access.", + // }) + // @ApiParam({ name: "requestId", description: "Blame request ID" }) + // @ApiResponse({ status: 200, description: "Blame request" }) async getBlameRequestV2( @Param("requestId") requestId: string, @CurrentUser() expert: any, @@ -84,68 +84,45 @@ export class ExpertInitiatedV2Controller { } @Post("create") - @ApiOperation({ - summary: "[V2] Create expert-initiated blame file", - description: - "Creates a BlameRequest with workflow. LINK: parties identified by phone, expert sends link. IN_PERSON: expert fills form later.", - }) - @ApiBody({ - type: CreateExpertInitiatedFileDto, - examples: { - thirdPartyInPerson: { - summary: "Third-party, IN_PERSON (both parties)", - description: - "Expert fills form on-site for both parties.", - value: { - type: "THIRD_PARTY", - creationMethod: "IN_PERSON", - firstPartyPhoneNumber: "09123456789", - secondPartyPhoneNumber: "09187654321", - }, - }, - thirdPartyLink: { - summary: "Third-party, LINK", - description: - "Expert sends link to first and second party by phone.", - value: { - type: "THIRD_PARTY", - creationMethod: "LINK", - firstPartyPhoneNumber: "09123456789", - }, - }, - carBodyInPerson: { - summary: "Car-body, IN_PERSON", - description: "Expert fills form on-site for single party (car body).", - value: { - type: "CAR_BODY", - creationMethod: "IN_PERSON", - firstPartyPhoneNumber: "09123456789", - }, - }, - carBodyLink: { - summary: "Car-body, LINK", - description: - "Expert sends link to party by phone. Only first party phone needed.", - value: { - type: "CAR_BODY", - creationMethod: "LINK", - firstPartyPhoneNumber: "09123456789", - }, - }, - }, - }) - @ApiResponse({ - status: 201, - description: "File created", - schema: { - type: "object", - properties: { - requestId: { type: "string" }, - publicId: { type: "string" }, - linkUrl: { type: "string", description: "Present for LINK method" }, - }, - }, - }) + // @ApiOperation({ + // summary: "[V2] Create expert-initiated blame file", + // description: + // "Creates a BlameRequest with workflow. LINK: parties identified by phone, expert sends link. IN_PERSON: expert fills form later.", + // }) + // @ApiBody({ + // type: CreateExpertInitiatedFileDto, + // examples: { + // thirdPartyInPerson: { + // summary: "Third-party, IN_PERSON (both parties)", + // description: + // "Expert fills form on-site for both parties.", + // value: { + // type: "THIRD_PARTY", + // creationMethod: "LINK", + // firstPartyPhoneNumber: "09123456789", + // }, + // }, + // carBodyInPerson: { + // summary: "Car-body, IN_PERSON", + // description: "Expert fills form on-site for single party (car body).", + // value: { + // type: "CAR_BODY", + // creationMethod: "IN_PERSON", + // firstPartyPhoneNumber: "09123456789", + // }, + // }, + // carBodyLink: { + // summary: "Car-body, LINK", + // description: + // "Expert sends link to party by phone. Only first party phone needed.", + // value: { + // type: "CAR_BODY", + // creationMethod: "LINK", + // firstPartyPhoneNumber: "09123456789", + // }, + // }, + // }, + // }) async createV2( @CurrentUser() expert: any, @Body() dto: CreateExpertInitiatedFileDto, @@ -157,13 +134,13 @@ export class ExpertInitiatedV2Controller { } @Post("send-link/:requestId") - @ApiOperation({ - summary: "[V2] Send blame link to first party (LINK)", - description: - "For expert-initiated LINK files only. Provide the first party phone number; the service stores it, registers the user if needed, and sends the invite link via SMS.", - }) - @ApiParam({ name: "requestId", description: "Blame request ID" }) - @ApiBody({ type: SendExpertInitiatedLinkV2Dto }) + // @ApiOperation({ + // summary: "[V2] Send blame link to first party (LINK)", + // description: + // "For expert-initiated LINK files only. Provide the first party phone number; the service stores it, registers the user if needed, and sends the invite link via SMS.", + // }) + // @ApiParam({ name: "requestId", description: "Blame request ID" }) + // @ApiBody({ type: SendExpertInitiatedLinkV2Dto }) async sendLinkV2( @CurrentUser() expert: any, @Param("requestId") requestId: string, @@ -173,14 +150,14 @@ export class ExpertInitiatedV2Controller { } @Post("send-party-otps/:requestId") - @ApiOperation({ - summary: "[V2] Send OTP to party/parties (IN_PERSON)", - description: - "Sends OTP via SMS to first party (and second party for THIRD_PARTY) using the same flow as /user/send-otp. Parties receive the code; collect it from them and call verify-party-otps. Call this before filling the blame form.", - }) - @ApiParam({ name: "requestId", description: "Blame request ID" }) - @ApiBody({ type: SendPartyOtpsDto }) - @ApiResponse({ status: 200, description: "OTP(s) sent; collect codes and call verify-party-otps" }) + // @ApiOperation({ + // summary: "[V2] Send OTP to party/parties (IN_PERSON)", + // description: + // "Sends OTP via SMS to first party (and second party for THIRD_PARTY) using the same flow as /user/send-otp. Parties receive the code; collect it from them and call verify-party-otps. Call this before filling the blame form.", + // }) + // @ApiParam({ name: "requestId", description: "Blame request ID" }) + // @ApiBody({ type: SendPartyOtpsDto }) + // @ApiResponse({ status: 200, description: "OTP(s) sent; collect codes and call verify-party-otps" }) async sendPartyOtpsV2( @CurrentUser() expert: any, @Param("requestId") requestId: string, @@ -190,14 +167,14 @@ export class ExpertInitiatedV2Controller { } @Post("verify-party-otps/:requestId") - @ApiOperation({ - summary: "[V2] Verify party OTPs (IN_PERSON)", - description: - "After send-party-otps, parties receive SMS. They tell you the code; submit it here. Required before complete-blame-data.", - }) - @ApiParam({ name: "requestId", description: "Blame request ID" }) - @ApiBody({ type: VerifyPartyOtpsDto }) - @ApiResponse({ status: 200, description: "OTPs verified; expert can proceed to complete-blame-data" }) + // @ApiOperation({ + // summary: "[V2] Verify party OTPs (IN_PERSON)", + // description: + // "After send-party-otps, parties receive SMS. They tell you the code; submit it here. Required before complete-blame-data.", + // }) + // @ApiParam({ name: "requestId", description: "Blame request ID" }) + // @ApiBody({ type: VerifyPartyOtpsDto }) + // @ApiResponse({ status: 200, description: "OTPs verified; expert can proceed to complete-blame-data" }) async verifyPartyOtpsV2( @CurrentUser() expert: any, @Param("requestId") requestId: string, @@ -207,99 +184,99 @@ export class ExpertInitiatedV2Controller { } @Post("complete-blame-data/:requestId") - @ApiOperation({ - summary: "[V2] Submit all blame data (IN_PERSON)", - description: - "For IN_PERSON files only. Send THIRD_PARTY or CAR_BODY form EXCEPT location. Use one shared expertDescription (not per-party descriptions). After this, call add-locations once to submit scene lat/lon and move workflow to WAITING_FOR_SIGNATURES.", - }) - @ApiParam({ name: "requestId", description: "Blame request ID" }) - @ApiBody({ - description: "Choose THIRD_PARTY or CAR_BODY example according to the file type. IN_PERSON v2 uses one shared expertDescription for the scene.", - examples: { - THIRD_PARTY: { - summary: "THIRD_PARTY", - description: "Use when the blame file type is THIRD_PARTY", - value: { - firstPartyPhoneNumber: "09123456789", - firstPartyInitialForm: { - expertOpinion: false, - imDamaged: false, - imGuilty: true, - }, - firstPartyPlate: { - nationalCodeOfInsurer: "", - nationalCodeOfDriver: "", - insurerLicense: "", - driverLicense: "", - plate: { leftDigits: 12, centerAlphabet: "الف", centerDigits: 345, ir: 22 }, - driverIsInsurer: true, - isNewCar: false, - userNoCertificate: false, - insurerBirthday: 13770624, - driverBirthday: "1370-01-01", - }, - expertDescription: { desc: "توضیح حادثه کارشناس (مشترک برای صحنه)" }, - secondParty: { - phoneNumber: "09187654321", - initialForm: { - expertOpinion: false, - imDamaged: true, - imGuilty: false, - }, - plate: { - nationalCodeOfInsurer: "", - nationalCodeOfDriver: "", - insurerLicense: "", - driverLicense: "", - plate: { leftDigits: 91, centerAlphabet: "ن", centerDigits: 174, ir: 79 }, - driverIsInsurer: true, - isNewCar: false, - userNoCertificate: false, - insurerBirthday: 13700720, - driverBirthday: "1370-01-01", - }, - }, - guiltyPartyPhoneNumber: "09123456789", - }, - }, - CAR_BODY: { - summary: "CAR_BODY", - description: "Use when the blame file type is CAR_BODY", - value: { - firstPartyPhoneNumber: "09123456789", - firstPartyInitialForm: { - expertOpinion: false, - imDamaged: false, - imGuilty: true, - }, - carBodyForm: { car: true, object: false }, - firstPartyPlate: { - plateId: "", - nationalCodeOfInsurer: "", - nationalCodeOfDriver: "", - insurerLicense: "", - driverLicense: "", - plate: { leftDigits: 12, centerAlphabet: "الف", centerDigits: 345, ir: 22 }, - driverIsInsurer: true, - isNewCar: false, - userNoCertificate: false, - insurerBirthday: 1370, - driverBirthday: "1370-01-01", - }, - expertDescription: { - desc: "توضیح حادثه", - accidentDate: "2025-01-15", - accidentTime: "14:30", - weatherCondition: "صاف", - roadCondition: "خشک", - lightCondition: "روز", - }, - }, - }, - }, - schema: { type: "object" }, - }) - @ApiResponse({ status: 200, description: "Blame form completed; next: add-locations" }) + // @ApiOperation({ + // summary: "[V2] Submit all blame data (IN_PERSON)", + // description: + // "For IN_PERSON files only. Send THIRD_PARTY or CAR_BODY form EXCEPT location. Use one shared expertDescription (not per-party descriptions). After this, call add-locations once to submit scene lat/lon and move workflow to WAITING_FOR_SIGNATURES.", + // }) + // @ApiParam({ name: "requestId", description: "Blame request ID" }) + // @ApiBody({ + // description: "Choose THIRD_PARTY or CAR_BODY example according to the file type. IN_PERSON v2 uses one shared expertDescription for the scene.", + // examples: { + // THIRD_PARTY: { + // summary: "THIRD_PARTY", + // description: "Use when the blame file type is THIRD_PARTY", + // value: { + // firstPartyPhoneNumber: "09123456789", + // firstPartyInitialForm: { + // expertOpinion: false, + // imDamaged: false, + // imGuilty: true, + // }, + // firstPartyPlate: { + // nationalCodeOfInsurer: "", + // nationalCodeOfDriver: "", + // insurerLicense: "", + // driverLicense: "", + // plate: { leftDigits: 12, centerAlphabet: "الف", centerDigits: 345, ir: 22 }, + // driverIsInsurer: true, + // isNewCar: false, + // userNoCertificate: false, + // insurerBirthday: 13770624, + // driverBirthday: "1370-01-01", + // }, + // expertDescription: { desc: "توضیح حادثه کارشناس (مشترک برای صحنه)" }, + // secondParty: { + // phoneNumber: "09187654321", + // initialForm: { + // expertOpinion: false, + // imDamaged: true, + // imGuilty: false, + // }, + // plate: { + // nationalCodeOfInsurer: "", + // nationalCodeOfDriver: "", + // insurerLicense: "", + // driverLicense: "", + // plate: { leftDigits: 91, centerAlphabet: "ن", centerDigits: 174, ir: 79 }, + // driverIsInsurer: true, + // isNewCar: false, + // userNoCertificate: false, + // insurerBirthday: 13700720, + // driverBirthday: "1370-01-01", + // }, + // }, + // guiltyPartyPhoneNumber: "09123456789", + // }, + // }, + // CAR_BODY: { + // summary: "CAR_BODY", + // description: "Use when the blame file type is CAR_BODY", + // value: { + // firstPartyPhoneNumber: "09123456789", + // firstPartyInitialForm: { + // expertOpinion: false, + // imDamaged: false, + // imGuilty: true, + // }, + // carBodyForm: { car: true, object: false }, + // firstPartyPlate: { + // plateId: "", + // nationalCodeOfInsurer: "", + // nationalCodeOfDriver: "", + // insurerLicense: "", + // driverLicense: "", + // plate: { leftDigits: 12, centerAlphabet: "الف", centerDigits: 345, ir: 22 }, + // driverIsInsurer: true, + // isNewCar: false, + // userNoCertificate: false, + // insurerBirthday: 1370, + // driverBirthday: "1370-01-01", + // }, + // expertDescription: { + // desc: "توضیح حادثه", + // accidentDate: "2025-01-15", + // accidentTime: "14:30", + // weatherCondition: "صاف", + // roadCondition: "خشک", + // lightCondition: "روز", + // }, + // }, + // }, + // }, + // schema: { type: "object" }, + // }) + // @ApiResponse({ status: 200, description: "Blame form completed; next: add-locations" }) async completeBlameDataV2( @CurrentUser() expert: any, @Param("requestId") requestId: string, @@ -313,24 +290,24 @@ export class ExpertInitiatedV2Controller { } @Post("add-locations/:requestId") - @ApiOperation({ - summary: "[V2] Submit one scene location for expert-initiated IN_PERSON blame", - description: - "Submit one scene location after complete-blame-data. This transitions workflow to WAITING_FOR_SIGNATURES.", - }) - @ApiParam({ name: "requestId", description: "Blame request ID" }) - @ApiBody({ - type: ExpertCompleteLocationV2Dto, - examples: { - scene: { - summary: "One scene location (all IN_PERSON types)", - value: { - location: { lat: 35.6892, lon: 51.389 }, - }, - }, - }, - }) - @ApiResponse({ status: 200, description: "Locations saved; next: upload party signature(s)" }) + // @ApiOperation({ + // summary: "[V2] Submit one scene location for expert-initiated IN_PERSON blame", + // description: + // "Submit one scene location after complete-blame-data. This transitions workflow to WAITING_FOR_SIGNATURES.", + // }) + // @ApiParam({ name: "requestId", description: "Blame request ID" }) + // @ApiBody({ + // type: ExpertCompleteLocationV2Dto, + // examples: { + // scene: { + // summary: "One scene location (all IN_PERSON types)", + // value: { + // location: { lat: 35.6892, lon: 51.389 }, + // }, + // }, + // }, + // }) + // @ApiResponse({ status: 200, description: "Locations saved; next: upload party signature(s)" }) async addLocationsV2( @CurrentUser() expert: any, @Param("requestId") requestId: string, @@ -344,17 +321,17 @@ export class ExpertInitiatedV2Controller { } @Post("upload-video/:requestId") - @ApiOperation({ - summary: "[V2] Expert uploads video for expert-initiated BlameRequest", - }) - @ApiParam({ name: "requestId", description: "Blame request ID" }) - @ApiConsumes("multipart/form-data") - @ApiBody({ - schema: { - type: "object", - properties: { file: { type: "string", format: "binary" } }, - }, - }) + // @ApiOperation({ + // summary: "[V2] Expert uploads video for expert-initiated BlameRequest", + // }) + // @ApiParam({ name: "requestId", description: "Blame request ID" }) + // @ApiConsumes("multipart/form-data") + // @ApiBody({ + // schema: { + // type: "object", + // properties: { file: { type: "string", format: "binary" } }, + // }, + // }) @UseInterceptors( FileInterceptor("file", { limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, @@ -368,7 +345,7 @@ export class ExpertInitiatedV2Controller { }), }), ) - @ApiResponse({ status: 200, description: "Video uploaded" }) + // @ApiResponse({ status: 200, description: "Video uploaded" }) async uploadVideoV2( @CurrentUser() expert: any, @Param("requestId") requestId: string, @@ -383,17 +360,17 @@ export class ExpertInitiatedV2Controller { } @Post("upload-voice/:requestId") - @ApiOperation({ - summary: "[V2] Expert uploads voice for expert-initiated BlameRequest", - }) - @ApiParam({ name: "requestId", description: "Blame request ID" }) - @ApiConsumes("multipart/form-data") - @ApiBody({ - schema: { - type: "object", - properties: { voice: { type: "string", format: "binary" } }, - }, - }) + // @ApiOperation({ + // summary: "[V2] Expert uploads voice for expert-initiated BlameRequest", + // }) + // @ApiParam({ name: "requestId", description: "Blame request ID" }) + // @ApiConsumes("multipart/form-data") + // @ApiBody({ + // schema: { + // type: "object", + // properties: { voice: { type: "string", format: "binary" } }, + // }, + // }) @UseInterceptors( FileInterceptor("voice", { limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, @@ -408,7 +385,7 @@ export class ExpertInitiatedV2Controller { }), }), ) - @ApiResponse({ status: 200, description: "Voice uploaded" }) + // @ApiResponse({ status: 200, description: "Voice uploaded" }) async uploadVoiceV2( @CurrentUser() expert: any, @Param("requestId") requestId: string, @@ -423,12 +400,12 @@ export class ExpertInitiatedV2Controller { } @Post("add-accident-fields/:requestId") - @ApiOperation({ - summary: "[V2] Expert adds accident fields to expert-initiated BlameRequest", - }) - @ApiParam({ name: "requestId", description: "Blame request ID" }) - @ApiBody({ type: ExpertAccidentFieldsDto }) - @ApiResponse({ status: 200, description: "Accident fields added" }) + // @ApiOperation({ + // summary: "[V2] Expert adds accident fields to expert-initiated BlameRequest", + // }) + // @ApiParam({ name: "requestId", description: "Blame request ID" }) + // @ApiBody({ type: ExpertAccidentFieldsDto }) + // @ApiResponse({ status: 200, description: "Accident fields added" }) async addAccidentFieldsV2( @CurrentUser() expert: any, @Param("requestId") requestId: string, @@ -442,24 +419,24 @@ export class ExpertInitiatedV2Controller { } @Post("upload-party-signature/:requestId") - @ApiOperation({ - summary: "[V2] Expert uploads party signature (IN_PERSON)", - description: - "For IN_PERSON only. Upload a party's signature collected on-site. CAR_BODY: use partyRole FIRST once. THIRD_PARTY: upload FIRST then SECOND. When all required parties have signed, blame case completes.", - }) - @ApiParam({ name: "requestId", description: "Blame request ID" }) - @ApiConsumes("multipart/form-data") - @ApiBody({ - schema: { - type: "object", - required: ["partyRole", "sign"], - properties: { - partyRole: { type: "string", enum: ["FIRST", "SECOND"] }, - isAccept: { type: "boolean", default: true }, - sign: { type: "string", format: "binary" }, - }, - }, - }) + // @ApiOperation({ + // summary: "[V2] Expert uploads party signature (IN_PERSON)", + // description: + // "For IN_PERSON only. Upload a party's signature collected on-site. CAR_BODY: use partyRole FIRST once. THIRD_PARTY: upload FIRST then SECOND. When all required parties have signed, blame case completes.", + // }) + // @ApiParam({ name: "requestId", description: "Blame request ID" }) + // @ApiConsumes("multipart/form-data") + // @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 }, @@ -473,7 +450,7 @@ export class ExpertInitiatedV2Controller { }), }), ) - @ApiResponse({ status: 200, description: "Signature recorded" }) + // @ApiResponse({ status: 200, description: "Signature recorded" }) async uploadPartySignatureV2( @CurrentUser() expert: any, @Param("requestId") requestId: string, @@ -496,13 +473,13 @@ export class ExpertInitiatedV2Controller { } @Post("create-claim-from-blame/:blameRequestId") - @ApiOperation({ - summary: "[V2] Create claim from expert-initiated IN_PERSON blame", - description: - "Field expert creates a claim on behalf of the damaged party. Blame must be COMPLETED (signatures collected). Then use claim v2 endpoints to fill parts, documents, and captures.", - }) - @ApiParam({ name: "blameRequestId", description: "Completed blame request ID" }) - @ApiResponse({ status: 201, description: "Claim created" }) + // @ApiOperation({ + // summary: "[V2] Create claim from expert-initiated IN_PERSON blame", + // description: + // "Field expert creates a claim on behalf of the damaged party. Blame must be COMPLETED (signatures collected). Then use claim v2 endpoints to fill parts, documents, and captures.", + // }) + // @ApiParam({ name: "blameRequestId", description: "Completed blame request ID" }) + // @ApiResponse({ status: 201, description: "Claim created" }) async createClaimFromBlame( @CurrentUser() expert: any, @Param("blameRequestId") blameRequestId: string, @@ -514,12 +491,12 @@ export class ExpertInitiatedV2Controller { } @Post("complete-claim-data/:claimRequestId") - @ApiOperation({ - summary: "Submit claim-needed data (expert-initiated IN_PERSON)", - }) - @ApiParam({ name: "claimRequestId", description: "Claim file ID" }) - @ApiBody({ type: ExpertCompleteClaimDataDto }) - @ApiResponse({ status: 200, description: "Claim data updated" }) + // @ApiOperation({ + // summary: "Submit claim-needed data (expert-initiated IN_PERSON)", + // }) + // @ApiParam({ name: "claimRequestId", description: "Claim file ID" }) + // @ApiBody({ type: ExpertCompleteClaimDataDto }) + // @ApiResponse({ status: 200, description: "Claim data updated" }) async completeClaimData( @CurrentUser() expert: any, @Param("claimRequestId") claimRequestId: string, diff --git a/src/request-management/registrar-initiated.controller.ts b/src/request-management/registrar-initiated.controller.ts index 1cf03ef..e171f42 100644 --- a/src/request-management/registrar-initiated.controller.ts +++ b/src/request-management/registrar-initiated.controller.ts @@ -2,7 +2,7 @@ import { extname } from "node:path"; import { Body, Controller, Get, Param, Post, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common"; import { FileInterceptor } from "@nestjs/platform-express"; import { diskStorage } from "multer"; -import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiTags, ApiExcludeController } from "@nestjs/swagger"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { RolesGuard } from "src/auth/guards/role.guard"; import { CurrentUser } from "src/decorators/user.decorator"; @@ -16,7 +16,8 @@ import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto"; import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto"; import { PartyRole } from "./entities/schema/partyRole.enum"; -@ApiTags("registrar-initiated-blame (v1)") +@ApiExcludeController() +// @ApiTags("registrar-initiated-blame (v1)") @Controller("registrar-initiated-blame") @ApiBearerAuth() @UseGuards(LocalActorAuthGuard, RolesGuard) @@ -25,8 +26,8 @@ export class RegistrarInitiatedController { constructor(private readonly requestManagementService: RequestManagementService) {} @Post("create") - @ApiOperation({ summary: "Registrar creates IN_PERSON blame file" }) - @ApiBody({ type: CreateRegistrarInitiatedFileDto }) +// @ApiOperation({ summary: "Registrar creates IN_PERSON blame file" }) +// @ApiBody({ type: CreateRegistrarInitiatedFileDto }) create(@CurrentUser() registrar: any, @Body() dto: CreateRegistrarInitiatedFileDto) { return this.requestManagementService.createRegistrarInitiatedBlame(registrar, dto); } @@ -42,8 +43,8 @@ export class RegistrarInitiatedController { } @Post("send-party-otps/:requestId") - @ApiParam({ name: "requestId" }) - @ApiBody({ type: SendPartyOtpsDto }) +// @ApiParam({ name: "requestId" }) +// @ApiBody({ type: SendPartyOtpsDto }) sendPartyOtps( @CurrentUser() registrar: any, @Param("requestId") requestId: string, @@ -53,8 +54,8 @@ export class RegistrarInitiatedController { } @Post("verify-party-otps/:requestId") - @ApiParam({ name: "requestId" }) - @ApiBody({ type: VerifyPartyOtpsDto }) +// @ApiParam({ name: "requestId" }) +// @ApiBody({ type: VerifyPartyOtpsDto }) verifyPartyOtps( @CurrentUser() registrar: any, @Param("requestId") requestId: string, @@ -73,7 +74,7 @@ export class RegistrarInitiatedController { } @Post("upload-video/:requestId") - @ApiConsumes("multipart/form-data") +// @ApiConsumes("multipart/form-data") @UseInterceptors( FileInterceptor("file", { limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, @@ -92,7 +93,7 @@ export class RegistrarInitiatedController { } @Post("upload-voice/:requestId") - @ApiConsumes("multipart/form-data") +// @ApiConsumes("multipart/form-data") @UseInterceptors( FileInterceptor("voice", { limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, @@ -111,7 +112,7 @@ export class RegistrarInitiatedController { } @Post("add-accident-fields/:requestId") - @ApiBody({ type: ExpertAccidentFieldsDto }) +// @ApiBody({ type: ExpertAccidentFieldsDto }) addAccidentFields( @CurrentUser() registrar: any, @Param("requestId") requestId: string, @@ -125,7 +126,7 @@ export class RegistrarInitiatedController { } @Post("upload-party-signature/:requestId") - @ApiConsumes("multipart/form-data") +// @ApiConsumes("multipart/form-data") @UseInterceptors( FileInterceptor("sign", { limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, diff --git a/src/request-management/request-management.controller.ts b/src/request-management/request-management.controller.ts index a662b2f..f2cc350 100644 --- a/src/request-management/request-management.controller.ts +++ b/src/request-management/request-management.controller.ts @@ -23,6 +23,7 @@ import { ApiParam, ApiTags, ApiOperation, + ApiExcludeController, } from "@nestjs/swagger"; import { Request } from "express"; import { diskStorage } from "multer"; @@ -43,8 +44,9 @@ import { } from "./dto/create-request-management.dto"; import { RequestManagementService } from "./request-management.service"; +@ApiExcludeController() @Controller("blame-request-management") -@ApiTags("blame-request-management") +// @ApiTags("blame-request-management") @ApiBearerAuth() @UseGuards(GlobalGuard, RolesGuard) @Roles(RoleEnum.USER) diff --git a/src/request-management/request-management.module.ts b/src/request-management/request-management.module.ts index c5d84e1..43ccfb1 100644 --- a/src/request-management/request-management.module.ts +++ b/src/request-management/request-management.module.ts @@ -42,6 +42,7 @@ import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller"; import { ExpertInitiatedBlameMirrorController } from "./expert-initiated-blame.mirror.controller"; import { RegistrarInitiatedController } from "./registrar-initiated.controller"; import { RegistrarBlameMirrorController } from "./registrar-blame.mirror.controller"; +import { ExpertInitiatedBlameV3MirrorController } from "./expert-initiated-blame-v3.mirror.controller"; import { InquiryRefreshController } from "./inquiry-refresh.controller"; import { InquiryRefreshService } from "./inquiry-refresh.service"; @@ -77,6 +78,7 @@ import { InquiryRefreshService } from "./inquiry-refresh.service"; ExpertInitiatedController, ExpertInitiatedV2Controller, ExpertInitiatedBlameMirrorController, + ExpertInitiatedBlameV3MirrorController, RegistrarInitiatedController, RegistrarBlameMirrorController, InquiryRefreshController, diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 70f6f59..9f2ea9e 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -38,6 +38,12 @@ import { import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum"; +import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; +import { resolveClaimOwnerFieldsFromBlame } from "src/helpers/blame-damaged-party"; +import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto"; +import { + RunInquiriesV3Dto, +} from "./dto/run-inquiries-v3.dto"; import { SandHubService } from "src/sand-hub/sand-hub.service"; import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum"; import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; @@ -4735,8 +4741,7 @@ export class RequestManagementService { actor: { actorId: new Types.ObjectId(actor.sub), actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), - actorType: - actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert", + actorType: "field_expert", }, metadata: { phoneNumber: phone }, } as any); @@ -4818,6 +4823,20 @@ export class RequestManagementService { const userId = u._id as Types.ObjectId; const canonicalPhone = normalizeIranMobile(phone) ?? phone; + const firstPartyUserId = + firstIdx !== -1 ? req.parties[firstIdx]?.person?.userId : undefined; + + if ( + role === PartyRole.SECOND && + firstPartyUserId != null && + String(firstPartyUserId) === String(userId) + ) { + throw new BadRequestException( + "The damaged party must verify OTP with a different phone number than the guilty party. " + + "Both parties cannot be the same user account.", + ); + } + if (role === PartyRole.FIRST) { if (firstIdx === -1) throw new BadRequestException("First party not found on request"); @@ -4863,8 +4882,7 @@ export class RequestManagementService { actor: { actorId: new Types.ObjectId(actor.sub), actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), - actorType: - actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert", + actorType: "field_expert", }, metadata: { partyRole: role, phoneNumber: phone }, } as any); @@ -6258,9 +6276,9 @@ export class RequestManagementService { ): Promise { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); - if (!req.expertInitiated) { + if (!req.expertInitiated && !req.registrarInitiated) { throw new BadRequestException( - "This endpoint is only for expert-initiated files.", + "This endpoint is only for expert- or registrar-initiated files.", ); } this.verifyExpertAccessForBlameV2(req, expert); @@ -7875,4 +7893,1216 @@ export class RequestManagementService { message, }; } + + // --------------------------------------------------------------------------- + // V3 IN-PERSON SEQUENTIAL FLOW (field expert, one party at a time) + // --------------------------------------------------------------------------- + + private assertBlameV3ExpertInPerson(req: any): void { + if (req.creationMethod !== CreationMethod.IN_PERSON) { + throw new BadRequestException("V3 flow is only for IN_PERSON files."); + } + if (!req.expertInitiated) { + throw new BadRequestException("V3 flow is only for expert-initiated files."); + } + } + + /** Resolve auto-created claim for V3 blame (created on guilty-party run-inquiries). */ + async getV3LinkedClaimForExpert( + expert: any, + blameRequestId: string, + ): Promise<{ + blameRequestId: string; + claimRequestId: string; + publicId: string; + }> { + const req = await this.blameRequestDbService.findById(blameRequestId); + if (!req) throw new NotFoundException("Blame request not found"); + this.assertBlameV3ExpertInPerson(req); + this.verifyExpertAccessForBlameV2(req, expert); + + const claim = await this.claimCaseDbService.findOne({ + blameRequestId: (req as any)._id, + }); + if (!claim) { + throw new NotFoundException( + "No claim linked to this blame file yet. Complete guilty-party run-inquiries first.", + ); + } + + return { + blameRequestId: String(req._id), + claimRequestId: String(claim._id), + publicId: claim.publicId ?? req.publicId, + }; + } + + private hasPartyInquiriesComplete(req: any, role: PartyRole): boolean { + const step = + role === PartyRole.FIRST + ? WorkflowStep.FIRST_INITIAL_FORM + : WorkflowStep.SECOND_INITIAL_FORM; + return (req.workflow?.completedSteps ?? []).includes(step); + } + + private partyHasSigned(req: any, role: PartyRole): boolean { + const idx = this.getPartyIndex(req, role); + return idx !== -1 && req.parties?.[idx]?.confirmation != null; + } + + private resolveV3InquiryPartyRole(req: any): PartyRole | null { + if (!this.hasPartyInquiriesComplete(req, PartyRole.FIRST)) { + return PartyRole.FIRST; + } + if ( + req.type === BlameRequestType.THIRD_PARTY && + !this.hasPartyInquiriesComplete(req, PartyRole.SECOND) + ) { + return PartyRole.SECOND; + } + return null; + } + + private pushWorkflowSteps(req: any, steps: WorkflowStep[]): void { + if (!req.workflow) req.workflow = {} as any; + const completed = Array.isArray(req.workflow.completedSteps) + ? [...req.workflow.completedSteps] + : []; + for (const step of steps) { + if (!completed.includes(step)) completed.push(step); + } + req.workflow.completedSteps = completed; + } + + private async runPartyInquiriesV3Internal( + req: any, + partyData: RunInquiriesV3Dto, + partyRole: PartyRole, + party: any, + ): Promise<{ clientId?: string }> { + const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND"; + let clientId: string | undefined; + const inquiryOptions = (cid?: string) => (cid ? { clientId: cid } : undefined); + + let inquiryRaw: any; + let inquiryMapped: any; + try { + const inquiry = await this.sandHubService.getTejaratBlockInquiry( + { + plate: partyData.plate as any, + nationalCodeOfInsurer: partyData.nationalCodeOfInsurer, + }, + inquiryOptions(party?.person?.clientId?.toString()), + ); + inquiryRaw = inquiry.raw; + inquiryMapped = inquiry.mapped; + this.recordPartyCaseInquiryStatus(req, "thirdParty", partyRole, true, { + source: "TEJARAT_BLOCK_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }); + } catch (err: any) { + this.logger.error( + `[V3] plate inquiry failed for ${roleLabel} party (request=${req._id}): ${err?.message || err}`, + ); + this.recordPartyCaseInquiryStatus(req, "thirdParty", partyRole, false, {}, err); + throw new BadRequestException( + `${roleLabel} party plate inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, + ); + } + + if (inquiryMapped?.Error) { + throw new BadRequestException( + inquiryMapped.Error.Message || + `${roleLabel} party plate inquiry returned an error`, + ); + } + + const clientName = inquiryMapped?.CompanyName; + const companyCode = inquiryMapped?.CompanyCode; + if (!clientName) { + throw new BadRequestException( + `CompanyName missing from ${roleLabel} party inquiry response`, + ); + } + const client = await this.clientService.findOrCreateClientByCompanyCode( + companyCode, + clientName, + ); + if (!client) { + throw new BadRequestException( + `CompanyCode missing or invalid in ${roleLabel} party inquiry response`, + ); + } + const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; + clientId = resolvedClientId ? String(resolvedClientId) : undefined; + + if (!party.person) party.person = {} as any; + party.person.clientId = resolvedClientId; + party.person.nationalCodeOfInsurer = partyData.nationalCodeOfInsurer; + party.person.nationalCodeOfDriver = partyData.nationalCodeOfDriver; + party.person.driverIsInsurer = partyData.driverIsInsurer; + party.person.insurerBirthday = partyData.insurerBirthday; + party.person.driverBirthday = + partyData.driverBirthday ?? + (partyData.driverIsInsurer ? String(partyData.insurerBirthday) : null); + if (partyData.insurerLicense) party.person.insurerLicense = partyData.insurerLicense; + if (partyData.driverLicense) party.person.driverLicense = partyData.driverLicense; + + if (!party.vehicle) party.vehicle = {} as any; + party.vehicle.plateId = this.plateToPlateIdString(partyData.plate); + party.vehicle.name = inquiryMapped?.MapTypNam; + party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`; + party.vehicle.inquiry = { + source: "TEJARAT_BLOCK_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }; + + if (!party.insurance) party.insurance = {} as any; + party.insurance.policyNumber = inquiryMapped?.LastCompanyDocumentNumber; + party.insurance.company = clientName; + party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; + party.insurance.startDate = inquiryMapped?.IssueDate; + party.insurance.endDate = inquiryMapped?.EndDate; + + if (req.type === BlameRequestType.CAR_BODY && partyRole === PartyRole.FIRST) { + try { + const carBodyInfo = await this.sandHubService.getTejaratCarBodyInquiry( + { + nationalCodeOfInsurer: partyData.nationalCodeOfInsurer, + plate: partyData.plate as any, + }, + clientId ? { clientId } : undefined, + ); + this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, true, { + source: "TEJARAT_CAR_BODY_INQUIRY", + raw: carBodyInfo.raw, + mapped: carBodyInfo.mapped, + }); + party.vehicle.inquiry = { + ...party.vehicle.inquiry, + carBody: { + source: "TEJARAT_CAR_BODY_INQUIRY", + raw: carBodyInfo.raw, + mapped: carBodyInfo.mapped, + }, + }; + const m = carBodyInfo.mapped; + (party.insurance as any).carBodyInsurance = { + policyNumber: m.policyNumber ?? null, + companyId: m.companyId ?? null, + companyName: m.CompanyName ?? null, + }; + const cbCompanyCode = m.companyId ?? m.CompanyCode; + const cbCompanyName = m.CompanyName ?? m.companyPersianName; + if (cbCompanyCode && cbCompanyName) { + const cbClient = await this.clientService.findOrCreateClientByCompanyCode( + Number(cbCompanyCode), + String(cbCompanyName), + ); + const cbClientId = (cbClient as any)?._id ?? (cbClient as any)?._doc?._id; + if (cbClientId) { + party.person.clientId = cbClientId; + clientId = String(cbClientId); + } + } + } catch (err: any) { + this.logger.error( + `[V3] car body inquiry failed for request=${req._id}: ${err?.message || err}`, + ); + this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, false, {}, err); + throw new BadRequestException( + `CAR_BODY inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, + ); + } + } + + const nationalCode = + partyData.nationalCodeOfInsurer || partyData.nationalCodeOfDriver; + const birthDate = partyData.insurerBirthday ?? partyData.driverBirthday; + if (nationalCode && birthDate != null) { + try { + const personalInquiry = await this.sandHubService.getPersonalInquiry( + nationalCode, + birthDate as string | number, + clientId ? { clientId } : undefined, + ); + this.recordPartyCaseInquiryStatus(req, "person", partyRole, true, personalInquiry); + } catch (err: any) { + this.logger.error( + `[V3] personal inquiry failed for ${roleLabel} party request=${req._id}: ${err?.message || err}`, + ); + this.recordPartyCaseInquiryStatus(req, "person", partyRole, false, {}, err); + throw new BadRequestException( + `${roleLabel} party personal identity inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, + ); + } + } + + const licenseNationalCode = partyData.driverIsInsurer + ? partyData.nationalCodeOfInsurer + : partyData.nationalCodeOfDriver; + const licenseNumber = partyData.driverIsInsurer + ? partyData.insurerLicense + : partyData.driverLicense; + if (!licenseNationalCode || !licenseNumber) { + throw new BadRequestException( + `${roleLabel} party driving license inquiry requires national code and ${ + partyData.driverIsInsurer ? "insurerLicense" : "driverLicense" + }.`, + ); + } + try { + const licenseInquiry = await this.sandHubService.getDrivingLicenseInfo( + licenseNationalCode, + licenseNumber, + clientId ? { clientId } : undefined, + ); + this.recordPartyCaseInquiryStatus( + req, + "drivingLicence", + partyRole, + true, + licenseInquiry, + ); + } catch (err: any) { + this.logger.error( + `[V3] driving license inquiry failed for ${roleLabel} party request=${req._id}: ${err?.message || err}`, + ); + this.recordPartyCaseInquiryStatus(req, "drivingLicence", partyRole, false, {}, err); + throw new BadRequestException( + `${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, + ); + } + + return { clientId }; + } + + private async validateShebaV3( + partyData: RunInquiriesV3Dto, + clientId?: string, + ): Promise { + if (!partyData.sheba) return; + const nationalCode = + partyData.nationalCodeOfInsurer || partyData.nationalCodeOfDriver; + await this.sandHubService.getShebaValidation( + nationalCode, + partyData.sheba, + clientId ? { clientId } : undefined, + ); + } + + private markPartyInquiriesCompleteOnBlame( + req: any, + partyRole: PartyRole, + actor: any, + ): void { + if (partyRole === PartyRole.FIRST) { + this.pushWorkflowSteps(req, [ + WorkflowStep.CREATED, + WorkflowStep.FIRST_INITIAL_FORM, + ]); + req.workflow.currentStep = WorkflowStep.FIRST_LOCATION; + req.workflow.nextStep = WorkflowStep.FIRST_VOICE; + + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + const guiltyUserId = req.parties?.[firstIdx]?.person?.userId; + if (!req.expert) req.expert = {} as any; + if (!req.expert.decision) req.expert.decision = {} as any; + if (guiltyUserId) { + (req.expert as any).decision.guiltyPartyId = new Types.ObjectId( + String(guiltyUserId), + ); + } + if (typeof (req as any).markModified === "function") { + (req as any).markModified("expert"); + } + } else { + this.pushWorkflowSteps(req, [WorkflowStep.SECOND_INITIAL_FORM]); + req.workflow.currentStep = WorkflowStep.SECOND_LOCATION; + req.workflow.nextStep = WorkflowStep.SECOND_VOICE; + } + } + + private async createV3ClaimFromBlame( + req: any, + actor: any, + options: { + sheba?: string; + nationalCode?: string; + interimThirdParty?: boolean; + } = {}, + ) { + const requestId = String(req._id); + const existing = await this.claimCaseDbService.findOne({ + blameRequestId: (req as any)._id, + }); + if (existing) { + throw new ConflictException( + "A claim for this blame file already exists.", + ); + } + + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + const firstParty = req.parties?.[firstIdx]; + const guiltyUserId = firstParty?.person?.userId; + const guiltyClientId = firstParty?.person?.clientId; + + let ownerUserId: string; + let ownerRole: string | undefined; + let payingClientId: string | undefined; + + if (req.type === BlameRequestType.CAR_BODY || !options.interimThirdParty) { + const ownerFields = resolveClaimOwnerFieldsFromBlame(req); + if (!ownerFields) { + throw new BadRequestException( + "Could not resolve claim owner from blame parties.", + ); + } + ownerUserId = ownerFields.userId; + ownerRole = ownerFields.userRole; + payingClientId = ownerFields.clientId ?? guiltyClientId?.toString(); + } else { + if (!guiltyUserId) { + throw new BadRequestException("First party must be verified before creating claim."); + } + ownerUserId = String(guiltyUserId); + ownerRole = PartyRole.FIRST; + payingClientId = guiltyClientId ? String(guiltyClientId) : undefined; + } + + const claimNo = `CL${Math.floor(10000 + Math.random() * 90000)}`; + const newClaimData: any = { + requestNo: claimNo, + publicId: req.publicId, + blameRequestId: (req as any)._id, + blameRequestNo: req.requestNo, + initiatedByFieldExpertId: new Types.ObjectId(actor.sub), + status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, + claimStatus: ClaimStatus.PENDING, + inquiries: (req as any).inquiries ?? {}, + workflow: { + currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + nextStep: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + completedSteps: [ClaimWorkflowStep.CLAIM_CREATED], + locked: false, + }, + ...(options.interimThirdParty + ? {} + : { damagedPartyUserId: new Types.ObjectId(ownerUserId) }), + owner: { + userId: new Types.ObjectId(ownerUserId), + userRole: ownerRole as any, + ...(payingClientId + ? { + clientId: new Types.ObjectId(payingClientId), + userClientKey: payingClientId, + } + : {}), + }, + ...(options.sheba + ? { + money: { + sheba: options.sheba, + nationalCodeOfInsurer: options.nationalCode ?? "", + }, + } + : {}), + history: [ + { + type: "CLAIM_CREATED", + actor: { + actorId: new Types.ObjectId(actor.sub), + actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + actorType: "field_expert", + }, + timestamp: new Date(), + metadata: { + blameRequestId: requestId, + blamePublicId: req.publicId, + createdByV3InPerson: true, + interimThirdParty: !!options.interimThirdParty, + }, + }, + ], + }; + + return this.claimCaseDbService.create(newClaimData); + } + + private ensureV3GuiltyPartyDecision(req: any): void { + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + const guiltyUserId = req.parties?.[firstIdx]?.person?.userId; + if (!guiltyUserId) return; + if (!req.expert) req.expert = {} as any; + if (!req.expert.decision) req.expert.decision = {} as any; + (req.expert as any).decision.guiltyPartyId = new Types.ObjectId( + String(guiltyUserId), + ); + if (typeof (req as any).markModified === "function") { + (req as any).markModified("expert"); + } + } + + /** THIRD_PARTY: damaged party is always SECOND after OTP + inquiries. */ + private resolveV3DamagedPartyForClaimSync(req: any): { + userId: string; + userRole?: string; + clientId?: string; + } | null { + if (req.type === BlameRequestType.CAR_BODY) { + return resolveClaimOwnerFieldsFromBlame(req); + } + + const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); + if (secondIdx === -1) return null; + + const damagedPerson = req.parties[secondIdx]?.person; + if (damagedPerson?.userId == null) return null; + + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + const guiltyPerson = firstIdx !== -1 ? req.parties[firstIdx]?.person : null; + const damagedUserId = String(damagedPerson.userId); + const guiltyUserId = guiltyPerson?.userId + ? String(guiltyPerson.userId) + : null; + + // Throws — caller should not catch; distinct accounts are required. + if (guiltyUserId && damagedUserId === guiltyUserId) { + throw new BadRequestException( + "Guilty and damaged parties are bound to the same user account. " + + "Re-verify the second party OTP using a different phone number.", + ); + } + + const payingClientId = + guiltyPerson?.clientId != null ? String(guiltyPerson.clientId) : undefined; + + return { + userId: damagedUserId, + userRole: PartyRole.SECOND, + ...(payingClientId ? { clientId: payingClientId } : {}), + }; + } + + private async syncV3ClaimDamagedPartyFromBlame( + req: any, + partyData: RunInquiriesV3Dto, + ): Promise { + const claim = await this.claimCaseDbService.findOne({ + blameRequestId: (req as any)._id, + }); + if (!claim) { + throw new NotFoundException("Claim not found for this blame file."); + } + + this.ensureV3GuiltyPartyDecision(req); + + const ownerFields = this.resolveV3DamagedPartyForClaimSync(req); + if (!ownerFields) { + throw new BadRequestException( + "Could not resolve damaged party after second-party inquiries. " + + "Ensure the second party OTP is verified and inquiry data was saved.", + ); + } + + const nationalCode = + partyData.nationalCodeOfInsurer || partyData.nationalCodeOfDriver || ""; + + await this.claimCaseDbService.findByIdAndUpdate(String(claim._id), { + $set: { + damagedPartyUserId: new Types.ObjectId(ownerFields.userId), + "owner.userId": new Types.ObjectId(ownerFields.userId), + "owner.userRole": ownerFields.userRole, + ...(ownerFields.clientId + ? { + "owner.clientId": new Types.ObjectId(ownerFields.clientId), + "owner.userClientKey": ownerFields.clientId, + } + : {}), + ...(partyData.sheba + ? { + "money.sheba": partyData.sheba, + "money.nationalCodeOfInsurer": nationalCode, + } + : {}), + }, + }); + } + + /** + * Unified inquiries: 1st call = guilty party (+ auto claim), 2nd call = damaged (THIRD_PARTY). + * Uses standard workflow steps (FIRST_INITIAL_FORM / SECOND_INITIAL_FORM). + */ + async runInquiriesV3( + requestId: string, + actor: any, + dto: RunInquiriesV3Dto, + ): Promise<{ + blameRequestId: string; + partyRole: PartyRole; + claimRequestId?: string; + claimPublicId?: string; + message: string; + }> { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Blame request not found"); + this.assertBlameV3ExpertInPerson(req); + this.verifyExpertAccessForBlameV2(req, actor); + + const partyRole = this.resolveV3InquiryPartyRole(req); + if (!partyRole) { + throw new ConflictException("All party inquiries are already complete."); + } + + if (partyRole === PartyRole.FIRST) { + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + if (firstIdx === -1) throw new BadRequestException("First party not found"); + const firstParty = req.parties[firstIdx]; + if (!firstParty?.person?.userId) { + throw new BadRequestException( + "Guilty party OTP must be verified before running inquiries.", + ); + } + if (req.type === BlameRequestType.CAR_BODY) { + const form = firstParty.carBodyFirstForm as any; + if (form?.car == null && form?.object == null) { + throw new BadRequestException( + "Submit car-body-form before running inquiries (CAR_BODY).", + ); + } + } + + await this.runPartyInquiriesV3Internal(req, dto, PartyRole.FIRST, firstParty); + if (req.type === BlameRequestType.CAR_BODY) { + await this.validateShebaV3(dto, firstParty.person?.clientId?.toString()); + } + + this.markPartyInquiriesCompleteOnBlame(req, PartyRole.FIRST, actor); + await (req as any).save(); + + const nationalCode = + dto.nationalCodeOfInsurer || dto.nationalCodeOfDriver || ""; + const newClaim = await this.createV3ClaimFromBlame(req, actor, { + sheba: req.type === BlameRequestType.CAR_BODY ? dto.sheba : undefined, + nationalCode: + req.type === BlameRequestType.CAR_BODY ? nationalCode : undefined, + interimThirdParty: req.type === BlameRequestType.THIRD_PARTY, + }); + + return { + blameRequestId: requestId, + partyRole: PartyRole.FIRST, + claimRequestId: String(newClaim._id), + claimPublicId: req.publicId, + message: + "Guilty-party inquiries complete and claim created. Proceed with location, description, voice, and signature.", + }; + } + + // Damaged party (THIRD_PARTY only) + if (!this.partyHasSigned(req, PartyRole.FIRST)) { + throw new BadRequestException( + "Guilty party must sign before running damaged-party inquiries.", + ); + } + const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); + if (secondIdx === -1 || !req.parties[secondIdx]?.person?.userId) { + throw new BadRequestException( + "Damaged party OTP must be verified before running inquiries.", + ); + } + + const secondParty = req.parties[secondIdx]; + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + const firstUserId = + firstIdx !== -1 ? req.parties[firstIdx]?.person?.userId : null; + const secondUserId = secondParty.person?.userId; + if ( + firstUserId && + secondUserId && + String(firstUserId) === String(secondUserId) + ) { + throw new BadRequestException( + "Guilty and damaged parties are bound to the same user account. " + + "Re-verify the second party OTP using a different phone number.", + ); + } + + await this.runPartyInquiriesV3Internal(req, dto, PartyRole.SECOND, secondParty); + await this.validateShebaV3(dto, secondParty.person?.clientId?.toString()); + + this.ensureV3GuiltyPartyDecision(req); + if (typeof (req as any).markModified === "function") { + (req as any).markModified("parties"); + } + await (req as any).save(); + + await this.syncV3ClaimDamagedPartyFromBlame(req, dto); + + this.markPartyInquiriesCompleteOnBlame(req, PartyRole.SECOND, actor); + await (req as any).save(); + + return { + blameRequestId: requestId, + partyRole: PartyRole.SECOND, + message: + "Damaged-party inquiries complete. Proceed with location, description, voice, and signature.", + }; + } + + private assertBlameV3PartyDetailPhase(req: any, partyRole: PartyRole): void { + this.assertBlameV3ExpertInPerson(req); + if (partyRole === PartyRole.FIRST) { + if (!this.hasPartyInquiriesComplete(req, PartyRole.FIRST)) { + throw new BadRequestException( + "Complete guilty-party inquiries before collecting details.", + ); + } + if (this.partyHasSigned(req, PartyRole.FIRST)) { + throw new BadRequestException("Guilty party has already signed."); + } + return; + } + if (req.type !== BlameRequestType.THIRD_PARTY) { + throw new BadRequestException("CAR_BODY has only one party."); + } + if (!this.isV3SecondPartyDetailPhase(req)) { + throw new BadRequestException( + "Complete damaged-party inquiries before collecting details.", + ); + } + if (this.partyHasSigned(req, PartyRole.SECOND)) { + throw new BadRequestException("Damaged party has already signed."); + } + } + + private assertBlameV3AccidentFieldsPhase(req: any): void { + this.assertBlameV3ExpertInPerson(req); + 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 accident fields.", + ); + } + } + + /** + * V3 signature — no accident-fields prerequisite; does not auto-complete blame + * until finalize-for-field is called after part selection. + */ + async expertUploadPartySignatureV3( + expert: any, + requestId: string, + partyRole: PartyRole, + isAccept: boolean, + signFile: Express.Multer.File, + ): Promise { + if (!signFile) { + throw new BadRequestException("A signature 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 (partyRole === PartyRole.FIRST) { + if (!this.hasPartyInquiriesComplete(req, PartyRole.FIRST)) { + throw new BadRequestException( + "Complete guilty-party inquiries before signing.", + ); + } + if (this.partyHasSigned(req, PartyRole.FIRST)) { + throw new BadRequestException("Guilty party has already signed."); + } + if (this.hasPartyInquiriesComplete(req, PartyRole.SECOND)) { + throw new BadRequestException( + "Guilty party must sign before damaged-party inquiries.", + ); + } + } else { + if (!this.hasPartyInquiriesComplete(req, PartyRole.SECOND)) { + throw new BadRequestException( + "Complete damaged-party inquiries before signing.", + ); + } + } + + const partyIndex = this.getPartyIndex(req, partyRole); + if (partyIndex === -1) { + throw new BadRequestException(`Party ${partyRole} not found on this request`); + } + if ( + req.type === BlameRequestType.CAR_BODY && + partyRole === PartyRole.SECOND + ) { + throw new BadRequestException( + "CAR_BODY has only first party; use partyRole FIRST.", + ); + } + + const party = req.parties[partyIndex]; + if (party.confirmation) { + throw new BadRequestException(`Party ${partyRole} has already signed.`); + } + + const partyUserId = party.person?.userId + ? String(party.person.userId) + : undefined; + const signDoc = await this.userSign.create({ + fileName: signFile.filename, + userId: partyUserId || expert.sub, + path: signFile.path, + requestId: new Types.ObjectId(requestId), + }); + const signatureData = { + fileId: String((signDoc as any)._id), + fileName: signFile.filename, + fileUrl: signFile.path, + }; + + await this.blameRequestDbService.findByIdAndUpdate(requestId, { + $set: { + [`parties.${partyIndex}.confirmation`]: { + partyRole: party.role, + accepted: isAccept, + signature: signatureData, + }, + }, + }); + + const fresh = await this.blameRequestDbService.findById(requestId); + if (fresh) { + if (partyRole === PartyRole.FIRST) { + this.pushWorkflowSteps(fresh, [ + WorkflowStep.FIRST_LOCATION, + WorkflowStep.FIRST_VOICE, + WorkflowStep.FIRST_DESCRIPTION, + WorkflowStep.FIRST_SIGN, + WorkflowStep.FIRST_COMPLETED, + ]); + fresh.workflow.currentStep = + fresh.type === BlameRequestType.THIRD_PARTY + ? WorkflowStep.FIRST_INVITE_SECOND + : WorkflowStep.FIRST_COMPLETED; + fresh.workflow.nextStep = + fresh.type === BlameRequestType.THIRD_PARTY + ? WorkflowStep.SECOND_INITIAL_FORM + : WorkflowStep.WAITING_FOR_GUILT_DECISION; + } else { + this.pushWorkflowSteps(fresh, [ + WorkflowStep.SECOND_LOCATION, + WorkflowStep.SECOND_VOICE, + WorkflowStep.SECOND_DESCRIPTION, + WorkflowStep.SECOND_SIGN, + WorkflowStep.SECOND_COMPLETED, + ]); + fresh.workflow.currentStep = WorkflowStep.SECOND_COMPLETED; + fresh.workflow.nextStep = WorkflowStep.WAITING_FOR_GUILT_DECISION; + } + await (fresh as any).save(); + } + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "V3_PARTY_SIGNATURE_RECORDED", + actor: { + actorId: new Types.ObjectId(expert.sub), + actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), + actorType: "field_expert", + }, + metadata: { partyRole, accepted: isAccept }, + } as any); + await (req as any).save(); + + return { + requestId, + accepted: isAccept, + status: req.status ?? CaseStatus.OPEN, + message: `Signature recorded for ${partyRole} party.`, + }; + } + + /** Saves accident fields without advancing blame to WAITING_FOR_SIGNATURES (V3 order). */ + async expertAddAccidentFieldsForBlameV3( + expert: any, + requestId: string, + fields: ExpertAccidentFieldsDto, + ) { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + this.assertBlameV3ExpertInPerson(req); + this.verifyExpertAccessForBlameV2(req, expert); + this.assertBlameV3AccidentFieldsPhase(req); + + if (!req.expert) req.expert = {} as any; + if (!req.expert.decision) req.expert.decision = {} as any; + + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + const guiltyUserId = req.parties?.[firstIdx]?.person?.userId; + const guiltyPhone = (req.parties?.[firstIdx]?.person as any)?.phoneNumber; + const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); + const damagedPhone = (req.parties?.[secondIdx]?.person as any)?.phoneNumber; + + (req.expert as any).decision = { + ...(req.expert as any).decision, + guiltyPartyId: guiltyUserId + ? new Types.ObjectId(String(guiltyUserId)) + : (req.expert as any).decision?.guiltyPartyId, + description: + (req.expert as any).decision?.description || + `مقصر توسط کارشناس در محل حادثه مشخص شد. کاربر ${guiltyPhone ?? ""} مقصر و کاربر ${damagedPhone ?? ""} زیان دیده می‌باشد.`, + decidedAt: (req.expert as any).decision?.decidedAt || new Date(), + decidedByExpertId: new Types.ObjectId(String(expert.sub)), + fields: { + accidentWay: fields.accidentWay, + accidentReason: fields.accidentReason, + accidentType: fields.accidentType, + }, + }; + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "V3_ACCIDENT_FIELDS_SAVED", + actor: { + actorId: new Types.ObjectId(String(expert.sub)), + actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), + actorType: "field_expert", + }, + metadata: { accidentWay: fields.accidentWay }, + } as any); + + await (req as any).save(); + + return { + requestId: req._id, + publicId: req.publicId, + message: + "Accident fields saved. Proceed to required documents, part selection, capture, walk-around video, then blame accident video.", + }; + } + + /** + * V3 final field step: blame accident video. Moves file to WAITING_FOR_EXPERT + * after claim capture and walk-around video are complete. + */ + async expertUploadBlameVideoV3( + 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_EXPERT; + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "V3_BLAME_ACCIDENT_VIDEO_UPLOADED", + actor: { + actorId: new Types.ObjectId(expert.sub), + actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), + actorType: "field_expert", + }, + 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 expert review.", + }; + } + + /** + * @deprecated V3 blame completes via expertUploadBlameVideoV3 (WAITING_FOR_EXPERT). + */ + async tryAutoCompleteBlameV3ForField(_req: any): Promise { + return; + } + + private partyHasInquiryData(req: any, role: PartyRole): boolean { + const roleKey = role === PartyRole.FIRST ? "FIRST" : "SECOND"; + if (req.inquiries?.thirdParty?.data?.[roleKey]) return true; + const idx = this.getPartyIndex(req, role); + return idx !== -1 && req.parties?.[idx]?.vehicle?.inquiry != null; + } + + private isV3SecondPartyDetailPhase(req: any): boolean { + if (req.type !== BlameRequestType.THIRD_PARTY) return false; + if (!this.partyHasSigned(req, PartyRole.FIRST)) return false; + + const step = String(req.workflow?.currentStep ?? ""); + if (step.startsWith("SECOND_")) return true; + + return ( + this.hasPartyInquiriesComplete(req, PartyRole.SECOND) || + this.partyHasInquiryData(req, PartyRole.SECOND) + ); + } + + /** When partyRole is omitted, infer from workflow / party progress (sequential V3 flow). */ + private resolvePartyRoleV3(req: any, partyRole?: string): PartyRole { + if (partyRole === "SECOND" || partyRole === PartyRole.SECOND) { + return PartyRole.SECOND; + } + if (partyRole === "FIRST" || partyRole === PartyRole.FIRST) { + return PartyRole.FIRST; + } + + const currentStep = String(req.workflow?.currentStep ?? ""); + if (currentStep.startsWith("SECOND_")) return PartyRole.SECOND; + if (currentStep.startsWith("FIRST_")) return PartyRole.FIRST; + + if ( + this.hasPartyInquiriesComplete(req, PartyRole.FIRST) && + !this.partyHasSigned(req, PartyRole.FIRST) + ) { + return PartyRole.FIRST; + } + if ( + req.type === BlameRequestType.THIRD_PARTY && + this.partyHasSigned(req, PartyRole.FIRST) && + !this.partyHasSigned(req, PartyRole.SECOND) + ) { + return PartyRole.SECOND; + } + + return PartyRole.FIRST; + } + + async addPartyVoiceV3( + requestId: string, + actor: any, + voice: Express.Multer.File, + partyRole?: string, + ) { + if (!voice) throw new BadRequestException("File is required"); + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + this.verifyExpertAccessForBlameV2(req, actor); + const role = this.resolvePartyRoleV3(req, partyRole); + this.assertBlameV3PartyDetailPhase(req, role); + const idx = this.getPartyIndex(req, role); + if (idx === -1) throw new BadRequestException(`${role} party not found`); + + const voiceDoc = await this.blameVoiceDbService.create({ + fileName: voice.filename, + path: voice.path, + requestId: new Types.ObjectId(req._id), + context: UploadContext.INITIAL, + } as any); + + const party = req.parties[idx]; + if (!party.evidence) party.evidence = {} as any; + if (!Array.isArray(party.evidence.voices)) party.evidence.voices = []; + party.evidence.voices.push(String((voiceDoc as any)._id)); + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "V3_PARTY_VOICE_UPLOADED", + actor: { + actorId: new Types.ObjectId(actor.sub), + actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + actorType: "field_expert", + }, + metadata: { partyRole: role, voiceId: String((voiceDoc as any)._id) }, + } as any); + + await (req as any).save(); + return { requestId: req._id, publicId: req.publicId, partyRole: role }; + } + + async addPartyLocationV3( + requestId: string, + actor: any, + body: LocationDto, + partyRole?: string, + ) { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + this.verifyExpertAccessForBlameV2(req, actor); + const role = this.resolvePartyRoleV3(req, partyRole); + this.assertBlameV3PartyDetailPhase(req, role); + + const idx = this.getPartyIndex(req, role); + if (idx === -1) throw new BadRequestException(`${role} party not found`); + + req.parties[idx].location = body as any; + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "V3_PARTY_LOCATION_SAVED", + actor: { + actorId: new Types.ObjectId(actor.sub), + actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + actorType: "field_expert", + }, + metadata: { partyRole: role, location: body }, + } as any); + + await (req as any).save(); + return { requestId: req._id, publicId: req.publicId, partyRole: role }; + } + + async addPartyDescriptionV3( + requestId: string, + actor: any, + body: DescriptionDto, + partyRole?: string, + ) { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + this.verifyExpertAccessForBlameV2(req, actor); + const role = this.resolvePartyRoleV3(req, partyRole); + this.assertBlameV3PartyDetailPhase(req, role); + + const idx = this.getPartyIndex(req, role); + if (idx === -1) throw new BadRequestException(`${role} party not found`); + + const party = req.parties[idx]; + if (!party.statement) party.statement = {} as any; + party.statement.description = body.desc; + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "V3_PARTY_DESCRIPTION_SAVED", + actor: { + actorId: new Types.ObjectId(actor.sub), + actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + actorType: "field_expert", + }, + metadata: { partyRole: role }, + } as any); + + await (req as any).save(); + return { requestId: req._id, publicId: req.publicId, partyRole: role }; + } + + async carBodyAccidentTypeFormV3( + requestId: string, + body: CarBodyFormDto, + actor: any, + ) { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + if (req.type !== BlameRequestType.CAR_BODY) { + throw new BadRequestException("CAR_BODY accident type is only for CAR_BODY files."); + } + this.verifyExpertAccessForBlameV2(req, actor); + this.assertBlameV3ExpertInPerson(req); + + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + if (firstIdx === -1) throw new BadRequestException("First party not found"); + const firstParty = req.parties[firstIdx]; + if (!firstParty?.person?.userId) { + throw new BadRequestException( + "Party OTP must be verified before the car-body form.", + ); + } + if (this.hasPartyInquiriesComplete(req, PartyRole.FIRST)) { + throw new BadRequestException( + "Car-body form must be submitted before running inquiries.", + ); + } + + if (!firstParty.carBodyFirstForm) firstParty.carBodyFirstForm = {} as any; + firstParty.carBodyFirstForm.car = body.car; + firstParty.carBodyFirstForm.object = body.object; + + this.pushWorkflowSteps(req, [WorkflowStep.CAR_BODY_ACCIDENT_TYPE]); + req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE; + req.workflow.nextStep = WorkflowStep.FIRST_INITIAL_FORM; + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "V3_CAR_BODY_ACCIDENT_TYPE_SAVED", + actor: { + actorId: new Types.ObjectId(actor.sub), + actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + actorType: "field_expert", + }, + metadata: { car: body.car, object: body.object }, + } as any); + + await (req as any).save(); + return { requestId: req._id, publicId: req.publicId }; + } }