diff --git a/src/claim-request-management/claim-request-management.module.ts b/src/claim-request-management/claim-request-management.module.ts index 79e30bc..d2c522f 100644 --- a/src/claim-request-management/claim-request-management.module.ts +++ b/src/claim-request-management/claim-request-management.module.ts @@ -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, diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 4de9656..89e32d3 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -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) }, diff --git a/src/claim-request-management/registrar-claim.mirror.controller.ts b/src/claim-request-management/registrar-claim.mirror.controller.ts new file mode 100644 index 0000000..86f1853 --- /dev/null +++ b/src/claim-request-management/registrar-claim.mirror.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + ); + } +} diff --git a/src/common/auth/auth.module.ts b/src/common/auth/auth.module.ts index fb596c5..b4bf1b9 100644 --- a/src/common/auth/auth.module.ts +++ b/src/common/auth/auth.module.ts @@ -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 {} diff --git a/src/common/auth/constants/hash.constants.ts b/src/common/auth/constants/hash.constants.ts new file mode 100644 index 0000000..a01f4a2 --- /dev/null +++ b/src/common/auth/constants/hash.constants.ts @@ -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 +}; diff --git a/src/common/auth/providers/hash.provider.ts b/src/common/auth/providers/hash.provider.ts new file mode 100644 index 0000000..b77f88d --- /dev/null +++ b/src/common/auth/providers/hash.provider.ts @@ -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("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 { + 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); + } +} diff --git a/src/common/auth/providers/index.ts b/src/common/auth/providers/index.ts new file mode 100644 index 0000000..fd681a4 --- /dev/null +++ b/src/common/auth/providers/index.ts @@ -0,0 +1 @@ +export { HashService } from "./hash.provider"; diff --git a/src/common/auth/types/payload.types.ts b/src/common/auth/types/payload.types.ts index a939f9b..b7418e1 100644 --- a/src/common/auth/types/payload.types.ts +++ b/src/common/auth/types/payload.types.ts @@ -1,7 +1,6 @@ export interface JwtPayload { sub: string; role: string; - clientId?: string; iat?: number; exp?: number; } diff --git a/src/core/config/config.schema.ts b/src/core/config/config.schema.ts index 1e6753a..3f414d5 100644 --- a/src/core/config/config.schema.ts +++ b/src/core/config/config.schema.ts @@ -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; // --------------------------------------------------------- // } diff --git a/src/request-management/expert-initiated-blame.mirror.controller.ts b/src/request-management/expert-initiated-blame.mirror.controller.ts index bc72519..6b43b49 100644 --- a/src/request-management/expert-initiated-blame.mirror.controller.ts +++ b/src/request-management/expert-initiated-blame.mirror.controller.ts @@ -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 }) diff --git a/src/request-management/registrar-blame.mirror.controller.ts b/src/request-management/registrar-blame.mirror.controller.ts new file mode 100644 index 0000000..c605e30 --- /dev/null +++ b/src/request-management/registrar-blame.mirror.controller.ts @@ -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, + ); + } +} diff --git a/src/request-management/request-management.module.ts b/src/request-management/request-management.module.ts index 70af222..c2b4f08 100644 --- a/src/request-management/request-management.module.ts +++ b/src/request-management/request-management.module.ts @@ -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, diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 118e5b6..cfdeb38 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -1424,7 +1424,7 @@ export class RequestManagementService { status: req.status, }; } catch (error) { - console.log(error); + throw error; } }