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 { BlameConfessionDtoV2, CarBodyFormDto, DescriptionDto, LocationDto, } from "./dto/create-request-management.dto"; import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; import { SendPartyOtpsDto } from "./dto/send-party-otps.dto"; import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto"; import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto"; import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto"; import { SendExpertInitiatedLinkV2Dto } from "./dto/send-expert-initiated-link.v2.dto"; import { RequestManagementService } from "./request-management.service"; import { PartyRole } from "./entities/schema/partyRole.enum"; /** * Expert-initiated IN_PERSON blame flow that mirrors the normal user blame API * (`v2/blame-request-management`) one-to-one, so the frontend can reuse the same * pages by only swapping the route prefix: * * `v2/blame-request-management/*` -> `v2/expert-initiated/blame-request-management/*` * * The field expert is on the accident scene and fills every step on behalf of * both parties using the exact same endpoints/bodies as a normal user. By * contract the FIRST party the expert registers is always the guilty party, and * the blame is finalized (guilt decided + ready for signatures) without any * waiting-for-expert phase or invite links. * * Sequence: create -> send/verify party OTPs -> first party steps * (blame-confession, initial-form, upload-video, add-detail-location, * upload-voice, add-detail-description) -> add-second-party (no link) -> * second party steps -> sign FIRST + sign SECOND -> COMPLETED. */ @ApiTags("expert-initiated blame (mirror v2)") @Controller("v2/expert-initiated/blame-request-management") @ApiBearerAuth() @UseGuards(LocalActorAuthGuard, RolesGuard) @Roles(RoleEnum.FIELD_EXPERT) export class ExpertInitiatedBlameMirrorController { constructor( private readonly requestManagementService: RequestManagementService, private readonly mediaPolicyService: MediaPolicyService, ) {} @Post() @ApiOperation({ summary: "[Expert mirror] Create expert-initiated blame file", description: "Use creationMethod=IN_PERSON for the on-site flow. Creates a BlameRequest owned by the parties but filled by the expert.", }) @ApiBody({ type: CreateExpertInitiatedFileDto }) async create( @CurrentUser() expert: any, @Body() dto: CreateExpertInitiatedFileDto, ) { return this.requestManagementService.createExpertInitiatedBlameV2( expert, dto, ); } @Get() @ApiOperation({ summary: "[Expert mirror] List my expert-initiated blame files", }) async list(@CurrentUser() expert: any) { return this.requestManagementService.getMyExpertInitiatedFilesV2(expert); } @Post("send-link/:requestId") @ApiParam({ name: "requestId" }) @ApiBody({ type: SendExpertInitiatedLinkV2Dto }) @ApiOperation({ summary: "[Expert mirror] Send blame link to first party (LINK method)", description: "For files created with creationMethod=LINK. Provide the first party phone number; the service stores it, registers the user if needed, and sends the invite link via SMS.", }) async sendLink( @CurrentUser() expert: any, @Param("requestId") requestId: string, @Body() dto: SendExpertInitiatedLinkV2Dto, ) { return this.requestManagementService.sendLinkV2(expert, requestId, dto); } @Post("send-party-otp/:requestId") @ApiParam({ name: "requestId" }) @ApiBody({ type: SendPartyOtpDto }) @ApiOperation({ summary: "[Expert mirror] Send OTP to one party (one-at-a-time IN_PERSON)", description: "Sends an OTP to a single phone number. Use this for the sequential flow: send to first party -> verify -> fill data -> send to second party -> verify -> continue. No invite link is sent.", }) async sendPartyOtp( @CurrentUser() expert: any, @Param("requestId") requestId: string, @Body() dto: SendPartyOtpDto, ) { return this.requestManagementService.sendPartyOtpV2(expert, requestId, dto); } @Post("verify-party-otp/:requestId") @ApiParam({ name: "requestId" }) @ApiBody({ type: VerifyPartyOtpDto }) @ApiOperation({ summary: "[Expert mirror] Verify one party's OTP (one-at-a-time 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() expert: any, @Param("requestId") requestId: string, @Body() dto: VerifyPartyOtpDto, ) { return this.requestManagementService.verifyPartyOtpV2( expert, requestId, dto, ); } @Post("/car-body-form/:requestId") @ApiParam({ name: "requestId" }) @ApiBody({ type: CarBodyFormDto }) @ApiOperation({ summary: "[Expert mirror] CAR_BODY accident type" }) async carBodyForm( @Param("requestId") requestId: string, @Body() body: CarBodyFormDto, @CurrentUser() expert: any, @Body("partyRole") partyRole?: string, ) { return this.requestManagementService.carBodyAccidentTypeFormV2( requestId, body, expert, partyRole, ); } @Post("/initial-form/:requestId") @ApiParam({ name: "requestId" }) @ApiBody({ type: AddPlateDto }) @ApiOperation({ summary: "[Expert 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() expert: any, @Body("partyRole") partyRole?: string, ) { return this.requestManagementService.initialFormV2( requestId, body, expert, 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, `expert-${file.originalname}-${unique}${ex}`); }, }), }), ) @ApiParam({ name: "requestId" }) @Post("upload-video/:requestId") @ApiOperation({ summary: "[Expert mirror] Upload first-party video" }) async uploadVideo( @Param("requestId") requestId: string, @CurrentUser() expert: any, @UploadedFile() file?: Express.Multer.File, ) { await this.mediaPolicyService.assertForBlame(file, requestId, "video"); return this.requestManagementService.uploadFirstPartyVideoV2( requestId, file, expert, ); } @Post("/add-detail-location/:requestId") @ApiParam({ name: "requestId" }) @ApiBody({ type: LocationDto }) @ApiOperation({ summary: "[Expert mirror] Add location for current party" }) async addLocation( @Param("requestId") requestId: string, @Body() body: LocationDto, @CurrentUser() expert: any, @Body("partyRole") partyRole?: string, ) { return this.requestManagementService.addDetailLocationV2( requestId, body, expert, 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 the file is currently collecting (FIRST for these steps).", }, }, }, }) @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, `expert-${flname}-${unique}${ex}`); }, }), }), ) @ApiParam({ name: "requestId" }) @Post("upload-voice/:requestId") @ApiOperation({ summary: "[Expert mirror] Upload voice for current party" }) async uploadVoice( @Param("requestId") requestId: string, @CurrentUser() expert: any, @UploadedFile() voice?: Express.Multer.File, @Body("partyRole") partyRole?: string, ) { await this.mediaPolicyService.assertForBlame(voice, requestId, "voice"); return this.requestManagementService.uploadVoiceV2( requestId, voice, expert, partyRole, ); } @Post("/add-detail-description/:requestId") @ApiParam({ name: "requestId" }) @ApiBody({ type: DescriptionDto }) @ApiOperation({ summary: "[Expert 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() expert: any, @Body("partyRole") partyRole?: string, ) { return this.requestManagementService.addDescriptionV2( requestId, body, expert, partyRole, ); } @Post("add-second-party/:phoneNumber/:requestId/") @ApiParam({ name: "phoneNumber" }) @ApiParam({ name: "requestId" }) @ApiOperation({ summary: "[Expert mirror] Advance to second party (no SMS link)", description: "IN_PERSON variant of add-second-party: there is no invite link. Advances the workflow so the expert can fill the second party's steps. `frontendRoute` is accepted for route compatibility but ignored.", }) async addSecondParty( @Param("phoneNumber") phoneNumber: string, @Param("requestId") requestId: string, @CurrentUser() expert: any, ) { return this.requestManagementService.expertAdvanceToSecondPartyV2( requestId, expert, phoneNumber, ); } @Put("sign/:requestId") @ApiParam({ name: "requestId" }) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "[Expert 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, `expert-party-${unique}${ext}`); }, }), }), ) async sign( @Param("requestId") requestId: string, @Body() body: { partyRole?: string; isAccept?: string | boolean }, @CurrentUser() expert: any, @UploadedFile() sign: Express.Multer.File, ) { // Guard: accident fields (accidentWay, etc.) must be filled before signing. // This prevents the expert from bypassing add-accident-fields and avoids the // double-sign bug where add-accident-fields re-enters WAITING_FOR_SIGNATURES // after a signature was already recorded. const requestData = await this.requestManagementService.getBlameRequestV2( requestId, expert, ); 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( expert, requestId, partyRole, isAccept, sign, ); } @Post("add-accident-fields/:requestId") @ApiParam({ name: "requestId" }) @ApiBody({ type: ExpertAccidentFieldsDto }) @ApiOperation({ summary: "[Expert mirror] Add accident fields and advance to signatures", description: "Saves accidentWay, accidentReason, and accidentType for the blame file. " + "In an IN_PERSON expert-initiated flow the first party is always guilty, so " + "calling this endpoint both records the accident details and advances the workflow " + "from WAITING_FOR_GUILT_DECISION straight to WAITING_FOR_SIGNATURES.", }) async addAccidentFields( @Param("requestId") requestId: string, @Body() fields: ExpertAccidentFieldsDto, @CurrentUser() expert: any, ) { return this.requestManagementService.expertAddAccidentFieldsForBlameV2( expert, requestId, fields, ); } @Get(":requestId") @ApiParam({ name: "requestId" }) @ApiOperation({ summary: "[Expert mirror] Get one blame request" }) async getOne( @Param("requestId") requestId: string, @CurrentUser() expert: any, ) { return this.requestManagementService.getBlameRequestV2(requestId, expert); } }