forked from Yara724/api
Added expert field mirror flow
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
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("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 })
|
||||
@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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user