Added registrar mirrored

This commit is contained in:
SepehrYahyaee
2026-06-15 15:58:31 +03:30
parent 41f81a2f76
commit a7849f915a
13 changed files with 1089 additions and 61 deletions

View File

@@ -8,6 +8,7 @@ import { ClaimRequestManagementController } from "./claim-request-management.con
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller"; import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller";
import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller"; import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller";
import { ExpertInitiatedClaimMirrorController } from "./expert-initiated-claim.mirror.controller"; import { ExpertInitiatedClaimMirrorController } from "./expert-initiated-claim.mirror.controller";
import { RegistrarClaimMirrorController } from "./registrar-claim.mirror.controller";
import { ClaimRequestManagementService } from "./claim-request-management.service"; import { ClaimRequestManagementService } from "./claim-request-management.service";
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service"; import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service"; import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
@@ -99,6 +100,7 @@ import { HttpModule } from "@nestjs/axios";
ClaimRequestManagementV2Controller, ClaimRequestManagementV2Controller,
RegistrarClaimV1Controller, RegistrarClaimV1Controller,
ExpertInitiatedClaimMirrorController, ExpertInitiatedClaimMirrorController,
RegistrarClaimMirrorController,
], ],
exports: [ exports: [
ClaimRequestManagementService, ClaimRequestManagementService,

View File

@@ -6536,6 +6536,28 @@ export class ClaimRequestManagementService {
}, },
{ lean: true }, { 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 { } else {
claims = await this.claimCaseDbService.find( claims = await this.claimCaseDbService.find(
{ "owner.userId": new Types.ObjectId(currentUserId) }, { "owner.userId": new Types.ObjectId(currentUserId) },

View File

@@ -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,
);
}
}

View File

@@ -6,6 +6,7 @@ import { StringValue } from "ms";
import { AuthGuard, RolesGuard } from "./guards"; import { AuthGuard, RolesGuard } from "./guards";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { HashService } from "./providers";
@Module({ @Module({
imports: [ imports: [
@@ -25,6 +26,7 @@ import { AuthService } from "./auth.service";
{ provide: APP_GUARD, useClass: AuthGuard }, { provide: APP_GUARD, useClass: AuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard }, { provide: APP_GUARD, useClass: RolesGuard },
AuthService, AuthService,
HashService,
], ],
}) })
export class AuthModule {} export class AuthModule {}

View 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
};

View 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);
}
}

View File

@@ -0,0 +1 @@
export { HashService } from "./hash.provider";

View File

@@ -1,7 +1,6 @@
export interface JwtPayload { export interface JwtPayload {
sub: string; sub: string;
role: string; role: string;
clientId?: string;
iat?: number; iat?: number;
exp?: number; exp?: number;
} }

View File

@@ -2,6 +2,7 @@ import {
IsBooleanString, IsBooleanString,
IsEnum, IsEnum,
IsNumber, IsNumber,
IsOptional,
IsPositive, IsPositive,
IsString, IsString,
IsUrl, IsUrl,
@@ -79,10 +80,8 @@ export class EnvironmentVariables {
JWT_EXPIRY: StringValue; JWT_EXPIRY: StringValue;
// --------------------------------------------------------- // // --------------------------------------------------------- //
// @IsString({ message: "HASH_PEPPER must be string" }) @IsString({ message: "HASH_PEPPER must be string" })
// @MinLength(32, { @IsOptional()
// message: "HASH_PEPPER must be at least 32 characters", HASH_PEPPER?: string;
// })
// HASH_PEPPER: string;
// --------------------------------------------------------- // // --------------------------------------------------------- //
} }

View File

@@ -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") @Post("/car-body-form/:requestId")
@ApiParam({ name: "requestId" }) @ApiParam({ name: "requestId" })
@ApiBody({ type: CarBodyFormDto }) @ApiBody({ type: CarBodyFormDto })

View 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,
);
}
}

View File

@@ -41,6 +41,7 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller"; import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
import { ExpertInitiatedBlameMirrorController } from "./expert-initiated-blame.mirror.controller"; import { ExpertInitiatedBlameMirrorController } from "./expert-initiated-blame.mirror.controller";
import { RegistrarInitiatedController } from "./registrar-initiated.controller"; import { RegistrarInitiatedController } from "./registrar-initiated.controller";
import { RegistrarBlameMirrorController } from "./registrar-blame.mirror.controller";
@Module({ @Module({
imports: [ imports: [
@@ -75,6 +76,7 @@ import { RegistrarInitiatedController } from "./registrar-initiated.controller";
ExpertInitiatedV2Controller, ExpertInitiatedV2Controller,
ExpertInitiatedBlameMirrorController, ExpertInitiatedBlameMirrorController,
RegistrarInitiatedController, RegistrarInitiatedController,
RegistrarBlameMirrorController,
], ],
providers: [ providers: [
RequestManagementService, RequestManagementService,

View File

@@ -1424,7 +1424,7 @@ export class RequestManagementService {
status: req.status, status: req.status,
}; };
} catch (error) { } catch (error) {
console.log(error); throw error;
} }
} }