merge upstream

This commit is contained in:
2026-07-27 10:39:17 +03:30
16 changed files with 745 additions and 16 deletions

View File

@@ -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-centerinitiated 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);
}
}

View File

@@ -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;
}

View File

@@ -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<BlameRequest>;

View File

@@ -51,6 +51,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: [
@@ -94,6 +95,7 @@ import { InquiryRefreshService } from "./inquiry-refresh.service";
FileReviewerBlameV5Controller,
FileMakerClaimApprovalV5Controller,
InquiryRefreshController,
CallCenterBlameV6Controller,
],
providers: [
RequestManagementService,

View File

@@ -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(
{
@@ -9368,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();
@@ -10075,4 +10090,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<RunInquiriesV3Dto, "sheba">,
): 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", "v6");
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<any> {
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<any[]> {
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,
}));
}
}