diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 625cfbc..d6bcb67 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -5131,6 +5131,152 @@ export class ClaimRequestManagementService { return userRating; } + /** + * V2: Claim owner signs final priced expert reply (single-party), same intent as blame + * `PUT …/sign/:requestId` — only when case status is WAITING_FOR_INSURER_APPROVAL and + * workflow is at INSURER_REVIEW (not factor-validation queue at EXPERT_COST_EVALUATION). + */ + async submitOwnerInsurerApprovalSignV2( + claimRequestId: string, + agree: boolean, + signFile: Express.Multer.File, + currentUserId: string, + actor?: { sub: string; role?: string; fullName?: string }, + ): Promise<{ + message: string; + claimRequestId: string; + status: ClaimCaseStatus; + claimStatus: ClaimStatus; + currentStep?: ClaimWorkflowStep; + accepted: boolean; + }> { + if (!Types.ObjectId.isValid(claimRequestId)) { + throw new BadRequestException("Invalid claim request id"); + } + if (!signFile) { + throw new BadRequestException("A signature file is required"); + } + + const claimCase = await this.claimCaseDbService.findById(claimRequestId); + if (!claimCase) { + throw new NotFoundException("Claim request not found"); + } + + const effectiveUserId = await this.resolveClaimEffectiveUserId( + claimCase, + currentUserId, + actor?.role, + ); + if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) { + throw new ForbiddenException( + "Only the claim owner (damaged party) can sign at this step.", + ); + } + + if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL) { + throw new BadRequestException( + `Claim is not waiting for your approval signature. Current status: ${String(claimCase.status)}`, + ); + } + if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.INSURER_REVIEW) { + throw new BadRequestException( + `Signing is only allowed at workflow step ${ClaimWorkflowStep.INSURER_REVIEW}. Current: ${String(claimCase.workflow?.currentStep ?? "")}`, + ); + } + if (claimCase.claimStatus !== ClaimStatus.APPROVED) { + throw new BadRequestException( + `Claim must be insurer-approved before owner signature. Current claimStatus: ${String(claimCase.claimStatus)}`, + ); + } + + if (claimCase.evaluation?.ownerInsurerApproval?.signedAt) { + throw new ConflictException("You have already submitted a signature for this claim."); + } + + const activeReply = + claimCase.evaluation?.damageExpertReplyFinal || + claimCase.evaluation?.damageExpertReply; + if (!activeReply?.submittedAt) { + throw new BadRequestException("No expert reply is available to sign off on yet."); + } + + const signDoc = await this.claimSignDbService.create({ + fileName: signFile.filename, + userId: effectiveUserId, + path: signFile.path, + requestId: new Types.ObjectId(claimRequestId), + } as any); + const signId = new Types.ObjectId(String((signDoc as any)._id)); + const signedAt = new Date(); + const approvalPayload = { + agree, + signDetailId: signId, + signedAt, + }; + + const historyActor = { + actorId: new Types.ObjectId(effectiveUserId), + actorName: claimCase.owner?.fullName || "User", + actorType: "user", + }; + + if (!agree) { + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + $set: { + status: ClaimCaseStatus.REJECTED, + claimStatus: ClaimStatus.REJECTED, + "evaluation.ownerInsurerApproval": approvalPayload, + "workflow.nextStep": null, + }, + $push: { + history: { + type: "OWNER_REJECTED_INSURER_APPROVAL_PRICING", + actor: historyActor, + timestamp: signedAt, + metadata: {}, + }, + }, + }); + return { + message: + "Your rejection has been recorded. This claim is marked rejected and will not proceed.", + claimRequestId, + status: ClaimCaseStatus.REJECTED, + claimStatus: ClaimStatus.REJECTED, + currentStep: claimCase.workflow?.currentStep, + accepted: false, + }; + } + + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + $set: { + status: ClaimCaseStatus.COMPLETED, + claimStatus: ClaimStatus.APPROVED, + "evaluation.ownerInsurerApproval": approvalPayload, + "workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED, + "workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED, + }, + $push: { + "workflow.completedSteps": ClaimWorkflowStep.INSURER_REVIEW, + history: { + type: "OWNER_SIGNED_INSURER_APPROVAL", + actor: historyActor, + timestamp: signedAt, + metadata: {}, + }, + }, + }); + + return { + message: "Your signature has been recorded. The claim is completed.", + claimRequestId, + status: ClaimCaseStatus.COMPLETED, + claimStatus: ClaimStatus.APPROVED, + currentStep: ClaimWorkflowStep.CLAIM_COMPLETED, + accepted: true, + }; + } + /** * V2 API: Get list of claims for current user (or for FIELD_EXPERT: claims from their expert-initiated IN_PERSON blame files). */ diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts index f291af1..4a315b1 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -2,6 +2,7 @@ import { Controller, HttpException, InternalServerErrorException, + BadRequestException, Param, Query, Post, @@ -18,6 +19,7 @@ import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, A import { FileInterceptor } from "@nestjs/platform-express"; import { diskStorage } from "multer"; import { extname } from "path"; +import { Types } from "mongoose"; import { GlobalGuard } from "src/auth/guards/global.guard"; import { RolesGuard } from "src/auth/guards/role.guard"; import { Roles } from "src/decorators/roles.decorator"; @@ -230,6 +232,83 @@ export class ClaimRequestManagementV2Controller { } } + /** + * V2: Owner signs acceptance of final expert pricing (WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW). + */ + @Put("request/:claimRequestId/owner-insurer-approval/sign") + @ApiParam({ + name: "claimRequestId", + description: "Claim case ID (MongoDB ObjectId)", + example: "507f1f77bcf86cd799439011", + }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "Sign final claim pricing (owner)", + description: + "Multipart: `sign` (signature image) and `agree` (boolean). Allowed only when `status` is WAITING_FOR_INSURER_APPROVAL, " + + "`workflow.currentStep` is INSURER_REVIEW (not EXPERT_COST_EVALUATION), and `claimStatus` is APPROVED. " + + "If `agree` is true, the case moves to COMPLETED; if false, to REJECTED.", + }) + @ApiBody({ + description: "Signature file and agreement", + schema: { + type: "object", + required: ["sign", "agree"], + properties: { + sign: { type: "string", format: "binary", description: "Signature image" }, + agree: { + type: "boolean", + description: "true to accept expert pricing and complete the claim", + }, + }, + }, + }) + @ApiResponse({ status: 200, description: "Signature stored; claim completed or rejected" }) + @ApiResponse({ status: 400, description: "Wrong step/status or missing file" }) + @ApiResponse({ status: 403, description: "Not the claim owner" }) + @ApiResponse({ status: 404, description: "Claim not found" }) + @ApiResponse({ status: 409, description: "Already signed" }) + @UseInterceptors( + FileInterceptor("sign", { + limits: { fileSize: 10 * 1024 * 1024 }, + storage: diskStorage({ + destination: "./files/claim-sign", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + const base = file.originalname.split(/[.,\s-]/)[0] || "sign"; + callback(null, `${base}-${unique}${ex}`); + }, + }), + }), + ) + async submitOwnerInsurerApprovalSignV2( + @Param("claimRequestId") claimRequestId: string, + @Body("agree") agree: string | boolean, + @CurrentUser() user: any, + @UploadedFile() sign: Express.Multer.File, + ) { + if (!Types.ObjectId.isValid(claimRequestId)) { + throw new BadRequestException("Invalid claim request id"); + } + const agreed = + typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree); + try { + return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2( + claimRequestId, + agreed, + sign, + user.sub, + user, + ); + } catch (error) { + if (error instanceof HttpException) throw error; + throw new InternalServerErrorException( + error instanceof Error ? error.message : "Failed to submit signature", + ); + } + } + @Post("create-from-blame/:blameRequestId") @ApiParam({ name: "blameRequestId", diff --git a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts index 8105e57..770aea6 100644 --- a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts @@ -76,6 +76,21 @@ export class ClaimUserComment { export const ClaimUserCommentSchema = SchemaFactory.createForClass(ClaimUserComment); +/** Owner acceptance + signature after expert pricing (WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW). */ +@Schema({ _id: false }) +export class ClaimOwnerInsurerApproval { + @Prop({ type: Boolean, required: true }) + agree: boolean; + + @Prop({ type: Types.ObjectId }) + signDetailId?: Types.ObjectId; + + @Prop({ type: Date, default: () => new Date() }) + signedAt?: Date; +} +export const ClaimOwnerInsurerApprovalSchema = + SchemaFactory.createForClass(ClaimOwnerInsurerApproval); + @Schema({ _id: false }) export class ClaimExpertReply { @Prop({ type: String }) @@ -254,6 +269,9 @@ export class ClaimEvaluation { @Prop({ type: ClaimFileRatingSchema }) rating?: ClaimFileRating; + @Prop({ type: ClaimOwnerInsurerApprovalSchema }) + ownerInsurerApproval?: ClaimOwnerInsurerApproval; + @Prop({ type: String }) visitLocation?: string;