Files
yara724-api/src/claim-request-management/expert-initiated-claim.mirror.controller.ts
2026-09-22 11:37:21 +03:30

651 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFile } from "node:fs/promises";
import { extname } from "node:path";
import {
Body,
Controller,
Get,
HttpException,
InternalServerErrorException,
Param,
Patch,
Post,
Put,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiParam,
ApiResponse,
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 { 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 {
UploadRequiredDocumentV2Dto,
UploadRequiredDocumentV2ResponseDto,
} from "./dto/upload-document-v2.dto";
import {
CapturePartV2Dto,
CapturePartV2ResponseDto,
} from "./dto/capture-part-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
import { Role } from "src/common/auth/enums";
/**
* Expert-initiated claim flow that mirrors the normal user claim API
* (`v2/claim-request-management`) one-to-one. The frontend reuses the same
* claim pages by only swapping the route prefix:
*
* `v2/claim-request-management/*` -> `v2/expert-initiated/claim-request-management/*`
*
* The field expert fills the claim (parts, captures, documents) on behalf of the
* damaged party for a claim created from an expert-initiated IN_PERSON blame.
* After the expert submits the data, the file follows the exact normal review
* lifecycle: the damage expert prices it, and the owner can later object / resend
* from their own account through the normal user endpoints.
*/
@ApiTags("expert-initiated claim (mirror v2)")
@Controller("v2/expert-initiated/claim-request-management")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.FIELD_EXPERT, RoleEnum.FILE_MAKER, RoleEnum.FILE_REVIEWER)
export class ExpertInitiatedClaimMirrorController {
constructor(
private readonly claimRequestManagementService: ClaimRequestManagementService,
private readonly mediaPolicyService: MediaPolicyService,
) {}
@Post("create-from-blame/:blameRequestId")
@ApiParam({ name: "blameRequestId" })
@ApiOperation({
summary:
"[Expert mirror] Create claim from expert-initiated IN_PERSON blame",
description:
"Blame must be COMPLETED. Creates a ClaimCase owned by the damaged (non-guilty) party.",
})
async createFromBlame(
@Param("blameRequestId") blameRequestId: string,
@CurrentUser() expert: any,
) {
return this.claimRequestManagementService.createClaimFromBlameForExpertV2(
blameRequestId,
expert,
);
}
@Get("outer-parts-catalog")
@ApiOperation({
summary: "Get outer parts catalog (V2)",
description:
"Returns the Fanavaran car-components list. All vehicle types share the same catalog.",
})
@ApiResponse({
status: 200,
description: "Outer parts catalog",
type: [OuterPartCatalogItemDto],
})
async getOuterPartsCatalog() {
return await this.claimRequestManagementService.getOuterPartsCatalogV2();
}
@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 getCarOtherParts() {
const raw = await readFile(
`${process.cwd()}/src/static/car-part.json`,
"utf-8",
);
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
@Get("branches/:insuranceId")
@ApiParam({
name: "insuranceId",
description: "Insurer client id (MongoDB ObjectId)",
example: "60d5ec49e7b2f8001c8e4d2a",
})
@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.",
})
@ApiResponse({ status: 200, description: "List of branches for insurer" })
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
return this.claimRequestManagementService.retrieveInsuranceBranches(
insuranceId,
);
}
@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:** Expert selects which outer car parts (body parts) were damaged on behalf of the damaged party.
**Validations:**
- Claim must exist
- 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
**After Success:**
- Workflow moves to: SELECT_OTHER_PARTS (Step 3)
`,
})
@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: 404, description: "Claim case not found" })
@ApiResponse({ status: 409, description: "Outer parts already selected" })
async selectOuterParts(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOuterPartsV2Dto,
@CurrentUser() expert: any,
): Promise<SelectOuterPartsV2ResponseDto> {
return this.claimRequestManagementService.selectOuterPartsV2(
claimRequestId,
body,
expert.sub,
expert,
);
}
@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:** Expert selects non-body damaged parts and provides bank information for payment on behalf of the damaged party.
Optional: upload car green card file in the same step.
**Validations:**
- Claim must exist
- 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)
`,
})
@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`. 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: 404, description: "Claim case not found" })
@ApiResponse({
status: 409,
description: "Bank information already submitted",
})
async selectOtherParts(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOtherPartsV2Dto,
@CurrentUser() expert: any,
@UploadedFile() file?: Express.Multer.File,
): Promise<SelectOtherPartsV2ResponseDto> {
// Green-card photo is optional here — the helper no-ops on missing file.
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.selectOtherPartsV2(
claimRequestId,
body,
expert.sub,
expert,
file,
);
}
@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() expert: any,
): Promise<GetCaptureRequirementsV2ResponseDto> {
return this.claimRequestManagementService.getCaptureRequirementsV2(
claimRequestId,
expert.sub,
expert,
);
}
@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.
`,
})
@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",
"accident_sketch",
],
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() expert: any,
): Promise<UploadRequiredDocumentV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.uploadRequiredDocumentV2(
claimRequestId,
body,
file,
expert.sub,
expert,
);
}
@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.
`,
})
@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() expert: any,
): Promise<CapturePartV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.capturePartV2(
claimRequestId,
body,
file,
expert.sub,
expert,
);
}
// ─── Mixed-factor priced-line signature ──────────────────────────────────────
@Put("claim-sign/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiConsumes("multipart/form-data")
@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, false to reject",
},
},
},
})
@ApiOperation({
summary:
"Priced-line acceptance before factor uploads (Flow 3 — expert acts on behalf of user)",
description:
"For mixed priced/factor claims only, the field expert records the damaged party's " +
"acceptance of priced lines before factor uploads. V2–V5 no longer require a final " +
"owner signature: they complete after expert work. The expert's identity is resolved " +
"to the claim owner via `resolveClaimEffectiveUserId`.",
})
@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 submitOwnerSign(
@Param("claimRequestId") claimRequestId: string,
@Body("agree") agree: string | boolean,
@CurrentUser() expert: any,
@UploadedFile() sign: Express.Multer.File,
) {
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,
sign,
expert.sub,
expert,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit signature",
);
}
}
@Patch("car-capture/:claimRequestId")
@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, `claim-video-${unique}${ex}`);
},
}),
}),
)
@ApiParam({
name: "claimRequestId",
description: "The claim case ID",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({
schema: {
type: "object",
required: ["file"],
properties: { file: { type: "string", format: "binary" } },
},
})
@ApiOperation({
summary: "Upload Car Walk-Around Video (V2 - Step 4b)",
description:
"Upload a walk-around video of the damaged car during the capture phase. Allowed at any point after outer parts are selected.",
})
@ApiResponse({ status: 200, description: "Video uploaded successfully" })
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.setVideoCaptureV2(
claimRequestId,
file,
expert.sub,
expert,
);
}
}