1
0
forked from Yara724/api

Added registrar + fixed sign bug in car-body flow

This commit is contained in:
2026-03-26 16:35:02 +03:30
parent b1be5b1e09
commit 8af152abc0
18 changed files with 706 additions and 31 deletions

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn, IsString } from "class-validator";
export class CreateRegistrarInitiatedFileDto {
@ApiProperty({ enum: ["THIRD_PARTY", "CAR_BODY"], example: "THIRD_PARTY" })
@IsString()
@IsIn(["THIRD_PARTY", "CAR_BODY"])
type: "THIRD_PARTY" | "CAR_BODY";
}

View File

@@ -81,6 +81,14 @@ export class BlameRequest {
@Prop({ type: Types.ObjectId })
initiatedByFieldExpertId?: Types.ObjectId;
/** True when this blame was created by a registrar. */
@Prop({ default: false })
registrarInitiated?: boolean;
/** Registrar who created this file (for registrar-initiated flows). */
@Prop({ type: Types.ObjectId })
initiatedByRegistrarId?: Types.ObjectId;
/** LINK = expert sends link to user(s); IN_PERSON = expert fills form on-site. */
@Prop({ type: String, enum: CreationMethod })
creationMethod?: CreationMethod;

View File

@@ -282,6 +282,7 @@ export enum CreationMethod {
export enum FilledBy {
CUSTOMER = "customer",
EXPERT = "expert",
REGISTRAR = "registrar",
}
export class ExpertLinkInfo {

View File

@@ -0,0 +1,155 @@
import { extname } from "node:path";
import { Body, Controller, Get, Param, Post, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { CurrentUser } from "src/decorators/user.decorator";
import { Roles } from "src/decorators/roles.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { RequestManagementService } from "./request-management.service";
import { CreateRegistrarInitiatedFileDto } from "./dto/registrar-initiated.dto";
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
import { PartyRole } from "./entities/schema/partyRole.enum";
@ApiTags("registrar-initiated-blame (v1)")
@Controller("registrar-initiated-blame")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.REGISTRAR)
export class RegistrarInitiatedController {
constructor(private readonly requestManagementService: RequestManagementService) {}
@Post("create")
@ApiOperation({ summary: "Registrar creates IN_PERSON blame file" })
@ApiBody({ type: CreateRegistrarInitiatedFileDto })
create(@CurrentUser() registrar: any, @Body() dto: CreateRegistrarInitiatedFileDto) {
return this.requestManagementService.createRegistrarInitiatedBlame(registrar, dto);
}
@Get("my-files")
myFiles(@CurrentUser() registrar: any) {
return this.requestManagementService.getMyExpertInitiatedFilesV2(registrar);
}
@Get("blame/:requestId")
getBlame(@Param("requestId") requestId: string, @CurrentUser() registrar: any) {
return this.requestManagementService.getBlameRequestV2(requestId, registrar);
}
@Post("send-party-otps/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: SendPartyOtpsDto })
sendPartyOtps(
@CurrentUser() registrar: any,
@Param("requestId") requestId: string,
@Body() dto: SendPartyOtpsDto,
) {
return this.requestManagementService.sendPartyOtpsV2(registrar, requestId, dto);
}
@Post("verify-party-otps/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: VerifyPartyOtpsDto })
verifyPartyOtps(
@CurrentUser() registrar: any,
@Param("requestId") requestId: string,
@Body() dto: VerifyPartyOtpsDto,
) {
return this.requestManagementService.verifyPartyOtpsV2(registrar, requestId, dto);
}
@Post("complete-blame-data/:requestId")
completeBlameData(
@CurrentUser() registrar: any,
@Param("requestId") requestId: string,
@Body() body: any,
) {
return this.requestManagementService.expertCompleteBlameDataV2(registrar, requestId, body);
}
@Post("upload-video/:requestId")
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: 20 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/video",
filename: (req, file, cb) => cb(null, `registrar-${Date.now()}${extname(file.originalname)}`),
}),
}),
)
uploadVideo(
@CurrentUser() registrar: any,
@Param("requestId") requestId: string,
@UploadedFile() file?: Express.Multer.File,
) {
return this.requestManagementService.expertUploadVideoForBlameV2(registrar, requestId, file);
}
@Post("upload-voice/:requestId")
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("voice", {
limits: { fileSize: 10 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/voice",
filename: (req, file, cb) => cb(null, `registrar-${Date.now()}${extname(file.originalname)}`),
}),
}),
)
uploadVoice(
@CurrentUser() registrar: any,
@Param("requestId") requestId: string,
@UploadedFile() voice?: Express.Multer.File,
) {
return this.requestManagementService.expertUploadVoiceForBlameV2(registrar, requestId, voice);
}
@Post("add-accident-fields/:requestId")
@ApiBody({ type: ExpertAccidentFieldsDto })
addAccidentFields(
@CurrentUser() registrar: any,
@Param("requestId") requestId: string,
@Body() fields: ExpertAccidentFieldsDto,
) {
return this.requestManagementService.expertAddAccidentFieldsForBlameV2(
registrar,
requestId,
fields,
);
}
@Post("upload-party-signature/:requestId")
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("sign", {
limits: { fileSize: 10 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/signs",
filename: (req, file, cb) => cb(null, `registrar-party-${Date.now()}${extname(file.originalname)}`),
}),
}),
)
uploadPartySignature(
@CurrentUser() registrar: any,
@Param("requestId") requestId: string,
@Body() body: { partyRole?: string; isAccept?: boolean | string },
@UploadedFile() sign?: Express.Multer.File,
) {
const role =
body.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
const isAccept = !(body.isAccept === false || body.isAccept === "false");
return this.requestManagementService.expertUploadPartySignatureV2(
registrar,
requestId,
role,
isAccept,
sign!,
);
}
}

View File

@@ -38,6 +38,7 @@ import { RequestManagementController } from "./request-management.controller";
import { RequestManagementV2Controller } from "./request-management.v2.controller";
import { ExpertInitiatedController } from "./expert-initiated.controller";
import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
import { RegistrarInitiatedController } from "./registrar-initiated.controller";
@Module({
imports: [
@@ -69,6 +70,7 @@ import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
RequestManagementV2Controller,
ExpertInitiatedController,
ExpertInitiatedV2Controller,
RegistrarInitiatedController,
],
providers: [
RequestManagementService,

View File

@@ -103,7 +103,7 @@ export class RequestManagementService {
}
private async advanceWorkflowToNext(req: any, submittedStepKey: WorkflowStep) {
// CAR_BODY: after first-party description there is no second party or expert; go to COMPLETED
// CAR_BODY: after first-party description, move to signature step.
if (
req.type === BlameRequestType.CAR_BODY &&
submittedStepKey === WorkflowStep.FIRST_DESCRIPTION
@@ -113,11 +113,11 @@ export class RequestManagementService {
: [];
if (!completed.includes(submittedStepKey)) completed.push(submittedStepKey);
req.workflow.completedSteps = completed;
req.workflow.currentStep = WorkflowStep.COMPLETED;
req.workflow.currentStep = WorkflowStep.WAITING_FOR_SIGNATURES;
req.workflow.nextStep = undefined;
req.status = CaseStatus.COMPLETED;
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
this.logger.debug(
"[WORKFLOW] CAR_BODY: advanced to COMPLETED after FIRST_DESCRIPTION",
"[WORKFLOW] CAR_BODY: advanced to WAITING_FOR_SIGNATURES after FIRST_DESCRIPTION",
);
return;
}
@@ -3234,16 +3234,86 @@ export class RequestManagementService {
* Verify the current user (field expert) can access this BlameRequest (v2).
*/
private verifyExpertAccessForBlameV2(req: any, expert: any): void {
if (!req?.expertInitiated || !req?.initiatedByFieldExpertId) {
throw new ForbiddenException(
"This file is not expert-initiated or has no initiating expert",
if (req?.expertInitiated && req?.initiatedByFieldExpertId) {
if (String(req.initiatedByFieldExpertId) !== String(expert?.sub)) {
throw new ForbiddenException(
"You can only access files that you have initiated",
);
}
return;
}
if (req?.registrarInitiated && req?.initiatedByRegistrarId) {
if (String(req.initiatedByRegistrarId) !== String(expert?.sub)) {
throw new ForbiddenException(
"You can only access files that you have initiated",
);
}
return;
}
throw new ForbiddenException(
"This file is not initiator-scoped or has no initiating actor",
);
}
async createRegistrarInitiatedBlame(
registrar: any,
dto: { type: "THIRD_PARTY" | "CAR_BODY" },
): Promise<{ requestId: string; publicId: string }> {
const registrarId = new Types.ObjectId(registrar.sub);
const type =
dto.type === "CAR_BODY"
? BlameRequestType.CAR_BODY
: BlameRequestType.THIRD_PARTY;
const firstStep = await this.getWorkflowStep({ stepNumber: 1 });
if (!firstStep?.stepKey) {
throw new InternalServerErrorException(
"Workflow stepNumber=1 not configured in step manager",
);
}
if (String(req.initiatedByFieldExpertId) !== String(expert?.sub)) {
throw new ForbiddenException(
"You can only access files that you have initiated",
const nextStepFromManager = firstStep.nextPossibleSteps?.[0];
const nextStep =
type === BlameRequestType.CAR_BODY
? (WorkflowStep.CAR_BODY_ACCIDENT_TYPE as WorkflowStep)
: (nextStepFromManager as WorkflowStep);
if (!nextStep) {
throw new InternalServerErrorException(
"Workflow stepNumber=1 has no nextPossibleSteps configured",
);
}
const publicId = await this.publicIdService.generateRequestPublicId();
const parties: any[] = [{ role: PartyRole.FIRST, person: {} }];
if (type === BlameRequestType.THIRD_PARTY) {
parties.push({ role: PartyRole.SECOND, person: {} });
}
const created = await this.blameRequestDbService.create({
publicId,
requestNo: publicId,
type,
parties,
workflow: {
currentStep: firstStep.stepKey as WorkflowStep,
nextStep,
completedSteps: [firstStep.stepKey as WorkflowStep],
locked: false,
},
history: [],
registrarInitiated: true,
initiatedByRegistrarId: registrarId,
creationMethod: CreationMethod.IN_PERSON,
filledBy: FilledBy.REGISTRAR,
} as any);
if (!Array.isArray((created as any).history)) (created as any).history = [];
(created as any).history.push({
type: "FILE_CREATED_BY_REGISTRAR",
actor: {
actorId: registrarId,
actorName: `${registrar.firstName || ""} ${registrar.lastName || ""}`.trim(),
actorType: "registrar",
},
metadata: { creationMethod: CreationMethod.IN_PERSON, type: dto.type },
});
await (created as any).save();
return { requestId: String((created as any)._id), publicId: (created as any).publicId };
}
/**
@@ -3596,7 +3666,10 @@ export class RequestManagementService {
): Promise<{ sent: boolean; message: string }> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) {
if (
(!req.expertInitiated && !req.registrarInitiated) ||
req.creationMethod !== CreationMethod.IN_PERSON
) {
throw new BadRequestException(
"This endpoint is only for expert-initiated IN_PERSON blame files.",
);
@@ -3657,7 +3730,10 @@ export class RequestManagementService {
): Promise<{ verified: boolean; message: string }> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) {
if (
(!req.expertInitiated && !req.registrarInitiated) ||
req.creationMethod !== CreationMethod.IN_PERSON
) {
throw new BadRequestException(
"This endpoint is only for expert-initiated IN_PERSON blame files.",
);
@@ -3712,7 +3788,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: expert?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
},
metadata: {
firstPartyVerified: true,
@@ -3759,8 +3835,10 @@ export class RequestManagementService {
async getMyExpertInitiatedFilesV2(expert: any): Promise<any[]> {
const expertId = new Types.ObjectId(expert.sub);
const files = await this.blameRequestDbService.find({
expertInitiated: true,
initiatedByFieldExpertId: expertId,
$or: [
{ expertInitiated: true, initiatedByFieldExpertId: expertId },
{ registrarInitiated: true, initiatedByRegistrarId: expertId },
],
});
return (files || []).map((f: any) => ({
_id: f._id,
@@ -3790,16 +3868,20 @@ export class RequestManagementService {
req.expertInitiated &&
req.initiatedByFieldExpertId &&
String(req.initiatedByFieldExpertId) === String(user?.sub);
const isRegistrarOwner =
req.registrarInitiated &&
req.initiatedByRegistrarId &&
String(req.initiatedByRegistrarId) === String(user?.sub);
const isParty = Array.isArray(req.parties) && req.parties.some((p: any) => {
const pid = p?.person?.userId ? String(p.person.userId) : null;
const phone = p?.person?.phoneNumber;
return (pid && pid === String(user?.sub)) || (phone && phone === user?.username);
});
if (!isFieldExpertOwner && !isParty) {
if (!isFieldExpertOwner && !isRegistrarOwner && !isParty) {
throw new ForbiddenException("You do not have access to this request");
}
// Optionally bind userId to party when user opens via LINK (phone match, no userId yet)
if (!isFieldExpertOwner && user?.sub && Types.ObjectId.isValid(user.sub)) {
if (!isFieldExpertOwner && !isRegistrarOwner && user?.sub && Types.ObjectId.isValid(user.sub)) {
let updated = false;
for (const p of req.parties || []) {
if (
@@ -3862,7 +3944,10 @@ export class RequestManagementService {
if (!req) {
throw new NotFoundException("Request not found");
}
if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) {
if (
(!req.expertInitiated && !req.registrarInitiated) ||
req.creationMethod !== CreationMethod.IN_PERSON
) {
throw new BadRequestException(
"This endpoint is only for expert-initiated IN_PERSON BlameRequest files.",
);
@@ -3890,7 +3975,10 @@ export class RequestManagementService {
): Promise<any> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) {
if (
(!req.expertInitiated && !req.registrarInitiated) ||
req.creationMethod !== CreationMethod.IN_PERSON
) {
throw new BadRequestException("Only expert-initiated IN_PERSON CAR_BODY files.");
}
if (req.type !== BlameRequestType.CAR_BODY) {
@@ -4193,7 +4281,7 @@ export class RequestManagementService {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.expertInitiated) {
if (!req.expertInitiated && !req.registrarInitiated) {
throw new BadRequestException("This endpoint is only for expert-initiated files.");
}
this.verifyExpertAccessForBlameV2(req, expert);