import { Controller, HttpException, InternalServerErrorException, BadRequestException, Param, Query, Post, Patch, Put, Body, UseGuards, Get, UseInterceptors, UploadedFile, } from "@nestjs/common"; import { readFile } from "node:fs/promises"; import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger"; import { FileInterceptor } from "@nestjs/platform-express"; import { diskStorage } from "multer"; import { extname } from "node: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"; 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 { ClaimRequestManagementService } from "./claim-request-management.service"; import { OuterPartCatalogItemDto, SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto, } from "./dto/select-outer-parts-v2.dto"; import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto"; import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto"; import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto"; import { CapturePartV2Dto, CapturePartV2ResponseDto, VideoCaptureV2ResponseDto, } from "./dto/capture-part-v2.dto"; import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto"; import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto"; import { UserRatingDto } from "./dto/user-rating.dto"; import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; @ApiTags("claim-request-management (v2)") @Controller("v2/claim-request-management") @ApiBearerAuth() @UseGuards(GlobalGuard, RolesGuard) @Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR) export class ClaimRequestManagementV2Controller { constructor( private readonly claimRequestManagementService: ClaimRequestManagementService, private readonly mediaPolicyService: MediaPolicyService, ) {} @Get("requests") @ApiOperation({ summary: "Get My Claims (V2)", description: "Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.", }) @ApiResponse({ status: 200, description: "List of user claims", type: GetMyClaimsV2ResponseDto, }) async getMyClaims( @CurrentUser() user: any, @Query() query: ListQueryV2Dto, ): Promise { try { return await this.claimRequestManagementService.getMyClaimsV2( user.sub, user, query, ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to get claims list", ); } } @Get("request/:claimRequestId") @ApiParam({ name: "claimRequestId", description: "The claim case ID (MongoDB ObjectId)", example: "507f1f77bcf86cd799439011", }) @ApiOperation({ summary: "Get Claim Details (V2)", description: "Returns the claim snapshot for **USER** (owner), **FIELD_EXPERT**, or **REGISTRAR** when permitted. Initiating experts/registrars see unmasked money fields; owners get `ownerGuidance`.", }) @ApiResponse({ status: 200, description: "Claim details", type: ClaimDetailsV2ResponseDto, }) @ApiResponse({ status: 403, description: "User is not the claim owner", }) @ApiResponse({ status: 404, description: "Claim not found", }) async getClaimDetails( @Param("claimRequestId") claimRequestId: string, @CurrentUser() user: any, ): Promise { try { return await this.claimRequestManagementService.getClaimDetailsV2( claimRequestId, user.sub, user, ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to get claim details", ); } } /** * V2: Acknowledge a damage-expert resend that only contains instructions (no extra documents or part photos). */ @Post("request/:claimRequestId/expert-resend/acknowledge") @ApiOperation({ summary: "Acknowledge expert resend (instructions only)", description: "Use when `workflow.currentStep` is USER_EXPERT_RESEND and the expert did not list any `resendDocuments` or `resendCarParts`. " + "Returns the claim to WAITING_FOR_DAMAGE_EXPERT. If documents or parts were requested, upload them via the existing upload/capture endpoints instead.", }) @ApiParam({ name: "claimRequestId" }) @ApiResponse({ status: 200, description: "Claim returned to expert queue" }) @ApiResponse({ status: 400, description: "Resend requires uploads or wrong step" }) async acknowledgeExpertResend( @Param("claimRequestId") claimRequestId: string, @CurrentUser() user: any, ) { try { return await this.claimRequestManagementService.acknowledgeExpertResendInstructionsV2( claimRequestId, user.sub, user, ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to acknowledge expert resend", ); } } /** * V2: User objection after expert resend (same intent as v1 PUT …/request/resend/:id/objection). */ @Put("request/:claimRequestId/objection") @ApiOperation({ summary: "Submit user objection (V2)", description: "**Windows:** (1) **Insurer-review:** `ClaimCaseStatus` in **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, with `workflow.currentStep=INSURER_REVIEW` and no recorded **final** `evaluation.ownerInsurerApproval` — including **mixed** priced+factor gate (`NEEDS_REVISION` before priced-line signature for factors) or **final** totals (`claimStatus=APPROVED`). Not allowed while uploading factors (`OWNER_UPLOAD_FACTOR_DOCUMENTS`) or expert validation (`EXPERT_COST_EVALUATION`/`EXPERT_VALIDATING_REPAIR_FACTORS`).\n" + "(2) **Legacy resend:** active expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).\n\n" + "`objectionParts` may only reference **priced** repair lines (`factorNeeded=false`). Factor-only lines cannot be disputed until they have expert pricing.\n\n" + "After **`damageExpertReplyFinal`** exists (final reply following a prior objection), **no second objection** — owner uses **owner-insurer-approval/sign** to accept/reject and close the case.\n\n" + "Stores `evaluation.objection`, clears partial/final owner approval fields, merges `newParts` into `damage.selectedParts`, returns case to `WAITING_FOR_DAMAGE_EXPERT`.", }) @ApiParam({ name: "claimRequestId", description: "The claim case ID (MongoDB ObjectId)", example: "507f1f77bcf86cd799439011", }) @ApiBody({ type: UserObjectionV2Dto }) @ApiResponse({ status: 200, description: "Objection stored" }) @ApiResponse({ status: 400, description: "No active resend or empty payload" }) @ApiResponse({ status: 403, description: "Not the claim owner" }) @ApiResponse({ status: 404, description: "Claim not found" }) @ApiResponse({ status: 409, description: "Objection already submitted" }) async submitUserObjectionV2( @Param("claimRequestId") claimRequestId: string, @Body() body: UserObjectionV2Dto, @CurrentUser() user: any, ) { try { return await this.claimRequestManagementService.handleUserObjectionV2( claimRequestId, body, user.sub, user, ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to submit objection", ); } } /** * V2: User satisfaction rating after the claim case is completed (same intent as v1 PUT …/request/:id/user-rating). */ @Put("request/:claimRequestId/user-rating") @ApiOperation({ summary: "Submit user satisfaction rating (V2)", description: "Only the claim owner (damaged party) may submit. Allowed when `status` is `COMPLETED`. " + "Stores scores on `ClaimCase.userRating` (0–5). One submission per case.", }) @ApiParam({ name: "claimRequestId", description: "The claim case ID (MongoDB ObjectId)", example: "507f1f77bcf86cd799439011", }) @ApiBody({ type: UserRatingDto }) @ApiResponse({ status: 200, description: "Rating saved" }) @ApiResponse({ status: 400, description: "Claim not completed or invalid scores" }) @ApiResponse({ status: 403, description: "Not the claim owner" }) @ApiResponse({ status: 404, description: "Claim not found" }) @ApiResponse({ status: 409, description: "Rating already submitted" }) async addUserRatingV2( @Param("claimRequestId") claimRequestId: string, @Body() ratingDto: UserRatingDto, @CurrentUser() user: any, ) { try { return await this.claimRequestManagementService.addUserRatingV2( claimRequestId, ratingDto, user.sub, user, ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to save rating", ); } } /** * V2: Owner signature — priced-line gate (mixed factors) or final accept/reject. */ @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 priced lines or final claim pricing (owner)", description: "Multipart: `sign`, `agree`, `branchId`. Requires `ClaimCaseStatus` **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, and `workflow.currentStep=INSURER_REVIEW` (not during owner factor upload or `EXPERT_COST_EVALUATION`).\n\n" + "**Phase A — Mixed reply, priced lines only:** `claimStatus=NEEDS_REVISION`, no `evaluation.ownerPricedPartsApproval` yet. `agree=true` records that signature and moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` for factor uploads; `agree=false` rejects the whole case (`REJECTED`).\n\n" + "**Phase B — Final:** `claimStatus=APPROVED`, no `evaluation.ownerInsurerApproval` yet. `agree=true` → `COMPLETED`; `agree=false` → `REJECTED`.\n\n" + "Response may include `phase`: `PRICED_PARTS_FOR_FACTORS` or `FINAL_APPROVAL` for UI state.", }) @ApiBody({ description: "Signature file, agreement, and branch", schema: { type: "object", required: ["sign", "agree", "branchId"], properties: { sign: { type: "string", format: "binary", description: "Signature image" }, agree: { type: "boolean", description: "true to accept expert pricing and complete the claim", }, branchId: { type: "string", description: "Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)", example: "507f1f77bcf86cd799439011", }, }, }, }) @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: DEFAULT_MEDIA_MAX_BYTES }, 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, @Body("branchId") branchId: string, @CurrentUser() user: any, @UploadedFile() sign: Express.Multer.File, ) { if (!Types.ObjectId.isValid(claimRequestId)) { throw new BadRequestException("Invalid claim request id"); } await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image"); const agreed = typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree); try { return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2( claimRequestId, agreed, typeof branchId === "string" ? branchId : "", 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", description: "ID of the completed blame case to create claim from", }) async createClaimFromBlame( @Param("blameRequestId") blameRequestId: string, @CurrentUser() user: any, ) { try { return await this.claimRequestManagementService.createClaimFromBlameV2( blameRequestId, user.sub, ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to create claim request", ); } } /** * V2 API: Select damaged outer car parts (Step 2 of claim workflow) */ @Get("outer-parts-catalog") @ApiOperation({ summary: "Get outer parts catalog (V2)", description: "Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.", }) @ApiResponse({ status: 200, description: "Outer parts catalog", type: [OuterPartCatalogItemDto], }) async getOuterPartsCatalog( @Query("carType") carType?: ClaimVehicleTypeV2, ): Promise { return this.claimRequestManagementService.getOuterPartsCatalogV2(carType); } @Get("branches/:insuranceId") @ApiOperation({ summary: "Get insurer branches (V2)", description: "Returns branch list for a given insurer/client id so frontend can render branch options (name/code/address/city/state) and submit selected branchId in daghi part options.", }) @ApiParam({ name: "insuranceId", description: "Insurer client id (MongoDB ObjectId)", example: "60d5ec49e7b2f8001c8e4d2a", }) @ApiResponse({ status: 200, description: "List of branches for insurer", }) async getInsuranceBranchesV2(@Param("insuranceId") insuranceId: string) { return await this.claimRequestManagementService.retrieveInsuranceBranches( insuranceId, ); } @Get("car-other-part") @ApiOperation({ summary: "Get other (non-body) parts catalog", description: "Returns legacy other-parts catalog used by frontend. Response is parsed JSON.", }) @ApiResponse({ status: 200, description: "Other parts catalog", }) async getCarOtherPartsV2() { const raw = await readFile( `${process.cwd()}/src/static/car-part.json`, "utf-8", ); try { return JSON.parse(raw); } catch { return raw; } } @Patch("select-outer-parts/:claimRequestId") @ApiOperation({ summary: "Select Damaged Outer Car Parts (V2 - Step 2)", description: ` **Workflow Step:** SELECT_OUTER_PARTS (Step 2 of Claim) **Purpose:** User selects which outer car parts (body parts) were damaged in the accident. **Validations:** - Claim must exist - User must be the claim owner (damaged party from blame case) - Current workflow step must be CLAIM_CREATED - Parts array must contain at least 1 part - No duplicate parts allowed - Parts cannot be re-selected once submitted **Valid Outer Parts:** - hood, trunk, roof - front_right_door, front_left_door, rear_right_door, rear_left_door - front_bumper, rear_bumper - front_right_fender, front_left_fender, rear_right_fender, rear_left_fender **After Success:** - Workflow moves to: SELECT_OTHER_PARTS (Step 3) - User can proceed to select non-body parts and provide bank info `, }) @ApiParam({ name: "claimRequestId", description: "The claim case ID (MongoDB ObjectId)", example: "507f1f77bcf86cd799439011", }) @ApiBody({ type: SelectOuterPartsV2Dto, description: "Selected vehicle type + selected outer part IDs from catalog", examples: { example1: { summary: "Sedan - minor front damage", value: { carType: "sedan", selectedPartIds: [19, 21, 16], }, }, example2: { summary: "SUV - left side impact", value: { carType: "suv", selectedPartIds: [102, 103, 104, 107], }, }, example3: { summary: "Hatchback - rear-end collision", value: { carType: "hatchback", selectedPartIds: [225, 226, 210], }, }, example4: { summary: "Pickup - two sides + roof", value: { carType: "pickup", selectedPartIds: [319, 312, 330], }, }, }, }) @ApiResponse({ status: 200, description: "Outer parts selected successfully", type: SelectOuterPartsV2ResponseDto, }) @ApiResponse({ status: 400, description: "Invalid workflow step or validation failed", }) @ApiResponse({ status: 403, description: "User is not the claim owner", }) @ApiResponse({ status: 404, description: "Claim case not found", }) @ApiResponse({ status: 409, description: "Outer parts already selected", }) async selectOuterParts( @Param("claimRequestId") claimRequestId: string, @Body() body: SelectOuterPartsV2Dto, @CurrentUser() user: any, ): Promise { try { return await this.claimRequestManagementService.selectOuterPartsV2( claimRequestId, body, user.sub, user, ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to select outer parts", ); } } /** * V2 API: Select other damaged parts and provide bank information (Step 3 of claim workflow) */ @Patch("select-other-parts/:claimRequestId") @ApiOperation({ summary: "Select Other Parts & Bank Information (V2 - Step 3)", description: ` **Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim) **Purpose:** User selects non-body damaged parts and provides bank information for payment. Optional: upload car green card file in the same step. **Validations:** - Claim must exist - User must be the claim owner (damaged party from blame case) - Current workflow step must be SELECT_OTHER_PARTS - Bank information must not have been submitted previously - Sheba (sheba) accepted as IR + 24 digits or only 24 digits - National code must be exactly 10 digits **Valid Other Parts (Optional):** - engine, suspension, brake_system, electrical - radiator, transmission, exhaust - headlight, taillight, mirror, glass **After Success:** - Workflow moves to: CAPTURE_PART_DAMAGES (Step 4) - User captures car angles and damaged-part photos, then uploads required documents (Step 5) `, }) @ApiParam({ name: "claimRequestId", description: "The claim case ID (MongoDB ObjectId)", 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, `other-parts-${unique}${ex}`); }, }), }), ) @ApiBody({ description: "Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer` like THIRD_PARTY flow. Optional file can be uploaded as car green card.", schema: { type: "object", properties: { otherParts: { oneOf: [ { type: "array", items: { type: "string" } }, { type: "string", description: "JSON string array for multipart" }, ], example: ["engine", "suspension"], }, sheba: { type: "string", example: "IR123456789012345678901234" }, nationalCodeOfInsurer: { type: "string", example: "1234567890" }, file: { type: "string", format: "binary" }, }, required: ["sheba", "nationalCodeOfInsurer"], }, }) @ApiResponse({ status: 200, description: "Other parts and bank information saved successfully", type: SelectOtherPartsV2ResponseDto, }) @ApiResponse({ status: 400, description: "Invalid workflow step or validation failed", }) @ApiResponse({ status: 403, description: "User is not the claim owner", }) @ApiResponse({ status: 404, description: "Claim case not found", }) @ApiResponse({ status: 409, description: "Bank information already submitted", }) async selectOtherParts( @Param("claimRequestId") claimRequestId: string, @Body() body: SelectOtherPartsV2Dto, @CurrentUser() user: any, @UploadedFile() file?: Express.Multer.File, ): Promise { // Green-card photo is optional here — the helper no-ops on missing file. await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); try { return await this.claimRequestManagementService.selectOtherPartsV2( claimRequestId, body, user.sub, user, file, ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to select other parts", ); } } /** * V2 API: Get capture requirements (documents, angles, parts) */ @Get("capture-requirements/:claimRequestId") @ApiOperation({ summary: "Get Capture Requirements (V2)", description: ` **Get list of what needs to be captured:** - Required documents (10 remaining at the documents step for third-party; 3 damaged-party items should be uploaded during capture — see \`preferUploadDuringCapture\` on each item) - Car angles (4 items: front, back, left, right) - Damaged parts (based on selected outer parts) Returns status of each item (uploaded/captured or not). **V2 order (enforced by API):** During \`CAPTURE_PART_DAMAGES\`, (1) all damaged-part photos, (2) four car angles, (3) chassis/engine/metal-plate via upload-document, then walk-around video. Remaining documents in \`UPLOAD_REQUIRED_DOCUMENTS\`. Use \`captureSequencePhase\` / \`captureSequenceHint\` in the response. `, }) @ApiParam({ name: "claimRequestId", description: "The claim case ID (MongoDB ObjectId)", example: "507f1f77bcf86cd799439011", }) @ApiResponse({ status: 200, description: "Capture requirements retrieved successfully", type: GetCaptureRequirementsV2ResponseDto, }) @ApiResponse({ status: 403, description: "User is not the claim owner", }) @ApiResponse({ status: 404, description: "Claim case not found", }) async getCaptureRequirements( @Param("claimRequestId") claimRequestId: string, @CurrentUser() user: any, ): Promise { try { return await this.claimRequestManagementService.getCaptureRequirementsV2( claimRequestId, user.sub, user, ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to get capture requirements", ); } } /** * V2 API: Upload required document */ @Post("upload-document/:claimRequestId") @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); const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`; callback(null, filename); }, }), }), ) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Upload Required Document (V2 - Step 5)", description: ` **Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim) **Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements): - damaged_driving_license_front/back - damaged_chassis_number, damaged_engine_photo - damaged_car_card_front/back, damaged_metal_plate - guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY) **When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE and the claim is ready for damage expert review. **Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to upload documents on behalf of the damaged party. `, }) @ApiParam({ name: "claimRequestId", description: "The claim case ID", example: "507f1f77bcf86cd799439011", }) @ApiBody({ schema: { type: "object", required: ["documentKey", "file"], properties: { documentKey: { type: "string", enum: [ "damaged_driving_license_front", "damaged_driving_license_back", "damaged_chassis_number", "damaged_engine_photo", "damaged_car_card_front", "damaged_car_card_back", "damaged_metal_plate", "guilty_driving_license_front", "guilty_driving_license_back", "guilty_car_card_front", "guilty_car_card_back", "guilty_metal_plate", ], example: "damaged_driving_license_front", }, file: { type: "string", format: "binary", }, }, }, }) @ApiResponse({ status: 200, description: "Document uploaded successfully", type: UploadRequiredDocumentV2ResponseDto, }) @ApiResponse({ status: 400, description: "Invalid workflow step", }) @ApiResponse({ status: 409, description: "Document already uploaded", }) async uploadDocument( @Param("claimRequestId") claimRequestId: string, @Body() body: UploadRequiredDocumentV2Dto, @UploadedFile() file: Express.Multer.File, @CurrentUser() user: any, ): Promise { await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); try { return await this.claimRequestManagementService.uploadRequiredDocumentV2( claimRequestId, body, file, user.sub, user, ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to upload document", ); } } /** * V2 API: Capture car angle or damaged part */ @Post("capture-part/:claimRequestId") @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); const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`; callback(null, filename); }, }), }), ) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Capture Car Angle or Damaged Part (V2 - Step 4)", description: ` **Workflow Step:** CAPTURE_PART_DAMAGES (Step 4 of Claim) **Capture types:** 1. **angle**: Car angles (front, back, left, right) - 4 required 2. **part**: Damaged parts based on selectedParts from Step 2 **When all captures are complete (parts, angles, capture-phase docs):** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5). Angles are blocked until all parts are captured; capture-phase documents are blocked until all angles are captured. **Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to capture photos on behalf of the damaged party. `, }) @ApiParam({ name: "claimRequestId", description: "The claim case ID", example: "507f1f77bcf86cd799439011", }) @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", }, }, }, }) @ApiResponse({ status: 200, description: "Capture saved successfully", type: CapturePartV2ResponseDto, }) @ApiResponse({ status: 400, description: "Invalid workflow step", }) async capturePart( @Param("claimRequestId") claimRequestId: string, @Body() body: CapturePartV2Dto, @UploadedFile() file: Express.Multer.File, @CurrentUser() user: any, ): Promise { await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); try { return await this.claimRequestManagementService.capturePartV2( claimRequestId, body, file, user.sub, user, ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to capture part", ); } } /** * V2: Upload repair factor for a part (same intent as v1 PATCH …/request/reply/:claimRequestId/:partId/upload-factor). */ @Patch("request/reply/:claimRequestId/:partId/upload-factor") @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Upload repair factor file for a factor-needed part (V2)", description: "Part must have `factorNeeded: true` on the active reply (`evaluation.damageExpertReply` or `evaluation.damageExpertReplyFinal`). One file per part.\n\n" + "**Requires:** `ClaimCaseStatus` **`OWNER_REPAIR_FACTOR_UPLOAD_PENDING`** or **`INSURER_REVIEW_MIXED_FACTORS_PENDING`** (or legacy **`WAITING_FOR_INSURER_APPROVAL`**), `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`.\n\n" + "**Mixed priced+factor replies:** owner must complete **owner-insurer-approval/sign** (priced-line phase) first so `evaluation.ownerPricedPartsApproval` exists.\n\n" + "When every `factorNeeded` line has `factorLink`, the case moves to **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION` for damage expert validation.", }) @ApiParam({ name: "claimRequestId", description: "Claim case ID" }) @ApiParam({ name: "partId", description: "Part id from expert reply" }) @ApiBody({ schema: { type: "object", properties: { file: { type: "string", format: "binary" }, }, }, }) @UseInterceptors( FileInterceptor("file", { storage: diskStorage({ destination: "./files/claim-factors", filename: (req, file, callback) => { const unique = Date.now(); callback(null, `-${unique}-${file.originalname}`); }, }), limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, }), ) async uploadFactorForPartV2( @Param("claimRequestId") claimRequestId: string, @Param("partId") partId: string, @UploadedFile() file: Express.Multer.File, @CurrentUser() user: any, ) { await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); try { return await this.claimRequestManagementService.uploadClaimFactorV2( claimRequestId, partId, file, user.sub, user, ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to upload factor", ); } } /** * V2 API: Upload car walk-around video (same behavior as v1 PATCH claim-request-management/car-capture/:id) */ @ApiBody({ schema: { type: "object", properties: { file: { type: "string", format: "binary" }, }, }, }) @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); const filename = `claim-video-${unique}${ex}`; callback(null, filename); }, }), }), ) @ApiConsumes("multipart/form-data") @Patch("car-capture/:claimRequestId") @ApiOperation({ summary: "Upload car walk-around video (V2)", description: "Multipart upload of the car capture video during CAPTURE_PART_DAMAGES. " + "Stores file metadata in `claim-video-capture` and sets `ClaimCase.media.videoCaptureId`. " + "Only one video per claim; path is stored on the video document (not duplicated on the case).", }) @ApiParam({ name: "claimRequestId", description: "The claim case ID (MongoDB ObjectId)", example: "507f1f77bcf86cd799439011", }) @ApiResponse({ status: 200, description: "Video uploaded successfully", type: VideoCaptureV2ResponseDto, }) @ApiResponse({ status: 400, description: "Wrong workflow step or missing file" }) @ApiResponse({ status: 403, description: "Not the claim owner" }) @ApiResponse({ status: 404, description: "Claim not found" }) @ApiResponse({ status: 409, description: "Video already uploaded" }) async captureVideoCaptureV2( @Param("claimRequestId") claimRequestId: string, @UploadedFile("file") file: Express.Multer.File, @CurrentUser() user: any, ): Promise { await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video"); try { return await this.claimRequestManagementService.setVideoCaptureV2( claimRequestId, file, user.sub, user, ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to upload car capture video", ); } } }