From 9828aee8af1c67d7ee68c809e5d228bb5963cffa Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 26 Jul 2026 11:35:21 +0330 Subject: [PATCH 1/3] YARA-1136 --- src/Types&Enums/role.enum.ts | 1 + src/auth/auth-services/actor.auth.service.ts | 10 + .../call-center-blame-v6.controller.ts | 154 +++++++++++ .../dto/run-call-center-inquiry-v6.dto.ts | 85 ++++++ .../entities/schema/blame-cases.schema.ts | 20 ++ .../request-management.module.ts | 2 + .../request-management.service.ts | 247 ++++++++++++++++++ src/super-admin/dto/super-admin.dto.ts | 34 +++ src/super-admin/super-admin.controller.ts | 8 + src/super-admin/super-admin.service.ts | 27 ++ .../call-center-agent.db.service.ts | 50 ++++ .../schema/call-center-agent.schema.ts | 61 +++++ src/users/users.module.ts | 8 + 13 files changed, 707 insertions(+) create mode 100644 src/request-management/call-center-blame-v6.controller.ts create mode 100644 src/request-management/dto/run-call-center-inquiry-v6.dto.ts create mode 100644 src/users/entities/db-service/call-center-agent.db.service.ts create mode 100644 src/users/entities/schema/call-center-agent.schema.ts diff --git a/src/Types&Enums/role.enum.ts b/src/Types&Enums/role.enum.ts index c71b816..812aaf9 100644 --- a/src/Types&Enums/role.enum.ts +++ b/src/Types&Enums/role.enum.ts @@ -9,4 +9,5 @@ export enum RoleEnum { FILE_MAKER = "file_maker", FILE_REVIEWER = "file_reviewer", SUPER_ADMIN = "super_admin", + CALL_CENTER = "call_center", } diff --git a/src/auth/auth-services/actor.auth.service.ts b/src/auth/auth-services/actor.auth.service.ts index 3d87a8c..736c754 100644 --- a/src/auth/auth-services/actor.auth.service.ts +++ b/src/auth/auth-services/actor.auth.service.ts @@ -29,6 +29,7 @@ import { FieldExpertDbService } from "src/users/entities/db-service/field-expert import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service"; import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service"; import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service"; +import { CallCenterAgentDbService } from "src/users/entities/db-service/call-center-agent.db.service"; import { HashService } from "src/utils/hash/hash.service"; import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service"; import { SuperAdminDbService } from "src/super-admin/entities/db-service/super-admin.db.service"; @@ -58,6 +59,7 @@ export class ActorAuthService { private readonly fileMakerDbService: FileMakerDbService, private readonly fileReviewerDbService: FileReviewerDbService, private readonly superAdminDbService: SuperAdminDbService, + private readonly callCenterAgentDbService: CallCenterAgentDbService, ) {} // TODO convrt to class for dynamic controller @@ -123,6 +125,14 @@ export class ActorAuthService { }); else res = await this.superAdminDbService.findByLoginIdentifier(username); break; + case RoleEnum.CALL_CENTER: + if (username == null && userId) + res = await this.callCenterAgentDbService.findOne({ + _id: new Types.ObjectId(userId), + }); + else + res = await this.callCenterAgentDbService.findByLoginIdentifier(username); + break; default: return null; } diff --git a/src/request-management/call-center-blame-v6.controller.ts b/src/request-management/call-center-blame-v6.controller.ts new file mode 100644 index 0000000..4659623 --- /dev/null +++ b/src/request-management/call-center-blame-v6.controller.ts @@ -0,0 +1,154 @@ +import { + Body, + Controller, + Get, + Param, + Post, + UseGuards, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + 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 { RequestManagementService } from "./request-management.service"; +import { RunCallCenterInquiryV6Dto } from "./dto/run-call-center-inquiry-v6.dto"; + +/** + * V6 call-center blame API. + * + * A call-center agent takes the guilty party's details over the phone + * (plate, national code, birthday), runs the insurance inquiry, then sends + * the blame link via SMS. The user (guilty party) opens the link and + * completes the form through the standard v2 user flow — except the + * initial-form / inquiry step is skipped (`skipInitialFormStep=true`) because + * the agent already did it. + * + * For THIRD_PARTY files: only the guilty party's data is collected here. + * The damaged party sees their own portion of the page and fills their info + * as normal after the blame link is opened. + */ +@ApiTags("call-center-blame (v6)") +@Controller("v6/call-center-blame") +@ApiBearerAuth() +@UseGuards(LocalActorAuthGuard, RolesGuard) +@Roles(RoleEnum.CALL_CENTER) +export class CallCenterBlameV6Controller { + constructor( + private readonly requestManagementService: RequestManagementService, + ) {} + + @Get("my-files") + @ApiOperation({ summary: "[V6] List blame files created by this call-center agent" }) + @ApiResponse({ status: 200, description: "List of blame files started by this agent" }) + getMyFiles(@CurrentUser() agent: any) { + return this.requestManagementService.getMyCallCenterFilesV6(agent); + } + + @Post("create") + @ApiOperation({ + summary: "[V6] Create a call-center–initiated blame file", + description: + "Creates a LINK blame file. The call-center agent collects the guilty " + + "party's plate + national-code over the phone, calls run-inquiry to store " + + "the results, then calls send-link to SMS the blame URL to the user. " + + "The user opens the link and fills the form via the normal v2 flow; the " + + "initial-form/inquiry step is automatically skipped because the agent " + + "already ran it.", + }) + @ApiBody({ + schema: { + type: "object", + required: ["type"], + properties: { + type: { + type: "string", + enum: ["THIRD_PARTY", "CAR_BODY"], + example: "THIRD_PARTY", + }, + }, + }, + }) + createFile( + @CurrentUser() agent: any, + @Body("type") type: "THIRD_PARTY" | "CAR_BODY", + ) { + return this.requestManagementService.createCallCenterInitiatedBlameV6(agent, { type }); + } + + @Post("run-inquiry/:requestId") + @ApiOperation({ + summary: "[V6] Run plate + insurance inquiry for the guilty party", + description: + "Call after `create`. The agent supplies the plate and national-code data " + + "collected from the caller. Plate + third-party block inquiry is executed " + + "and the results are stored on the blame document. " + + "For THIRD_PARTY files only the guilty party (first party) is inquired here; " + + "the damaged party's inquiry is handled later by the user via the link. " + + "Sheba (IBAN) is not collected here — the user adds it themselves.", + }) + @ApiParam({ name: "requestId", description: "Blame request ID from `create`" }) + @ApiBody({ type: RunCallCenterInquiryV6Dto }) + runInquiry( + @CurrentUser() agent: any, + @Param("requestId") requestId: string, + @Body() dto: RunCallCenterInquiryV6Dto, + ) { + return this.requestManagementService.runCallCenterInquiryV6(agent, requestId, dto); + } + + @Post("send-link/:requestId") + @ApiOperation({ + summary: "[V6] Send blame link to the guilty party via SMS", + description: + "Call after `run-inquiry`. Provide the guilty party's phone number; " + + "the service registers the user if needed, stores them as the first party, " + + "and sends the blame invite link via SMS. The user opens the link and " + + "completes the blame form via the standard v2 user flow (initial-form step skipped).", + }) + @ApiParam({ name: "requestId", description: "Blame request ID" }) + @ApiBody({ + schema: { + type: "object", + required: ["phoneNumber"], + properties: { + phoneNumber: { + type: "string", + example: "09121234567", + description: "Mobile number of the guilty party", + }, + }, + }, + }) + sendLink( + @CurrentUser() agent: any, + @Param("requestId") requestId: string, + @Body("phoneNumber") phoneNumber: string, + ) { + return this.requestManagementService.sendCallCenterLinkV6(agent, requestId, { phoneNumber }); + } + + @Get("blame/:requestId") + @ApiOperation({ + summary: "[V6] Get a single blame file created by this agent", + description: + "Returns the current status, workflow step, and party data for one blame " + + "file started by this call-center agent. Useful for checking whether the " + + "user has opened the link and progressed through the form.", + }) + @ApiParam({ name: "requestId", description: "Blame request ID" }) + getBlame( + @CurrentUser() agent: any, + @Param("requestId") requestId: string, + ) { + return this.requestManagementService.getCallCenterBlameDetailV6(agent, requestId); + } +} diff --git a/src/request-management/dto/run-call-center-inquiry-v6.dto.ts b/src/request-management/dto/run-call-center-inquiry-v6.dto.ts new file mode 100644 index 0000000..7e505c5 --- /dev/null +++ b/src/request-management/dto/run-call-center-inquiry-v6.dto.ts @@ -0,0 +1,85 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; +import { Type } from "class-transformer"; + +class PlateV6Dto { + @ApiProperty({ example: "44", description: "Left two digits" }) + @IsString() + @IsNotEmpty() + leftDigits: string; + + @ApiProperty({ example: "ب", description: "Center alphabet letter" }) + @IsString() + @IsNotEmpty() + centerAlphabet: string; + + @ApiProperty({ example: "111", description: "Center three digits" }) + @IsString() + @IsNotEmpty() + centerDigits: string; + + @ApiProperty({ example: "22", description: "Right two digits (Iran region code)" }) + @IsString() + @IsNotEmpty() + ir: string; +} + +/** + * Inquiry body for the V6 call-center flow. + * Same as V3 but without `sheba` — the user adds their own IBAN later via the link. + */ +export class RunCallCenterInquiryV6Dto { + @ApiProperty({ + type: PlateV6Dto, + description: "Plate segments — Tejarat block / third-party inquiry.", + }) + @ValidateNested() + @Type(() => PlateV6Dto) + plate: PlateV6Dto; + + @ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" }) + @IsString() + @IsNotEmpty() + nationalCodeOfInsurer: string; + + @ApiProperty({ example: "1234567890", description: "National code of the driver" }) + @IsString() + @IsNotEmpty() + nationalCodeOfDriver: string; + + @ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" }) + @IsBoolean() + driverIsInsurer: boolean; + + @ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" }) + insurerBirthday: number | string; + + @ApiPropertyOptional({ + example: 13780624, + description: "Driver birth date (Jalali). Required when driverIsInsurer is false.", + }) + @IsOptional() + driverBirthday?: number | string | null; + + @ApiPropertyOptional({ + example: "123456789", + description: "Driver license (required when driverIsInsurer is false).", + }) + @IsOptional() + @IsString() + driverLicense?: string; + + @ApiPropertyOptional({ + example: "123456789", + description: "Insurer license (required when driverIsInsurer is true).", + }) + @IsOptional() + @IsString() + insurerLicense?: string; +} diff --git a/src/request-management/entities/schema/blame-cases.schema.ts b/src/request-management/entities/schema/blame-cases.schema.ts index b2115ca..090ddda 100644 --- a/src/request-management/entities/schema/blame-cases.schema.ts +++ b/src/request-management/entities/schema/blame-cases.schema.ts @@ -131,6 +131,26 @@ export class BlameRequest { */ @Prop({ type: Types.ObjectId }) assignedFileReviewerId?: Types.ObjectId; + + /** + * V6 call-center flow: true when this blame was started by a call-center agent + * on behalf of the guilty party who called in. + */ + @Prop({ default: false }) + callCenterInitiated?: boolean; + + /** + * V6 call-center flow: the call-center agent who created this file. + */ + @Prop({ type: Types.ObjectId }) + initiatedByCallCenterId?: Types.ObjectId; + + /** + * V6 call-center flow: when true the user-side blame page should skip the + * inquiry / initial-form step (the call-center agent already ran the inquiry). + */ + @Prop({ default: false }) + skipInitialFormStep?: boolean; } export type BlameRequestDocument = HydratedDocument; diff --git a/src/request-management/request-management.module.ts b/src/request-management/request-management.module.ts index 9da5a2d..5a256df 100644 --- a/src/request-management/request-management.module.ts +++ b/src/request-management/request-management.module.ts @@ -50,6 +50,7 @@ import { FileReviewerBlameV5Controller } from "./file-reviewer-blame-v5.controll import { FileMakerClaimApprovalV5Controller } from "./file-maker-claim-approval-v5.controller"; import { InquiryRefreshController } from "./inquiry-refresh.controller"; import { InquiryRefreshService } from "./inquiry-refresh.service"; +import { CallCenterBlameV6Controller } from "./call-center-blame-v6.controller"; @Module({ imports: [ @@ -92,6 +93,7 @@ import { InquiryRefreshService } from "./inquiry-refresh.service"; FileReviewerBlameV5Controller, FileMakerClaimApprovalV5Controller, InquiryRefreshController, + CallCenterBlameV6Controller, ], providers: [ RequestManagementService, diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 5669630..c5c2d03 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -10075,4 +10075,251 @@ export class RequestManagementService { ); } } + + // ── V6 call-center flow ──────────────────────────────────────────────── + + /** + * V6: Create a LINK blame file initiated by a call-center agent. + * Always LINK + CUSTOMER-filled (user fills the form after receiving the SMS). + * `skipInitialFormStep` is set so the user-side page skips the inquiry step + * (the agent already ran it via runCallCenterInquiryV6). + */ + async createCallCenterInitiatedBlameV6( + agent: any, + dto: { type: "THIRD_PARTY" | "CAR_BODY" }, + ): Promise<{ requestId: string; publicId: string }> { + if (agent?.role !== RoleEnum.CALL_CENTER) { + throw new ForbiddenException("Only call-center agents can use this endpoint."); + } + const agentId = new Types.ObjectId(agent.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", + ); + } + 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 created = await this.blameRequestDbService.create({ + publicId, + requestNo: publicId, + type, + parties: [{ role: PartyRole.FIRST, person: {} }], + workflow: { + currentStep: firstStep.stepKey as WorkflowStep, + nextStep, + completedSteps: [firstStep.stepKey as WorkflowStep], + locked: false, + }, + history: [], + callCenterInitiated: true, + initiatedByCallCenterId: agentId, + creationMethod: CreationMethod.LINK, + filledBy: FilledBy.CUSTOMER, + // User-side page should skip the inquiry/initial-form step + skipInitialFormStep: true, + }); + + const requestId = String((created as any)._id); + if (!Array.isArray((created as any).history)) (created as any).history = []; + (created as any).history.push({ + type: "FILE_CREATED_BY_CALL_CENTER", + actor: { + actorId: agentId, + actorName: `${agent.firstName || ""} ${agent.lastName || ""}`.trim(), + actorType: RoleEnum.CALL_CENTER, + }, + metadata: { creationMethod: CreationMethod.LINK, type: dto.type }, + }); + await (created as any).save(); + + return { requestId, publicId: (created as any).publicId }; + } + + /** + * V6: Run plate + personal inquiry for the guilty party on behalf of the caller. + * Stores the inquiry results on the blame's first party (same as V3 guilty-party path) + * but does NOT create a claim — the user will do that after filling the form via the link. + * Note: sheba is intentionally absent — the user provides their own IBAN later. + */ + async runCallCenterInquiryV6( + agent: any, + requestId: string, + dto: Omit, + ): Promise<{ blameRequestId: string; message: string }> { + if (agent?.role !== RoleEnum.CALL_CENTER) { + throw new ForbiddenException("Only call-center agents can use this endpoint."); + } + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Blame request not found"); + if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) { + throw new ForbiddenException("You can only access files that you have initiated."); + } + + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + if (firstIdx === -1) throw new BadRequestException("First party not found"); + const firstParty = req.parties[firstIdx]; + + await this.runPartyInquiriesV3Internal(req, dto, PartyRole.FIRST, firstParty); + this.markPartyInquiriesCompleteOnBlame(req, PartyRole.FIRST, agent); + if (!Array.isArray((req as any).history)) (req as any).history = []; + (req as any).history.push({ + type: "CALL_CENTER_INQUIRY_COMPLETED", + actor: { + actorId: new Types.ObjectId(agent.sub), + actorName: `${agent.firstName || ""} ${agent.lastName || ""}`.trim(), + actorType: RoleEnum.CALL_CENTER, + }, + metadata: { partyRole: PartyRole.FIRST }, + }); + await (req as any).save(); + + return { + blameRequestId: requestId, + message: "Guilty-party inquiry complete. You can now send the blame link to the user.", + }; + } + + /** + * V6: Send the blame link to the guilty party's phone number. + * Registers/looks up the user by phone and stores them as the first party, then + * sends the SMS link so the user can fill in the rest of the form via the v2 flow. + */ + async sendCallCenterLinkV6( + agent: any, + requestId: string, + dto: { phoneNumber: string }, + ): Promise<{ sent: boolean; sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: string }[] }> { + if (agent?.role !== RoleEnum.CALL_CENTER) { + throw new ForbiddenException("Only call-center agents can use this endpoint."); + } + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) { + throw new ForbiddenException("You can only access files that you have initiated."); + } + if (!process.env.URL) { + throw new InternalServerErrorException("URL environment variable is not configured"); + } + + const phone = (dto?.phoneNumber || "").trim(); + if (!phone) throw new BadRequestException("phoneNumber is required"); + + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + if (firstIdx === -1) throw new BadRequestException("First party not found on request"); + const userId = await this.getOrCreateUserByPhoneNumber(phone); + if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any; + req.parties[firstIdx].person.phoneNumber = normalizeIranMobile(phone) ?? phone; + req.parties[firstIdx].person.userId = userId; + + const expertName = `${agent?.lastName || ""}`.trim() || "اپراتور"; + const firstLink = this.smsOrchestrationService.buildBlamePartyLink(requestId, "FIRST"); + const smsSent = await this.smsOrchestrationService.sendFieldExpertLink({ + receptor: phone, + type: req.type, + expertLastName: expertName, + link: firstLink, + }); + + const sentTo = [{ role: PartyRole.FIRST, phoneNumber: phone, smsSent, linkUrl: firstLink }]; + + if (!Array.isArray((req as any).history)) (req as any).history = []; + (req as any).history.push({ + type: "CALL_CENTER_LINK_SENT", + actor: { + actorId: new Types.ObjectId(agent.sub), + actorName: `${agent.firstName || ""} ${agent.lastName || ""}`.trim(), + actorType: RoleEnum.CALL_CENTER, + }, + metadata: { sentTo }, + }); + await (req as any).save(); + + return { sent: smsSent, sentTo }; + } + + /** + * V6: Get a single blame file detail for the call-center agent who created it. + */ + async getCallCenterBlameDetailV6(agent: any, requestId: string): Promise { + if (agent?.role !== RoleEnum.CALL_CENTER) { + throw new ForbiddenException("Only call-center agents can use this endpoint."); + } + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Blame request not found"); + if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) { + throw new ForbiddenException("You can only access files that you have initiated."); + } + return { + _id: (req as any)._id, + publicId: (req as any).publicId, + requestNo: (req as any).requestNo, + type: (req as any).type, + status: (req as any).status, + blameStatus: (req as any).blameStatus, + workflow: (req as any).workflow, + skipInitialFormStep: (req as any).skipInitialFormStep, + parties: ((req as any).parties ?? []).map((p: any) => ({ + role: p.role, + person: { + phoneNumber: p.person?.phoneNumber, + nationalCodeOfInsurer: p.person?.nationalCodeOfInsurer, + clientId: p.person?.clientId, + }, + insurance: p.insurance + ? { + company: p.insurance.company, + policyNumber: p.insurance.policyNumber, + startDate: p.insurance.startDate, + endDate: p.insurance.endDate, + } + : undefined, + vehicle: p.vehicle + ? { plateId: p.vehicle.plateId, name: p.vehicle.name } + : undefined, + })), + createdAt: (req as any).createdAt, + }; + } + + /** + * V6: List blame files created by this call-center agent. + */ + async getMyCallCenterFilesV6(agent: any): Promise { + if (agent?.role !== RoleEnum.CALL_CENTER) { + throw new ForbiddenException("Only call-center agents can use this endpoint."); + } + const agentId = new Types.ObjectId(agent.sub); + const files = await this.blameRequestDbService.find({ + callCenterInitiated: true, + initiatedByCallCenterId: agentId, + }); + return (files || []).map((f: any) => ({ + _id: f._id, + publicId: f.publicId, + requestNo: f.requestNo, + type: f.type, + status: f.status, + blameStatus: f.blameStatus, + workflow: f.workflow, + skipInitialFormStep: f.skipInitialFormStep, + createdAt: f.createdAt, + })); + } } diff --git a/src/super-admin/dto/super-admin.dto.ts b/src/super-admin/dto/super-admin.dto.ts index 36f9ab1..f0dddcf 100644 --- a/src/super-admin/dto/super-admin.dto.ts +++ b/src/super-admin/dto/super-admin.dto.ts @@ -74,3 +74,37 @@ export class CreateRegistrarAdminDto { @IsNotEmpty() clientId: string; } + +export class CreateCallCenterAgentDto { + @ApiProperty({ example: "agent@insurer.ir" }) + @IsEmail() + email: string; + + @ApiProperty({ example: "CallCenter@724" }) + @IsString() + @IsNotEmpty() + password: string; + + @ApiProperty({ example: "Sara" }) + @IsString() + @IsNotEmpty() + firstName: string; + + @ApiProperty({ example: "Hosseini" }) + @IsString() + @IsNotEmpty() + lastName: string; + + @ApiProperty({ + example: "664a1b2c3d4e5f6789012345", + description: "Mongo ObjectId of the insurer client this agent belongs to.", + }) + @IsString() + @IsNotEmpty() + clientId: string; + + @ApiPropertyOptional({ example: "09121234567" }) + @IsOptional() + @IsString() + mobile?: string; +} diff --git a/src/super-admin/super-admin.controller.ts b/src/super-admin/super-admin.controller.ts index fc813d4..3b06ad2 100644 --- a/src/super-admin/super-admin.controller.ts +++ b/src/super-admin/super-admin.controller.ts @@ -26,6 +26,7 @@ import { CreateSuperAdminDto, CreateFieldExpertAdminDto, CreateRegistrarAdminDto, + CreateCallCenterAgentDto, } from "./dto/super-admin.dto"; import { ClientDto } from "src/client/dto/create-client.dto"; import { @@ -151,6 +152,13 @@ export class SuperAdminController { return this.superAdminService.createRegistrar(body); } + @Post("create-call-center-agent") + @ApiOperation({ summary: "Create a call-center agent for a given client" }) + @ApiBody({ type: CreateCallCenterAgentDto }) + createCallCenterAgent(@Body() body: CreateCallCenterAgentDto) { + return this.superAdminService.createCallCenterAgent(body); + } + // ── System settings ────────────────────────────────────────────────────── @Get("system-settings") diff --git a/src/super-admin/super-admin.service.ts b/src/super-admin/super-admin.service.ts index 5a91545..a7b7a0d 100644 --- a/src/super-admin/super-admin.service.ts +++ b/src/super-admin/super-admin.service.ts @@ -19,10 +19,12 @@ import { CreateSuperAdminDto, CreateFieldExpertAdminDto, CreateRegistrarAdminDto, + CreateCallCenterAgentDto, } from "./dto/super-admin.dto"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service"; import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service"; +import { CallCenterAgentDbService } from "src/users/entities/db-service/call-center-agent.db.service"; @Injectable() export class SuperAdminService { @@ -33,6 +35,7 @@ export class SuperAdminService { private readonly actorAuthService: ActorAuthService, private readonly fieldExpertDbService: FieldExpertDbService, private readonly registrarDbService: RegistrarDbService, + private readonly callCenterAgentDbService: CallCenterAgentDbService, ) {} /** List all super-admin accounts (sans password). */ @@ -99,4 +102,28 @@ export class SuperAdminService { async createRegistrar(body: CreateRegistrarAdminDto) { return this.actorAuthService.createRegistrarMock(body); } + + async createCallCenterAgent(body: CreateCallCenterAgentDto) { + const email = body.email.toLowerCase().trim(); + const existing = await this.callCenterAgentDbService.findOne({ email }); + if (existing) { + throw new ConflictException( + "A call-center agent with this email already exists.", + ); + } + const password = await this.hashService.hash(body.password); + const created = await this.callCenterAgentDbService.create({ + email, + password, + firstName: body.firstName, + lastName: body.lastName, + clientKey: new Types.ObjectId(body.clientId), + mobile: body.mobile, + role: RoleEnum.CALL_CENTER, + }); + const { password: _pw, ...rest } = (created as any).toObject + ? (created as any).toObject() + : (created as any); + return rest; + } } diff --git a/src/users/entities/db-service/call-center-agent.db.service.ts b/src/users/entities/db-service/call-center-agent.db.service.ts new file mode 100644 index 0000000..20cdaec --- /dev/null +++ b/src/users/entities/db-service/call-center-agent.db.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { FilterQuery, Model, Types } from "mongoose"; +import { CallCenterAgentModel } from "../schema/call-center-agent.schema"; + +@Injectable() +export class CallCenterAgentDbService { + constructor( + @InjectModel(CallCenterAgentModel.name) + private readonly model: Model, + ) {} + + async create( + data: Partial & { password: string }, + ): Promise { + return this.model.create(data); + } + + async findOne( + filter: FilterQuery, + ): Promise { + return this.model.findOne(filter); + } + + async findById(id: string): Promise { + return this.model.findOne({ _id: new Types.ObjectId(id) }); + } + + async findByLoginIdentifier( + identifier: string, + ): Promise { + const id = identifier.trim(); + const or: FilterQuery[] = [ + { email: id }, + { username: id }, + ]; + if (/^\d{10}$/.test(id)) { + or.push({ nationalCode: id }); + } + return this.model.findOne({ $or: or }); + } + + async findAll( + filter: FilterQuery, + ): Promise<(CallCenterAgentModel & { _id: Types.ObjectId })[]> { + return this.model.find(filter).lean() as Promise< + (CallCenterAgentModel & { _id: Types.ObjectId })[] + >; + } +} diff --git a/src/users/entities/schema/call-center-agent.schema.ts b/src/users/entities/schema/call-center-agent.schema.ts new file mode 100644 index 0000000..16b6ae9 --- /dev/null +++ b/src/users/entities/schema/call-center-agent.schema.ts @@ -0,0 +1,61 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { Types } from "mongoose"; +import { RoleEnum } from "src/Types&Enums/role.enum"; + +/** + * Call-center agent actor. Works under an insurance company (clientKey) and + * can start V6 LINK blame files on behalf of callers. + */ +@Schema({ + collection: "call-center-agents", + versionKey: false, + timestamps: true, +}) +export class CallCenterAgentModel { + _id?: Types.ObjectId; + + @Prop({ required: true }) + firstName: string; + + @Prop({ required: true }) + lastName: string; + + @Prop({ type: String, unique: true, sparse: true, required: false }) + email?: string; + + @Prop({ type: String }) + username?: string; + + @Prop({ type: String, index: true, sparse: true }) + nationalCode?: string; + + @Prop({ type: Types.ObjectId, index: true }) + clientKey?: Types.ObjectId; + + @Prop({ required: true }) + password: string; + + @Prop({ required: false }) + mobile?: string; + + @Prop({ required: false }) + phone?: string; + + @Prop({ default: RoleEnum.CALL_CENTER }) + role: RoleEnum; + + @Prop({ type: "string", default: "" }) + otp: string; + + createdAt: Date; +} + +export const CallCenterAgentDbSchema = + SchemaFactory.createForClass(CallCenterAgentModel); + +CallCenterAgentDbSchema.pre("save", function (next) { + if (!this.username) { + this.username = (this as any).email || (this as any).nationalCode; + } + next(); +}); diff --git a/src/users/users.module.ts b/src/users/users.module.ts index e2209f0..a72add0 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -11,6 +11,7 @@ import { UserDbService } from "./entities/db-service/user.db.service"; import { ExpertFileActivityDbService } from "./entities/db-service/expert-file-activity.db.service"; import { FileMakerDbService } from "./entities/db-service/file-maker.db.service"; import { FileReviewerDbService } from "./entities/db-service/file-reviewer.db.service"; +import { CallCenterAgentDbService } from "./entities/db-service/call-center-agent.db.service"; import { DamageExpertDbSchema, DamageExpertModel, @@ -41,6 +42,10 @@ import { FileReviewerDbSchema, FileReviewerModel, } from "./entities/schema/file-reviewer.schema"; +import { + CallCenterAgentDbSchema, + CallCenterAgentModel, +} from "./entities/schema/call-center-agent.schema"; @Module({ imports: [ @@ -54,6 +59,7 @@ import { { name: ExpertFileActivity.name, schema: ExpertFileActivitySchema }, { name: FileMakerModel.name, schema: FileMakerDbSchema }, { name: FileReviewerModel.name, schema: FileReviewerDbSchema }, + { name: CallCenterAgentModel.name, schema: CallCenterAgentDbSchema }, ]), HashModule, ], @@ -68,6 +74,7 @@ import { ExpertFileActivityDbService, FileMakerDbService, FileReviewerDbService, + CallCenterAgentDbService, ], exports: [ UserDbService, @@ -79,6 +86,7 @@ import { ExpertFileActivityDbService, FileMakerDbService, FileReviewerDbService, + CallCenterAgentDbService, ], }) export class UsersModule {} From 4b9d946bfd2c6f3d4410da56f60bd0c7a84ea5da Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Mon, 27 Jul 2026 09:31:07 +0330 Subject: [PATCH 2/3] YARA-1154 --- src/expert-blame/expert-blame.service.ts | 2 ++ src/expert-claim/expert-claim.service.ts | 4 ++-- .../request-management.service.ts | 7 ++++++- .../sms-orchestration.service.ts | 15 ++++++++++----- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 07377c9..6ddb723 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -1871,6 +1871,7 @@ export class ExpertBlameService { link: this.smsOrchestrationService.buildBlamePartyLink( requestIdToken, role, + "v1", ), }); } @@ -2065,6 +2066,7 @@ export class ExpertBlameService { link: this.smsOrchestrationService.buildBlamePartyLink( requestIdToken, linkRole, + "v1", ), }); } diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 02cf412..01a7db3 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -3164,7 +3164,7 @@ export class ExpertClaimService { receptor: ownerPhoneResend, fileKind: "claim", publicId: claim.publicId, - link: this.smsOrchestrationService.buildClaimLink(String(claim._id)), + link: this.smsOrchestrationService.buildClaimLink(String(claim._id), "v1"), }); } @@ -3462,7 +3462,7 @@ export class ExpertClaimService { fileKind: "claim", publicId: claim.publicId, expertLastName, - link: this.smsOrchestrationService.buildClaimLink(String(claim._id)), + link: this.smsOrchestrationService.buildClaimLink(String(claim._id), "v1"), }); } diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index c5c2d03..ecad0b3 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -2319,6 +2319,7 @@ export class RequestManagementService { const url = this.smsOrchestrationService.buildInviteLink( frontendRoute, requestId, + "v1", ); await this.smsOrchestrationService.sendInviteLink( secondPartyPhone, @@ -2372,6 +2373,7 @@ export class RequestManagementService { const url = this.smsOrchestrationService.buildInviteLink( frontendRoute, requestId, + "v1", ); await this.smsOrchestrationService.sendInviteLink( phoneNumber, @@ -3485,6 +3487,7 @@ export class RequestManagementService { const URL = this.smsOrchestrationService.buildInviteLink( frontendRoutes, requestId, + "v1", ); await this.smsOrchestrationService.sendInviteLink( phoneNumber, @@ -4927,6 +4930,7 @@ export class RequestManagementService { const firstLink = this.smsOrchestrationService.buildBlamePartyLink( requestId, "FIRST", + "v2", ); const smsSent = await this.smsOrchestrationService.sendFieldExpertLink({ receptor: phone, @@ -8365,6 +8369,7 @@ export class RequestManagementService { targetPhone === request?.parties[0]?.person.phoneNumber ? "FIRST" : "SECOND", + "v1", ); await this.smsOrchestrationService.sendThirdPartyDamagedPartyClaimLinkNotice( { @@ -10229,7 +10234,7 @@ export class RequestManagementService { req.parties[firstIdx].person.userId = userId; const expertName = `${agent?.lastName || ""}`.trim() || "اپراتور"; - const firstLink = this.smsOrchestrationService.buildBlamePartyLink(requestId, "FIRST"); + const firstLink = this.smsOrchestrationService.buildBlamePartyLink(requestId, "FIRST", "v6"); const smsSent = await this.smsOrchestrationService.sendFieldExpertLink({ receptor: phone, type: req.type, diff --git a/src/sms-orchestration/sms-orchestration.service.ts b/src/sms-orchestration/sms-orchestration.service.ts index d727ef7..ae040f6 100644 --- a/src/sms-orchestration/sms-orchestration.service.ts +++ b/src/sms-orchestration/sms-orchestration.service.ts @@ -37,20 +37,25 @@ export class SmsOrchestrationService implements OnModuleInit { ); } - buildInviteLink(frontendRoute: string, requestId: string): string { - return `${process.env.URL}/${process.env.USER_BASE_PATH}/${frontendRoute}?token=${requestId}`; + buildInviteLink( + frontendRoute: string, + requestId: string, + versionPrefix: string, + ): string { + return `${process.env.URL}/${versionPrefix}/${frontendRoute}?token=${requestId}`; } buildBlamePartyLink( requestId: string, partyRole: "FIRST" | "SECOND", + versionPrefix: string, ): string { const route = partyRole === "SECOND" ? "user2" : "user"; - return `${process.env.URL}/${process.env.USER_BASE_PATH}/${route}?token=${requestId}`; + return `${process.env.URL}/${versionPrefix}/${route}?token=${requestId}`; } - buildClaimLink(claimRequestId: string): string { - return `${process.env.URL}/${process.env.USER_BASE_PATH}/caseClaim?token=${claimRequestId}`; + buildClaimLink(claimRequestId: string, versionPrefix: string): string { + return `${process.env.URL}/${versionPrefix}/caseClaim?token=${claimRequestId}`; } async sendInviteLink( From e4c3b7a16ad30990524a0ce9381d1a8426d2deb7 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Mon, 27 Jul 2026 10:15:46 +0330 Subject: [PATCH 3/3] YARA-1162 --- .../request-management.service.ts | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index ecad0b3..734f8c5 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -9373,16 +9373,26 @@ export class RequestManagementService { }, }; + if (typeof (req as any).markModified === "function") { + (req as any).markModified("expert"); + } + if (!Array.isArray(req.history)) req.history = []; - req.history.push({ - type: "V3_ACCIDENT_FIELDS_SAVED", - actor: { - actorId: new Types.ObjectId(String(expert.sub)), - actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), - actorType: "field_expert", - }, - metadata: { accidentWay: fields.accidentWay }, - } as any); + // Avoid duplicate history entries on idempotent retries. + const alreadyRecorded = (req.history as any[]).some( + (e: any) => e?.type === "V3_ACCIDENT_FIELDS_SAVED", + ); + if (!alreadyRecorded) { + req.history.push({ + type: "V3_ACCIDENT_FIELDS_SAVED", + actor: { + actorId: new Types.ObjectId(String(expert.sub)), + actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), + actorType: "field_expert", + }, + metadata: { accidentWay: fields.accidentWay }, + } as any); + } await (req as any).save();