forked from Yara724/api
Added registrar mirrored
This commit is contained in:
@@ -8,6 +8,7 @@ import { ClaimRequestManagementController } from "./claim-request-management.con
|
||||
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller";
|
||||
import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller";
|
||||
import { ExpertInitiatedClaimMirrorController } from "./expert-initiated-claim.mirror.controller";
|
||||
import { RegistrarClaimMirrorController } from "./registrar-claim.mirror.controller";
|
||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
|
||||
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
|
||||
@@ -99,6 +100,7 @@ import { HttpModule } from "@nestjs/axios";
|
||||
ClaimRequestManagementV2Controller,
|
||||
RegistrarClaimV1Controller,
|
||||
ExpertInitiatedClaimMirrorController,
|
||||
RegistrarClaimMirrorController,
|
||||
],
|
||||
exports: [
|
||||
ClaimRequestManagementService,
|
||||
|
||||
@@ -6536,6 +6536,28 @@ export class ClaimRequestManagementService {
|
||||
},
|
||||
{ lean: true },
|
||||
);
|
||||
} else if (actor?.role === RoleEnum.REGISTRAR) {
|
||||
const registrarBlameIds = await this.blameRequestDbService
|
||||
.find(
|
||||
{
|
||||
registrarInitiated: true,
|
||||
creationMethod: "IN_PERSON",
|
||||
initiatedByRegistrarId: new Types.ObjectId(currentUserId),
|
||||
},
|
||||
{ select: "_id", lean: true },
|
||||
)
|
||||
.then((docs) => docs.map((d) => (d as any)._id));
|
||||
claims = await this.claimCaseDbService.find(
|
||||
{
|
||||
$or: [
|
||||
{ "owner.userId": new Types.ObjectId(currentUserId) },
|
||||
...(registrarBlameIds.length
|
||||
? [{ blameRequestId: { $in: registrarBlameIds } }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{ lean: true },
|
||||
);
|
||||
} else {
|
||||
claims = await this.claimCaseDbService.find(
|
||||
{ "owner.userId": new Types.ObjectId(currentUserId) },
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { extname } from "node:path";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
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 { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Registrar 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/registrar/claim-request-management/*`
|
||||
*
|
||||
* The registrar fills the claim (parts, captures, documents) on behalf of the
|
||||
* damaged party for a claim created from a registrar-initiated IN_PERSON blame.
|
||||
* After submission the file follows the exact normal review lifecycle:
|
||||
* the damage expert prices it, and the owner can later object/resend.
|
||||
* The registrar's job ends here — no reviewing panel is needed.
|
||||
*/
|
||||
@ApiTags("registrar claim (mirror v2)")
|
||||
@Controller("v2/registrar/claim-request-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.REGISTRAR)
|
||||
export class RegistrarClaimMirrorController {
|
||||
constructor(
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
private readonly mediaPolicyService: MediaPolicyService,
|
||||
) {}
|
||||
|
||||
@Post("create-from-blame/:blameRequestId")
|
||||
@ApiParam({ name: "blameRequestId" })
|
||||
@ApiOperation({
|
||||
summary: "[Registrar mirror] Create claim from registrar-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() registrar: any,
|
||||
) {
|
||||
return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1(
|
||||
blameRequestId,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("requests")
|
||||
@ApiOperation({ summary: "[Registrar mirror] List my (in-person) claims" })
|
||||
async getMyClaims(
|
||||
@CurrentUser() registrar: any,
|
||||
@Query() query: ListQueryV2Dto,
|
||||
) {
|
||||
return this.claimRequestManagementService.getMyClaimsV2(
|
||||
registrar.sub,
|
||||
registrar,
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
@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) {
|
||||
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
|
||||
}
|
||||
|
||||
@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.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "List of branches for insurer" })
|
||||
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||
return this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||
insuranceId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("request/:claimRequestId")
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiOperation({ summary: "[Registrar mirror] Get claim details" })
|
||||
async getClaimDetails(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
return this.claimRequestManagementService.getClaimDetailsV2(
|
||||
claimRequestId,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@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:** Registrar selects which outer car parts (body parts) were damaged on behalf of the damaged party.
|
||||
|
||||
**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() registrar: any,
|
||||
): Promise<SelectOuterPartsV2ResponseDto> {
|
||||
return this.claimRequestManagementService.selectOuterPartsV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@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:** Registrar selects non-body damaged parts and provides bank information on behalf of the damaged party.
|
||||
Optional: upload car green card file in the same step.
|
||||
|
||||
**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() registrar: any,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
return this.claimRequestManagementService.selectOtherPartsV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
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)
|
||||
- Car angles (4 items: front, back, left, right)
|
||||
- Damaged parts (based on selected outer parts)
|
||||
|
||||
Returns status of each item (uploaded/captured or not).
|
||||
`,
|
||||
})
|
||||
@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: "Access denied" })
|
||||
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||
async getCaptureRequirements(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() registrar: any,
|
||||
): Promise<GetCaptureRequirementsV2ResponseDto> {
|
||||
return this.claimRequestManagementService.getCaptureRequirementsV2(
|
||||
claimRequestId,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@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.
|
||||
`,
|
||||
})
|
||||
@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() registrar: any,
|
||||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
return this.claimRequestManagementService.uploadRequiredDocumentV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@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:** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5).
|
||||
`,
|
||||
})
|
||||
@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() registrar: any,
|
||||
): Promise<CapturePartV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
return this.claimRequestManagementService.capturePartV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@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.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Video uploaded successfully" })
|
||||
async carCapture(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@UploadedFile("file") file: Express.Multer.File,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
|
||||
return this.claimRequestManagementService.setVideoCaptureV2(
|
||||
claimRequestId,
|
||||
file,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { StringValue } from "ms";
|
||||
import { AuthGuard, RolesGuard } from "./guards";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { HashService } from "./providers";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -25,6 +26,7 @@ import { AuthService } from "./auth.service";
|
||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||
AuthService,
|
||||
HashService,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
8
src/common/auth/constants/hash.constants.ts
Normal file
8
src/common/auth/constants/hash.constants.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ScryptOptions } from "node:crypto";
|
||||
|
||||
export const KEY_LENGTH = 64;
|
||||
export const SCRYPT_PARAMS: ScryptOptions = {
|
||||
N: 19456, // CPU/memory cost
|
||||
r: 8, // block size
|
||||
p: 1, // parallelization
|
||||
};
|
||||
68
src/common/auth/providers/hash.provider.ts
Normal file
68
src/common/auth/providers/hash.provider.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
BinaryLike,
|
||||
randomBytes,
|
||||
scrypt,
|
||||
ScryptOptions,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { KEY_LENGTH, SCRYPT_PARAMS } from "../constants/hash.constants";
|
||||
|
||||
const scryptAsync = promisify<
|
||||
BinaryLike,
|
||||
BinaryLike,
|
||||
number,
|
||||
ScryptOptions,
|
||||
Buffer
|
||||
>(scrypt);
|
||||
|
||||
@Injectable()
|
||||
export class HashService {
|
||||
private readonly pepper: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.pepper = this.configService.get<string>("HASH_PEPPER");
|
||||
}
|
||||
|
||||
private applyPepper(input: string): string {
|
||||
return this.pepper ? `${input}${this.pepper}` : input;
|
||||
}
|
||||
|
||||
private generateSalt(bytes: number = 16): string {
|
||||
return randomBytes(bytes).toString("hex");
|
||||
}
|
||||
|
||||
private async scrypt(password: string): Promise<[string, string]> {
|
||||
const salt = this.generateSalt();
|
||||
const pepperedInput = this.applyPepper(password);
|
||||
const hashedPassword = await scryptAsync(
|
||||
pepperedInput,
|
||||
salt,
|
||||
KEY_LENGTH,
|
||||
SCRYPT_PARAMS,
|
||||
);
|
||||
|
||||
return [salt, hashedPassword.toString("hex")];
|
||||
}
|
||||
|
||||
private async verifyScrypt(
|
||||
password: string,
|
||||
salt: string,
|
||||
hashedPassword: string,
|
||||
): Promise<boolean> {
|
||||
const pepperedInput = this.applyPepper(password);
|
||||
const derived = await scryptAsync(
|
||||
pepperedInput,
|
||||
salt,
|
||||
KEY_LENGTH,
|
||||
SCRYPT_PARAMS,
|
||||
);
|
||||
const storedBuffer = Buffer.from(hashedPassword, "hex");
|
||||
|
||||
if (derived.length !== storedBuffer.length) return false;
|
||||
|
||||
return timingSafeEqual(derived, storedBuffer);
|
||||
}
|
||||
}
|
||||
1
src/common/auth/providers/index.ts
Normal file
1
src/common/auth/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { HashService } from "./hash.provider";
|
||||
@@ -1,7 +1,6 @@
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
role: string;
|
||||
clientId?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
IsBooleanString,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
IsUrl,
|
||||
@@ -79,10 +80,8 @@ export class EnvironmentVariables {
|
||||
JWT_EXPIRY: StringValue;
|
||||
|
||||
// --------------------------------------------------------- //
|
||||
// @IsString({ message: "HASH_PEPPER must be string" })
|
||||
// @MinLength(32, {
|
||||
// message: "HASH_PEPPER must be at least 32 characters",
|
||||
// })
|
||||
// HASH_PEPPER: string;
|
||||
@IsString({ message: "HASH_PEPPER must be string" })
|
||||
@IsOptional()
|
||||
HASH_PEPPER?: string;
|
||||
// --------------------------------------------------------- //
|
||||
}
|
||||
|
||||
@@ -150,60 +150,6 @@ export class ExpertInitiatedBlameMirrorController {
|
||||
);
|
||||
}
|
||||
|
||||
// @Post("send-party-otps/:requestId")
|
||||
// @ApiParam({ name: "requestId" })
|
||||
// @ApiBody({ type: SendPartyOtpsDto })
|
||||
// @ApiOperation({
|
||||
// summary: "[Expert mirror] Send OTP to party/parties (IN_PERSON)",
|
||||
// description:
|
||||
// "Bind the parties' user accounts before filling their data. Required so guilt and the claim owner can be resolved.",
|
||||
// })
|
||||
// async sendPartyOtps(
|
||||
// @CurrentUser() expert: any,
|
||||
// @Param("requestId") requestId: string,
|
||||
// @Body() dto: SendPartyOtpsDto,
|
||||
// ) {
|
||||
// return this.requestManagementService.sendPartyOtpsV2(expert, requestId, dto);
|
||||
// }
|
||||
|
||||
// @Post("verify-party-otps/:requestId")
|
||||
// @ApiParam({ name: "requestId" })
|
||||
// @ApiBody({ type: VerifyPartyOtpsDto })
|
||||
// @ApiOperation({ summary: "[Expert mirror] Verify party OTPs (IN_PERSON)" })
|
||||
// async verifyPartyOtps(
|
||||
// @CurrentUser() expert: any,
|
||||
// @Param("requestId") requestId: string,
|
||||
// @Body() dto: VerifyPartyOtpsDto,
|
||||
// ) {
|
||||
// return this.requestManagementService.verifyPartyOtpsV2(
|
||||
// expert,
|
||||
// requestId,
|
||||
// dto,
|
||||
// );
|
||||
// }
|
||||
|
||||
// @Post("/blame-confession/:requestId")
|
||||
// @ApiParam({ name: "requestId" })
|
||||
// @ApiBody({ type: BlameConfessionDtoV2 })
|
||||
// @ApiOperation({
|
||||
// summary: "[Expert mirror] First-party blame confession",
|
||||
// description:
|
||||
// "THIRD_PARTY step. The FIRST party is the guilty party, so send imGuilty=true.",
|
||||
// })
|
||||
// async blameConfession(
|
||||
// @Param("requestId") requestId: string,
|
||||
// @Body() body: BlameConfessionDtoV2,
|
||||
// @CurrentUser() expert: any,
|
||||
// @Body("partyRole") partyRole?: string,
|
||||
// ) {
|
||||
// return this.requestManagementService.blameConfessionV2(
|
||||
// requestId,
|
||||
// body,
|
||||
// expert,
|
||||
// partyRole,
|
||||
// );
|
||||
// }
|
||||
|
||||
@Post("/car-body-form/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: CarBodyFormDto })
|
||||
|
||||
418
src/request-management/registrar-blame.mirror.controller.ts
Normal file
418
src/request-management/registrar-blame.mirror.controller.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { extname } from "node:path";
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { MediaPolicyService } from "src/media-policy/media-policy.service";
|
||||
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||
import {
|
||||
CarBodyFormDto,
|
||||
DescriptionDto,
|
||||
LocationDto,
|
||||
} from "./dto/create-request-management.dto";
|
||||
import { CreateRegistrarInitiatedFileDto } from "./dto/registrar-initiated.dto";
|
||||
import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
|
||||
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
|
||||
import { RequestManagementService } from "./request-management.service";
|
||||
import { PartyRole } from "./entities/schema/partyRole.enum";
|
||||
|
||||
/**
|
||||
* Registrar blame flow that mirrors the normal user blame API
|
||||
* (`v2/blame-request-management`) one-to-one, using only the IN_PERSON method.
|
||||
* The frontend can reuse the same pages by only swapping the route prefix:
|
||||
*
|
||||
* `v2/blame-request-management/*` -> `v2/registrar/blame-request-management/*`
|
||||
*
|
||||
* The registrar fills every step on behalf of both parties using the exact same
|
||||
* endpoints/bodies as a normal user. By contract the FIRST party registered is
|
||||
* always the guilty party. There is no LINK flow and no reviewing phase for the
|
||||
* registrar — the job is finished once the file is signed.
|
||||
*
|
||||
* Sequence: create -> send/verify OTP (first party) -> first party steps
|
||||
* (car-body-form or initial-form, upload-video, add-detail-location,
|
||||
* upload-voice, add-detail-description) -> add-second-party ->
|
||||
* send/verify OTP (second party) -> second party steps ->
|
||||
* add-accident-fields -> sign FIRST + sign SECOND -> COMPLETED.
|
||||
*/
|
||||
@ApiTags("registrar blame (mirror v2)")
|
||||
@Controller("v2/registrar/blame-request-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.REGISTRAR)
|
||||
export class RegistrarBlameMirrorController {
|
||||
constructor(
|
||||
private readonly requestManagementService: RequestManagementService,
|
||||
private readonly mediaPolicyService: MediaPolicyService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: "[Registrar mirror] Create registrar-initiated blame file",
|
||||
description:
|
||||
"Always IN_PERSON. Creates a BlameRequest owned by the parties but filled by the registrar. FIRST party registered is always the guilty party.",
|
||||
})
|
||||
@ApiBody({ type: CreateRegistrarInitiatedFileDto })
|
||||
async create(
|
||||
@CurrentUser() registrar: any,
|
||||
@Body() dto: CreateRegistrarInitiatedFileDto,
|
||||
) {
|
||||
return this.requestManagementService.createRegistrarInitiatedBlame(
|
||||
registrar,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "[Registrar mirror] List my registrar-initiated blame files",
|
||||
})
|
||||
async list(@CurrentUser() registrar: any) {
|
||||
return this.requestManagementService.getMyExpertInitiatedFilesV2(registrar);
|
||||
}
|
||||
|
||||
@Post("send-party-otp/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: SendPartyOtpDto })
|
||||
@ApiOperation({
|
||||
summary: "[Registrar mirror] Send OTP to one party (sequential IN_PERSON)",
|
||||
description:
|
||||
"Sends an OTP SMS to a single party's phone number. Flow: send to first party → verify → fill data → send to second party → verify → continue. No invite link is sent.",
|
||||
})
|
||||
async sendPartyOtp(
|
||||
@CurrentUser() registrar: any,
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() dto: SendPartyOtpDto,
|
||||
) {
|
||||
return this.requestManagementService.sendPartyOtpV2(
|
||||
registrar,
|
||||
requestId,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("verify-party-otp/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: VerifyPartyOtpDto })
|
||||
@ApiOperation({
|
||||
summary: "[Registrar mirror] Verify one party's OTP (sequential IN_PERSON)",
|
||||
description:
|
||||
"Verifies a single party's OTP and binds their account. `partyRole` is optional (inferred as FIRST until the first party is bound, then SECOND).",
|
||||
})
|
||||
async verifyPartyOtp(
|
||||
@CurrentUser() registrar: any,
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() dto: VerifyPartyOtpDto,
|
||||
) {
|
||||
return this.requestManagementService.verifyPartyOtpV2(
|
||||
registrar,
|
||||
requestId,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("/car-body-form/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: CarBodyFormDto })
|
||||
@ApiOperation({ summary: "[Registrar mirror] CAR_BODY accident type form" })
|
||||
async carBodyForm(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() body: CarBodyFormDto,
|
||||
@CurrentUser() registrar: any,
|
||||
@Body("partyRole") partyRole?: string,
|
||||
) {
|
||||
return this.requestManagementService.carBodyAccidentTypeFormV2(
|
||||
requestId,
|
||||
body,
|
||||
registrar,
|
||||
partyRole,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("/initial-form/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: AddPlateDto })
|
||||
@ApiOperation({
|
||||
summary: "[Registrar mirror] Initial form (plate/insurance) for current party",
|
||||
description:
|
||||
"Fills the FIRST or SECOND party plate/insurance/vehicle data depending on the current workflow step.",
|
||||
})
|
||||
async initialForm(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() body: AddPlateDto,
|
||||
@CurrentUser() registrar: any,
|
||||
@Body("partyRole") partyRole?: string,
|
||||
) {
|
||||
return this.requestManagementService.initialFormV2(
|
||||
requestId,
|
||||
body,
|
||||
registrar,
|
||||
partyRole,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
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, `registrar-${file.originalname}-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiParam({ name: "requestId" })
|
||||
@Post("upload-video/:requestId")
|
||||
@ApiOperation({ summary: "[Registrar mirror] Upload first-party video" })
|
||||
async uploadVideo(
|
||||
@Param("requestId") requestId: string,
|
||||
@CurrentUser() registrar: any,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
) {
|
||||
await this.mediaPolicyService.assertForBlame(file, requestId, "video");
|
||||
return this.requestManagementService.uploadFirstPartyVideoV2(
|
||||
requestId,
|
||||
file,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("/add-detail-location/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: LocationDto })
|
||||
@ApiOperation({ summary: "[Registrar mirror] Add location for current party" })
|
||||
async addLocation(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() body: LocationDto,
|
||||
@CurrentUser() registrar: any,
|
||||
@Body("partyRole") partyRole?: string,
|
||||
) {
|
||||
return this.requestManagementService.addDetailLocationV2(
|
||||
requestId,
|
||||
body,
|
||||
registrar,
|
||||
partyRole,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: { type: "string", format: "binary" },
|
||||
partyRole: {
|
||||
type: "string",
|
||||
enum: ["FIRST", "SECOND"],
|
||||
description:
|
||||
"Optional explicit party selector; must match the party currently collecting voice.",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@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);
|
||||
const flname = file.originalname.split(".")[0];
|
||||
callback(null, `registrar-${flname}-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiParam({ name: "requestId" })
|
||||
@Post("upload-voice/:requestId")
|
||||
@ApiOperation({ summary: "[Registrar mirror] Upload voice for current party" })
|
||||
async uploadVoice(
|
||||
@Param("requestId") requestId: string,
|
||||
@CurrentUser() registrar: any,
|
||||
@UploadedFile() voice?: Express.Multer.File,
|
||||
@Body("partyRole") partyRole?: string,
|
||||
) {
|
||||
await this.mediaPolicyService.assertForBlame(voice, requestId, "voice");
|
||||
return this.requestManagementService.uploadVoiceV2(
|
||||
requestId,
|
||||
voice,
|
||||
registrar,
|
||||
partyRole,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("/add-detail-description/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: DescriptionDto })
|
||||
@ApiOperation({
|
||||
summary: "[Registrar mirror] Add description for current party",
|
||||
description:
|
||||
"For THIRD_PARTY, submitting the SECOND party description finalizes guilt (FIRST party = guilty) and moves the file straight to WAITING_FOR_SIGNATURES.",
|
||||
})
|
||||
async addDescription(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() body: DescriptionDto,
|
||||
@CurrentUser() registrar: any,
|
||||
@Body("partyRole") partyRole?: string,
|
||||
) {
|
||||
return this.requestManagementService.addDescriptionV2(
|
||||
requestId,
|
||||
body,
|
||||
registrar,
|
||||
partyRole,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("add-second-party/:phoneNumber/:requestId/")
|
||||
@ApiParam({ name: "phoneNumber" })
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiOperation({
|
||||
summary: "[Registrar mirror] Advance to second party (sends OTP, no link)",
|
||||
description:
|
||||
"Sends an OTP SMS to the second party's phone number and updates the workflow to await verification. No invite link is sent. Use verify-party-otp next.",
|
||||
})
|
||||
async addSecondParty(
|
||||
@Param("phoneNumber") phoneNumber: string,
|
||||
@Param("requestId") requestId: string,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
return this.requestManagementService.expertAdvanceToSecondPartyV2(
|
||||
requestId,
|
||||
registrar,
|
||||
phoneNumber,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("add-accident-fields/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: ExpertAccidentFieldsDto })
|
||||
@ApiOperation({
|
||||
summary: "[Registrar mirror] Add accident fields and advance to signatures",
|
||||
description:
|
||||
"Saves accidentWay, accidentReason, and accidentType. " +
|
||||
"The first party is always guilty, so this also advances the workflow " +
|
||||
"from WAITING_FOR_GUILT_DECISION straight to WAITING_FOR_SIGNATURES. " +
|
||||
"Must be called before sign.",
|
||||
})
|
||||
async addAccidentFields(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() fields: ExpertAccidentFieldsDto,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
return this.requestManagementService.expertAddAccidentFieldsForBlameV2(
|
||||
registrar,
|
||||
requestId,
|
||||
fields,
|
||||
);
|
||||
}
|
||||
|
||||
@Put("sign/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "[Registrar mirror] Upload a party's on-site signature",
|
||||
description:
|
||||
"Collect each party's signature on site. CAR_BODY: partyRole=FIRST once. THIRD_PARTY: upload FIRST then SECOND. When all required parties have signed (accepted), the blame case completes.",
|
||||
})
|
||||
@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 ext = extname(file.originalname);
|
||||
callback(null, `registrar-party-${unique}${ext}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
async sign(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() body: { partyRole?: string; isAccept?: string | boolean },
|
||||
@CurrentUser() registrar: any,
|
||||
@UploadedFile() sign: Express.Multer.File,
|
||||
) {
|
||||
// Guard: accident fields must be filled before signing to prevent the
|
||||
// double-sign bug where add-accident-fields re-enters WAITING_FOR_SIGNATURES.
|
||||
const requestData = await this.requestManagementService.getBlameRequestV2(
|
||||
requestId,
|
||||
registrar,
|
||||
);
|
||||
if (!(requestData?.expert?.decision as any)?.fields?.accidentWay) {
|
||||
throw new BadRequestException(
|
||||
"Accident fields (accidentWay, accidentReason, accidentType) must be submitted via add-accident-fields before signing.",
|
||||
);
|
||||
}
|
||||
|
||||
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.expertUploadPartySignatureV2(
|
||||
registrar,
|
||||
requestId,
|
||||
partyRole,
|
||||
isAccept,
|
||||
sign,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiOperation({ summary: "[Registrar mirror] Get one blame request" })
|
||||
async getOne(
|
||||
@Param("requestId") requestId: string,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
return this.requestManagementService.getBlameRequestV2(
|
||||
requestId,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
|
||||
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";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -75,6 +76,7 @@ import { RegistrarInitiatedController } from "./registrar-initiated.controller";
|
||||
ExpertInitiatedV2Controller,
|
||||
ExpertInitiatedBlameMirrorController,
|
||||
RegistrarInitiatedController,
|
||||
RegistrarBlameMirrorController,
|
||||
],
|
||||
providers: [
|
||||
RequestManagementService,
|
||||
|
||||
@@ -1424,7 +1424,7 @@ export class RequestManagementService {
|
||||
status: req.status,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user