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