diff --git a/src/Types&Enums/role.enum.ts b/src/Types&Enums/role.enum.ts index 928890e..5cb284c 100644 --- a/src/Types&Enums/role.enum.ts +++ b/src/Types&Enums/role.enum.ts @@ -2,6 +2,7 @@ export enum RoleEnum { EXPERT = "expert", DAMAGE_EXPERT = "damage_expert", FIELD_EXPERT = "field_expert", + REGISTRAR = "registrar", COMPANY = "company", ADMIN = "admin", USER = "user", diff --git a/src/auth/auth-controllers/actor/actor.auth.controller.ts b/src/auth/auth-controllers/actor/actor.auth.controller.ts index 8905211..04d020c 100644 --- a/src/auth/auth-controllers/actor/actor.auth.controller.ts +++ b/src/auth/auth-controllers/actor/actor.auth.controller.ts @@ -23,6 +23,7 @@ import { import { LoginActorDto } from "src/auth/dto/actor/login.actor.dto"; import { ActorEditUserProfileDto } from "src/auth/dto/actor/profile.actor.dto"; import { CreateFieldExpertDto } from "src/auth/dto/actor/create-field-expert.actor.dto"; +import { CreateRegistrarDto } from "src/auth/dto/actor/create-registrar.actor.dto"; import { GenuineRegisterDto, InsurerRegisterDto, @@ -64,6 +65,14 @@ export class ActorAuthController { return await this.actorAuthService.createFieldExpertMock(body); } + /** Mock: create a registrar for testing. Make private later. */ + @Post("create-registrar") + @ApiBody({ type: CreateRegistrarDto }) + @ApiAcceptedResponse() + async createRegistrar(@Body() body: CreateRegistrarDto) { + return await this.actorAuthService.createRegistrarMock(body); + } + @UseGuards(LocalActorAuthGuard) @Post("login") @Roles() diff --git a/src/auth/auth-services/actor.auth.service.ts b/src/auth/auth-services/actor.auth.service.ts index f236f43..e37a58b 100644 --- a/src/auth/auth-services/actor.auth.service.ts +++ b/src/auth/auth-services/actor.auth.service.ts @@ -25,6 +25,7 @@ import { InsurerExpertDbService } from "src/users/entities/db-service/insurer-ex import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service"; +import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service"; import { HashService } from "src/utils/hash/hash.service"; // import { MailService } from "src/utils/mail/mail.service"; import { OtpService } from "src/utils/otp/otp.service"; @@ -46,6 +47,7 @@ export class ActorAuthService { private readonly expertDbService: ExpertDbService, private readonly damageExpertDbService: DamageExpertDbService, private readonly fieldExpertDbService: FieldExpertDbService, + private readonly registrarDbService: RegistrarDbService, private readonly insurerExpertDbService: InsurerExpertDbService, // private readonly mailService: MailService, // Mailer disabled – not used private readonly clientDbService: ClientDbService, @@ -79,6 +81,13 @@ export class ActorAuthService { else res = await this.fieldExpertDbService.findOne({ email: username }); break; + case RoleEnum.REGISTRAR: + if (username == null && userId) + res = await this.registrarDbService.findOne({ + _id: new Types.ObjectId(userId), + }); + else res = await this.registrarDbService.findOne({ email: username }); + break; case RoleEnum.COMPANY: res = await this.insurerExpertDbService.findOne({ email: username }); break; @@ -273,6 +282,10 @@ export class ActorAuthService { actor = await this.fieldExpertDbService.findOne(filter); dbServiceToUpdate = this.fieldExpertDbService as any; } + if (!actor) { + actor = await this.registrarDbService.findOne(filter); + dbServiceToUpdate = this.registrarDbService as any; + } if (!actor) { throw new NotFoundException("actor not found"); @@ -309,6 +322,10 @@ export class ActorAuthService { userExist = await this.fieldExpertDbService.findOne({ email }); dbServiceToUpdate = this.fieldExpertDbService; } + if (!userExist) { + userExist = await this.registrarDbService.findOne({ email }); + dbServiceToUpdate = this.registrarDbService as any; + } if (!userExist) throw new NotFoundException("user not found"); const decodeOtp = await this.hashService.compare(otp, userExist.otp); if (!decodeOtp) throw new UnauthorizedException("otp invalid"); @@ -383,6 +400,7 @@ export class ActorAuthService { "phone", "mobile", ], + registrar: ["email"], }; const allowedFields = allowedFieldsByRole[role]; @@ -498,4 +516,34 @@ export class ActorAuthService { throw er; } } + + async createRegistrarMock(body: { + email: string; + password: string; + clientId: string; + }) { + const hashPassword = await this.hashService.hash(body.password); + const payload = { + email: body.email.toLowerCase().trim(), + password: hashPassword, + clientKey: new Types.ObjectId(body.clientId), + role: RoleEnum.REGISTRAR, + }; + try { + const created = await this.registrarDbService.create(payload as any); + return { + id: (created as any)._id, + email: (created as any).email, + clientKey: (created as any).clientKey, + role: (created as any).role, + }; + } catch (er) { + if (er.code === 11000) { + throw new BadRequestException( + "A registrar with this email already exists.", + ); + } + throw er; + } + } } diff --git a/src/auth/dto/actor/create-registrar.actor.dto.ts b/src/auth/dto/actor/create-registrar.actor.dto.ts new file mode 100644 index 0000000..b8ef15a --- /dev/null +++ b/src/auth/dto/actor/create-registrar.actor.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail, IsMongoId, IsString, MinLength } from "class-validator"; + +export class CreateRegistrarDto { + @ApiProperty({ example: "registrar@sample.com" }) + @IsEmail() + email: string; + + @ApiProperty({ example: "securePassword123", minLength: 6 }) + @IsString() + @MinLength(6) + password: string; + + @ApiProperty({ example: "507f1f77bcf86cd799439011" }) + @IsMongoId() + clientId: string; +} + diff --git a/src/auth/guards/actor-local.guard.ts b/src/auth/guards/actor-local.guard.ts index 63a9926..f8e188a 100644 --- a/src/auth/guards/actor-local.guard.ts +++ b/src/auth/guards/actor-local.guard.ts @@ -44,6 +44,7 @@ export class LocalActorAuthGuard extends AuthGuard("actor") { RoleEnum.DAMAGE_EXPERT, RoleEnum.COMPANY, RoleEnum.FIELD_EXPERT, + RoleEnum.REGISTRAR, ].includes(payload.role) ) { throw new UnauthorizedException("User role is not authorized"); diff --git a/src/claim-request-management/claim-request-management.module.ts b/src/claim-request-management/claim-request-management.module.ts index 440b741..75c8166 100644 --- a/src/claim-request-management/claim-request-management.module.ts +++ b/src/claim-request-management/claim-request-management.module.ts @@ -6,6 +6,7 @@ import { RequestManagementModule } from "src/request-management/request-manageme import { UsersModule } from "src/users/users.module"; import { ClaimRequestManagementController } from "./claim-request-management.controller"; import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller"; +import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller"; import { ClaimRequestManagementService } from "./claim-request-management.service"; import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service"; import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service"; @@ -85,7 +86,11 @@ import { JwtModule } from "@nestjs/jwt"; ClaimRequiredDocumentDbService, ClaimAccessGuard, ], - controllers: [ClaimRequestManagementController, ClaimRequestManagementV2Controller], + controllers: [ + ClaimRequestManagementController, + ClaimRequestManagementV2Controller, + RegistrarClaimV1Controller, + ], exports: [ ClaimRequestManagementService, ClaimRequestManagementDbService, diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 65cb8e9..111e1e6 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -2996,25 +2996,130 @@ export class ClaimRequestManagementService { currentUserId: string, actorRole?: string, ): Promise { - if (actorRole !== RoleEnum.FIELD_EXPERT || !claimCase?.blameRequestId) { + if ( + ![RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR].includes(actorRole as any) || + !claimCase?.blameRequestId + ) { return currentUserId; } const blameRequest = await this.blameRequestDbService.findById( claimCase.blameRequestId.toString(), ); - if ( - !blameRequest?.expertInitiated || - blameRequest.creationMethod !== "IN_PERSON" || - !blameRequest.initiatedByFieldExpertId - ) { - return currentUserId; - } - if (String(blameRequest.initiatedByFieldExpertId) !== currentUserId) { - return currentUserId; + if (!blameRequest || blameRequest.creationMethod !== "IN_PERSON") return currentUserId; + if (actorRole === RoleEnum.FIELD_EXPERT) { + if ( + !blameRequest.expertInitiated || + !blameRequest.initiatedByFieldExpertId || + String(blameRequest.initiatedByFieldExpertId) !== currentUserId + ) { + return currentUserId; + } + } else { + if ( + !blameRequest.registrarInitiated || + !blameRequest.initiatedByRegistrarId || + String(blameRequest.initiatedByRegistrarId) !== currentUserId + ) { + return currentUserId; + } } return claimCase.owner?.userId ? String(claimCase.owner.userId) : currentUserId; } + async createClaimFromBlameForRegistrarV1( + blameRequestId: string, + registrar: { sub: string; firstName?: string; lastName?: string }, + ): Promise { + const blameRequest = await this.blameRequestDbService.findById(blameRequestId); + if (!blameRequest) throw new NotFoundException("Blame request not found"); + if (blameRequest.status !== BlameCaseStatus.COMPLETED) { + throw new BadRequestException( + `Blame request must be COMPLETED. Current status: ${blameRequest.status}`, + ); + } + if ( + !blameRequest.registrarInitiated || + blameRequest.creationMethod !== "IN_PERSON" || + !blameRequest.initiatedByRegistrarId + ) { + throw new BadRequestException( + "This endpoint is only for registrar-initiated IN_PERSON blame files.", + ); + } + if (String(blameRequest.initiatedByRegistrarId) !== String(registrar.sub)) { + throw new ForbiddenException( + "Only the registrar who created this blame file can create the claim.", + ); + } + const parties = blameRequest.parties || []; + const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY; + let damagedUserId: string; + if (isCarBody) { + const first = parties.find((p) => p.role === "FIRST"); + if (!first?.person?.userId) throw new BadRequestException("First party has no userId"); + damagedUserId = String(first.person.userId); + } else { + const guiltyPartyId = blameRequest.expert?.decision?.guiltyPartyId + ? String(blameRequest.expert.decision.guiltyPartyId) + : null; + if (!guiltyPartyId) throw new BadRequestException("Blame request has no guilty party set"); + const damagedParty = parties.find( + (p) => p.person?.userId && String(p.person.userId) !== guiltyPartyId, + ); + if (!damagedParty?.person?.userId) throw new BadRequestException("Could not determine damaged party"); + damagedUserId = String(damagedParty.person.userId); + } + const existingClaim = await this.claimCaseDbService.findOne({ + blameRequestId: new Types.ObjectId(blameRequestId), + }); + if (existingClaim) throw new ConflictException("A claim for this blame case already exists"); + const claimNo = await this.generateUniqueClaimNumber(); + const damagedParty = parties.find( + (p) => p.person?.userId && String(p.person.userId) === damagedUserId, + ); + const newClaim = await this.claimCaseDbService.create({ + requestNo: claimNo, + publicId: blameRequest.publicId, + blameRequestId: new Types.ObjectId(blameRequestId), + blameRequestNo: blameRequest.requestNo, + status: ClaimCaseStatus.SELECTING_OUTER_PARTS, + claimStatus: ClaimStatus.PENDING, + workflow: { + currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, + nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, + completedSteps: [ClaimWorkflowStep.CLAIM_CREATED], + locked: false, + }, + owner: { + userId: new Types.ObjectId(damagedUserId), + userRole: damagedParty?.role as any, + }, + createdByRegistrarId: new Types.ObjectId(registrar.sub), + history: [ + { + type: "CLAIM_CREATED", + actor: { + actorId: new Types.ObjectId(registrar.sub), + actorName: `${registrar.firstName || ""} ${registrar.lastName || ""}`.trim(), + actorType: "registrar", + }, + timestamp: new Date(), + metadata: { + blameRequestId, + blamePublicId: blameRequest.publicId, + createdByRegistrar: true, + }, + }, + ], + } as any); + return { + claimRequestId: String(newClaim._id), + publicId: blameRequest.publicId, + status: ClaimCaseStatus.SELECTING_OUTER_PARTS, + message: "Claim created successfully. Registrar can now fill claim data.", + }; + } + /** * V2 API: Select damaged outer car parts for claim (Step 2 of claim workflow) * diff --git a/src/claim-request-management/entites/schema/claim-cases.schema.ts b/src/claim-request-management/entites/schema/claim-cases.schema.ts index efd8410..8f92330 100644 --- a/src/claim-request-management/entites/schema/claim-cases.schema.ts +++ b/src/claim-request-management/entites/schema/claim-cases.schema.ts @@ -133,6 +133,9 @@ export class ClaimCase { @Prop({ type: [HistoryEventSchema], default: [] }) history?: HistoryEvent[]; + @Prop({ type: Types.ObjectId, index: true }) + createdByRegistrarId?: Types.ObjectId; + /** * Legacy fields kept optional to simplify progressive migration. * If you choose to migrate later, we can remove these. diff --git a/src/claim-request-management/registrar-claim.v1.controller.ts b/src/claim-request-management/registrar-claim.v1.controller.ts new file mode 100644 index 0000000..b689bfa --- /dev/null +++ b/src/claim-request-management/registrar-claim.v1.controller.ts @@ -0,0 +1,135 @@ +import { Body, Controller, Get, Param, Patch, Post, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { diskStorage } from "multer"; +import { extname } from "path"; +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 { Roles } from "src/decorators/roles.decorator"; +import { CurrentUser } from "src/decorators/user.decorator"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { SelectOuterPartsV2Dto } from "./dto/select-outer-parts-v2.dto"; +import { SelectOtherPartsV2Dto } from "./dto/select-other-parts-v2.dto"; +import { UploadRequiredDocumentV2Dto } from "./dto/upload-document-v2.dto"; +import { CapturePartV2Dto } from "./dto/capture-part-v2.dto"; +import { ClaimRequestManagementService } from "./claim-request-management.service"; + +@ApiTags("registrar-claim (v1)") +@Controller("registrar-claim") +@ApiBearerAuth() +@UseGuards(LocalActorAuthGuard, RolesGuard) +@Roles(RoleEnum.REGISTRAR) +export class RegistrarClaimV1Controller { + constructor(private readonly claimRequestManagementService: ClaimRequestManagementService) {} + + @Post("create-from-blame/:blameRequestId") + @ApiParam({ name: "blameRequestId" }) + createFromBlame(@Param("blameRequestId") blameRequestId: string, @CurrentUser() registrar: any) { + return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1( + blameRequestId, + registrar, + ); + } + + @Get("request/:claimRequestId") + getClaim(@Param("claimRequestId") claimRequestId: string, @CurrentUser() registrar: any) { + return this.claimRequestManagementService.getClaimDetailsV2( + claimRequestId, + registrar.sub, + registrar, + ); + } + + @Patch("select-outer-parts/:claimRequestId") + @ApiBody({ type: SelectOuterPartsV2Dto }) + selectOuter( + @Param("claimRequestId") claimRequestId: string, + @Body() body: SelectOuterPartsV2Dto, + @CurrentUser() registrar: any, + ) { + return this.claimRequestManagementService.selectOuterPartsV2( + claimRequestId, + body, + registrar.sub, + registrar, + ); + } + + @Patch("select-other-parts/:claimRequestId") + @ApiBody({ type: SelectOtherPartsV2Dto }) + selectOther( + @Param("claimRequestId") claimRequestId: string, + @Body() body: SelectOtherPartsV2Dto, + @CurrentUser() registrar: any, + ) { + return this.claimRequestManagementService.selectOtherPartsV2( + claimRequestId, + body, + registrar.sub, + registrar, + ); + } + + @Get("capture-requirements/:claimRequestId") + getCapture(@Param("claimRequestId") claimRequestId: string, @CurrentUser() registrar: any) { + return this.claimRequestManagementService.getCaptureRequirementsV2( + claimRequestId, + registrar.sub, + registrar, + ); + } + + @Post("upload-document/:claimRequestId") + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: 10 * 1024 * 1024 }, + storage: diskStorage({ + destination: "./files/claim-documents", + filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`), + }), + }), + ) + @ApiOperation({ summary: "Registrar uploads required claim document" }) + uploadDoc( + @Param("claimRequestId") claimRequestId: string, + @Body() body: UploadRequiredDocumentV2Dto, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() registrar: any, + ) { + return this.claimRequestManagementService.uploadRequiredDocumentV2( + claimRequestId, + body, + file, + registrar.sub, + registrar, + ); + } + + @Post("capture-part/:claimRequestId") + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: 10 * 1024 * 1024 }, + storage: diskStorage({ + destination: "./files/claim-captures", + filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`), + }), + }), + ) + capturePart( + @Param("claimRequestId") claimRequestId: string, + @Body() body: CapturePartV2Dto, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() registrar: any, + ) { + return this.claimRequestManagementService.capturePartV2( + claimRequestId, + body, + file, + registrar.sub, + registrar, + ); + } +} + diff --git a/src/request-management/dto/registrar-initiated.dto.ts b/src/request-management/dto/registrar-initiated.dto.ts new file mode 100644 index 0000000..5cb6b3e --- /dev/null +++ b/src/request-management/dto/registrar-initiated.dto.ts @@ -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"; +} + diff --git a/src/request-management/entities/schema/blame-cases.schema.ts b/src/request-management/entities/schema/blame-cases.schema.ts index b46816c..e89cf32 100644 --- a/src/request-management/entities/schema/blame-cases.schema.ts +++ b/src/request-management/entities/schema/blame-cases.schema.ts @@ -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; diff --git a/src/request-management/entities/schema/request-management.schema.ts b/src/request-management/entities/schema/request-management.schema.ts index d51d977..9f90deb 100644 --- a/src/request-management/entities/schema/request-management.schema.ts +++ b/src/request-management/entities/schema/request-management.schema.ts @@ -282,6 +282,7 @@ export enum CreationMethod { export enum FilledBy { CUSTOMER = "customer", EXPERT = "expert", + REGISTRAR = "registrar", } export class ExpertLinkInfo { diff --git a/src/request-management/registrar-initiated.controller.ts b/src/request-management/registrar-initiated.controller.ts new file mode 100644 index 0000000..89acd03 --- /dev/null +++ b/src/request-management/registrar-initiated.controller.ts @@ -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!, + ); + } +} + diff --git a/src/request-management/request-management.module.ts b/src/request-management/request-management.module.ts index ae14395..c346df9 100644 --- a/src/request-management/request-management.module.ts +++ b/src/request-management/request-management.module.ts @@ -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, diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index dbea7e9..b5b51d4 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -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; } @@ -3232,16 +3232,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 }; } /** @@ -3594,7 +3664,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.", ); @@ -3655,7 +3728,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.", ); @@ -3710,7 +3786,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, @@ -3757,8 +3833,10 @@ export class RequestManagementService { async getMyExpertInitiatedFilesV2(expert: any): Promise { 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, @@ -3807,16 +3885,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 ( @@ -3879,7 +3961,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.", ); @@ -3907,7 +3992,10 @@ export class RequestManagementService { ): Promise { 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) { @@ -4210,7 +4298,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); diff --git a/src/users/entities/db-service/registrar.db.service.ts b/src/users/entities/db-service/registrar.db.service.ts new file mode 100644 index 0000000..18f176e --- /dev/null +++ b/src/users/entities/db-service/registrar.db.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { FilterQuery, Model, Types } from "mongoose"; +import { RegistrarModel } from "../schema/registrar.schema"; + +@Injectable() +export class RegistrarDbService { + constructor( + @InjectModel(RegistrarModel.name) + private readonly registrarModel: Model, + ) {} + + async create( + user: Partial & { password: string }, + ): Promise { + return this.registrarModel.create(user); + } + + async findOne( + filter: FilterQuery, + ): Promise { + return this.registrarModel.findOne(filter); + } + + async findOneAndUpdate( + filter: FilterQuery, + update: any, + ): Promise { + return this.registrarModel.findOneAndUpdate(filter, update, { new: true }); + } + + async updateOne(filter: FilterQuery, update: any) { + return this.registrarModel.updateOne(filter, update); + } + + async findById(userId: string): Promise { + return this.registrarModel.findOne({ _id: new Types.ObjectId(userId) }); + } +} + diff --git a/src/users/entities/schema/registrar.schema.ts b/src/users/entities/schema/registrar.schema.ts new file mode 100644 index 0000000..a314f78 --- /dev/null +++ b/src/users/entities/schema/registrar.schema.ts @@ -0,0 +1,38 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { Types } from "mongoose"; +import { RoleEnum } from "src/Types&Enums/role.enum"; + +@Schema({ + collection: "registrar", + versionKey: false, + timestamps: true, +}) +export class RegistrarModel { + _id?: Types.ObjectId; + + @Prop({ type: "string", unique: true }) + email: string; + + @Prop({ type: "string" }) + username: string; + + @Prop({ required: true }) + password: string; + + @Prop({ type: Types.ObjectId, required: true, index: true }) + clientKey: Types.ObjectId; + + @Prop({ default: RoleEnum.REGISTRAR }) + role: RoleEnum; + + @Prop({ type: "string", default: "" }) + otp: string; +} + +export const RegistrarDbSchema = SchemaFactory.createForClass(RegistrarModel); + +RegistrarDbSchema.pre("save", function (next) { + if (!this.username) this.username = this.email; + next(); +}); + diff --git a/src/users/users.module.ts b/src/users/users.module.ts index c9fa042..aa050b7 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -7,6 +7,7 @@ import { OtpModule } from "src/utils/otp/otp.module"; import { ExpertDbService } from "./entities/db-service/expert.db.service"; import { FieldExpertDbService } from "./entities/db-service/field-expert.db.service"; import { InsurerExpertDbService } from "./entities/db-service/insurer-expert.db.service"; +import { RegistrarDbService } from "./entities/db-service/registrar.db.service"; import { UserDbService } from "./entities/db-service/user.db.service"; import { DamageExpertDbSchema, @@ -21,6 +22,10 @@ import { InsurerExpertDbSchema, InsurerExpertModel, } from "./entities/schema/insurer-expert.schema"; +import { + RegistrarDbSchema, + RegistrarModel, +} from "./entities/schema/registrar.schema"; import { UserModel, UserDbSchema } from "./entities/schema/user.schema"; @Module({ @@ -31,6 +36,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema"; { name: ExpertModel.name, schema: ExpertDbSchema }, { name: FieldExpertModel.name, schema: FieldExpertDbSchema }, { name: InsurerExpertModel.name, schema: InsurerExpertDbSchema }, + { name: RegistrarModel.name, schema: RegistrarDbSchema }, ]), OtpModule, HashModule, @@ -42,6 +48,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema"; DamageExpertDbService, FieldExpertDbService, InsurerExpertDbService, + RegistrarDbService, ], exports: [ UserDbService, @@ -49,6 +56,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema"; DamageExpertDbService, FieldExpertDbService, InsurerExpertDbService, + RegistrarDbService, ], }) export class UsersModule {}