forked from Yara724/api
Compare commits
13 Commits
d355771518
...
0442d04f20
| Author | SHA1 | Date | |
|---|---|---|---|
| 0442d04f20 | |||
|
|
d0e7694374 | ||
| 570fa865de | |||
|
|
8741d2ba82 | ||
| 7b53c98791 | |||
|
|
59eddb8e0e | ||
| 4e20fc5c96 | |||
|
|
4fabed77e5 | ||
|
|
2e4b10455b | ||
| 1bbf0de960 | |||
|
|
15fcb011aa | ||
| 2c52c14e03 | |||
|
|
5a89a0ff16 |
@@ -31,7 +31,8 @@
|
|||||||
"branchName": "شعبه غرب تهران(210050)",
|
"branchName": "شعبه غرب تهران(210050)",
|
||||||
"city": "تهران",
|
"city": "تهران",
|
||||||
"state": "تهران",
|
"state": "تهران",
|
||||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||||
|
"expertCode": "4664"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nationalCode": "0066868521",
|
"nationalCode": "0066868521",
|
||||||
@@ -40,7 +41,8 @@
|
|||||||
"lastName": "گودرزي پور",
|
"lastName": "گودرزي پور",
|
||||||
"branchCode": "210050",
|
"branchCode": "210050",
|
||||||
"branchName": "شعبه غرب تهران(210050)",
|
"branchName": "شعبه غرب تهران(210050)",
|
||||||
"title": "كارشناس ارزياب خسارت بدنه"
|
"title": "كارشناس ارزياب خسارت بدنه",
|
||||||
|
"expertCode": "4663"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nationalCode": "0076988961",
|
"nationalCode": "0076988961",
|
||||||
@@ -60,7 +62,8 @@
|
|||||||
"lastName": "شاملوفرد",
|
"lastName": "شاملوفرد",
|
||||||
"branchCode": "210050",
|
"branchCode": "210050",
|
||||||
"branchName": "شعبه غرب تهران(210050)",
|
"branchName": "شعبه غرب تهران(210050)",
|
||||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||||
|
"expertCode": "4666"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nationalCode": "0083730397",
|
"nationalCode": "0083730397",
|
||||||
@@ -80,7 +83,8 @@
|
|||||||
"lastName": "کرکي",
|
"lastName": "کرکي",
|
||||||
"branchCode": "210050",
|
"branchCode": "210050",
|
||||||
"branchName": "شعبه غرب تهران(210050)",
|
"branchName": "شعبه غرب تهران(210050)",
|
||||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||||
|
"expertCode": "4662"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nationalCode": "0440245151",
|
"nationalCode": "0440245151",
|
||||||
@@ -100,7 +104,8 @@
|
|||||||
"branchName": "شعبه غرب تهران(210050)",
|
"branchName": "شعبه غرب تهران(210050)",
|
||||||
"city": "تهران",
|
"city": "تهران",
|
||||||
"state": "تهران",
|
"state": "تهران",
|
||||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||||
|
"expertCode": "4665"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nationalCode": "0670358118",
|
"nationalCode": "0670358118",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type FieldExpertSeed = {
|
|||||||
city?: string;
|
city?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
expertCode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function stripQuotes(value: string): string {
|
function stripQuotes(value: string): string {
|
||||||
@@ -172,6 +173,7 @@ const FieldExpertSchema = new Schema(
|
|||||||
phone: { type: String },
|
phone: { type: String },
|
||||||
role: { type: String, default: "field_expert" },
|
role: { type: String, default: "field_expert" },
|
||||||
otp: { type: String, default: "" },
|
otp: { type: String, default: "" },
|
||||||
|
expertCode: { type: String, required: false },
|
||||||
},
|
},
|
||||||
{ collection: "field-expert", versionKey: false, timestamps: true },
|
{ collection: "field-expert", versionKey: false, timestamps: true },
|
||||||
);
|
);
|
||||||
@@ -273,6 +275,7 @@ async function main() {
|
|||||||
mobile: expert.mobile,
|
mobile: expert.mobile,
|
||||||
role: "field_expert",
|
role: "field_expert",
|
||||||
otp: "",
|
otp: "",
|
||||||
|
expertCode: expert.expertCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
const existing = await FieldExpert.findOne({
|
const existing = await FieldExpert.findOne({
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import {
|
|||||||
} from "src/helpers/user-access-resolver";
|
} from "src/helpers/user-access-resolver";
|
||||||
import {
|
import {
|
||||||
blameDamagedPartyMatchesUser,
|
blameDamagedPartyMatchesUser,
|
||||||
|
resolveClaimOwnerFieldsFromBlame,
|
||||||
resolveClaimOwnerParty,
|
resolveClaimOwnerParty,
|
||||||
resolveDamagedPartyUserId,
|
resolveDamagedPartyUserId,
|
||||||
} from "src/helpers/blame-damaged-party";
|
} from "src/helpers/blame-damaged-party";
|
||||||
@@ -4672,7 +4673,7 @@ export class ClaimRequestManagementService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* V2: Field expert creates a claim from an expert-initiated IN_PERSON completed blame.
|
* V2: Field expert creates a claim from an expert-initiated IN_PERSON completed blame.
|
||||||
* The claim owner is set to the damaged party (first for CAR_BODY, non-guilty for THIRD_PARTY).
|
* Owner: damaged party `userId` (SMS / user actions), guilty party `clientId` (insurer queue).
|
||||||
*/
|
*/
|
||||||
async createClaimFromBlameForExpertV2(
|
async createClaimFromBlameForExpertV2(
|
||||||
blameRequestId: string,
|
blameRequestId: string,
|
||||||
@@ -4702,29 +4703,11 @@ export class ClaimRequestManagementService {
|
|||||||
"Only the field expert who created this blame file can create the claim.",
|
"Only the field expert who created this blame file can create the claim.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const parties = blameRequest.parties || [];
|
const ownerFields = resolveClaimOwnerFieldsFromBlame(blameRequest);
|
||||||
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
|
if (!ownerFields) {
|
||||||
let damagedUserId: string;
|
throw new BadRequestException(
|
||||||
if (isCarBody) {
|
"Could not resolve claim owner (damaged party) from blame",
|
||||||
if (parties.length < 1)
|
|
||||||
throw new BadRequestException("Blame request has no party");
|
|
||||||
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({
|
const existingClaim = await this.claimCaseDbService.findOne({
|
||||||
blameRequestId: new Types.ObjectId(blameRequestId),
|
blameRequestId: new Types.ObjectId(blameRequestId),
|
||||||
@@ -4733,12 +4716,6 @@ export class ClaimRequestManagementService {
|
|||||||
throw new ConflictException("A claim for this blame case already exists");
|
throw new ConflictException("A claim for this blame case already exists");
|
||||||
}
|
}
|
||||||
const claimNo = await this.generateUniqueClaimNumber();
|
const claimNo = await this.generateUniqueClaimNumber();
|
||||||
const ownerParty = resolveClaimOwnerParty(blameRequest);
|
|
||||||
if (!ownerParty?.person?.userId) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"Could not resolve claim owner (guilty party) from blame",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const newClaim = await this.claimCaseDbService.create({
|
const newClaim = await this.claimCaseDbService.create({
|
||||||
requestNo: claimNo,
|
requestNo: claimNo,
|
||||||
publicId: blameRequest.publicId,
|
publicId: blameRequest.publicId,
|
||||||
@@ -4754,15 +4731,14 @@ export class ClaimRequestManagementService {
|
|||||||
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||||||
locked: false,
|
locked: false,
|
||||||
},
|
},
|
||||||
damagedPartyUserId: new Types.ObjectId(damagedUserId),
|
damagedPartyUserId: new Types.ObjectId(ownerFields.userId),
|
||||||
owner: {
|
owner: {
|
||||||
userId: new Types.ObjectId(String(ownerParty.person.userId)),
|
userId: new Types.ObjectId(ownerFields.userId),
|
||||||
userRole: ownerParty.role as any,
|
userRole: ownerFields.userRole as any,
|
||||||
...(ownerParty.person.clientId
|
...(ownerFields.clientId
|
||||||
? {
|
? {
|
||||||
clientId: new Types.ObjectId(
|
clientId: new Types.ObjectId(ownerFields.clientId),
|
||||||
String(ownerParty.person.clientId),
|
userClientKey: ownerFields.clientId,
|
||||||
),
|
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
@@ -5020,26 +4996,11 @@ export class ClaimRequestManagementService {
|
|||||||
"Only the registrar who created this blame file can create the claim.",
|
"Only the registrar who created this blame file can create the claim.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const parties = blameRequest.parties || [];
|
const ownerFields = resolveClaimOwnerFieldsFromBlame(blameRequest);
|
||||||
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
|
if (!ownerFields) {
|
||||||
let damagedUserId: string;
|
throw new BadRequestException(
|
||||||
if (isCarBody) {
|
"Could not resolve claim owner (damaged party) from blame",
|
||||||
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({
|
const existingClaim = await this.claimCaseDbService.findOne({
|
||||||
blameRequestId: new Types.ObjectId(blameRequestId),
|
blameRequestId: new Types.ObjectId(blameRequestId),
|
||||||
@@ -5047,12 +5008,6 @@ export class ClaimRequestManagementService {
|
|||||||
if (existingClaim)
|
if (existingClaim)
|
||||||
throw new ConflictException("A claim for this blame case already exists");
|
throw new ConflictException("A claim for this blame case already exists");
|
||||||
const claimNo = await this.generateUniqueClaimNumber();
|
const claimNo = await this.generateUniqueClaimNumber();
|
||||||
const ownerParty = resolveClaimOwnerParty(blameRequest);
|
|
||||||
if (!ownerParty?.person?.userId) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"Could not resolve claim owner (guilty party) from blame",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const newClaim = await this.claimCaseDbService.create({
|
const newClaim = await this.claimCaseDbService.create({
|
||||||
requestNo: claimNo,
|
requestNo: claimNo,
|
||||||
publicId: blameRequest.publicId,
|
publicId: blameRequest.publicId,
|
||||||
@@ -5067,15 +5022,14 @@ export class ClaimRequestManagementService {
|
|||||||
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||||||
locked: false,
|
locked: false,
|
||||||
},
|
},
|
||||||
damagedPartyUserId: new Types.ObjectId(damagedUserId),
|
damagedPartyUserId: new Types.ObjectId(ownerFields.userId),
|
||||||
owner: {
|
owner: {
|
||||||
userId: new Types.ObjectId(String(ownerParty.person.userId)),
|
userId: new Types.ObjectId(ownerFields.userId),
|
||||||
userRole: ownerParty.role as any,
|
userRole: ownerFields.userRole as any,
|
||||||
...(ownerParty.person.clientId
|
...(ownerFields.clientId
|
||||||
? {
|
? {
|
||||||
clientId: new Types.ObjectId(
|
clientId: new Types.ObjectId(ownerFields.clientId),
|
||||||
String(ownerParty.person.clientId),
|
userClientKey: ownerFields.clientId,
|
||||||
),
|
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
|
|||||||
97
src/client/client-external-inquiries.controller.ts
Normal file
97
src/client/client-external-inquiries.controller.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Put,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ApiBody,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { ClientService } from "./client.service";
|
||||||
|
import {
|
||||||
|
ClientExternalInquiriesCatalogDto,
|
||||||
|
ClientExternalInquiriesListDto,
|
||||||
|
ClientExternalInquiriesViewDto,
|
||||||
|
ExternalInquiryFlagsDto,
|
||||||
|
UpdateClientExternalInquiriesDto,
|
||||||
|
} from "./dto/client-external-inquiries.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-insurer external inquiry toggles (public for now — lock down when super-admin exists).
|
||||||
|
*/
|
||||||
|
@ApiTags("client-external-inquiries")
|
||||||
|
@Controller("client")
|
||||||
|
export class ClientExternalInquiriesController {
|
||||||
|
constructor(private readonly clientService: ClientService) {}
|
||||||
|
|
||||||
|
@Get("external-inquiries/catalog")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "List supported external inquiry kinds and API paths",
|
||||||
|
description: "No auth (temporary). Describes global master switch + per-client flags.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: ClientExternalInquiriesCatalogDto })
|
||||||
|
getCatalog(): ClientExternalInquiriesCatalogDto {
|
||||||
|
return this.clientService.getExternalInquiriesCatalog();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("external-inquiries")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "List external inquiry settings for all insurers",
|
||||||
|
description:
|
||||||
|
"No auth (temporary). Returns stored flags and effective live flags (after global master switch).",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: ClientExternalInquiriesListDto })
|
||||||
|
listAll(): Promise<ClientExternalInquiriesListDto> {
|
||||||
|
return this.clientService.listExternalInquirySettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":clientId/external-inquiries")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get external inquiry settings for one insurer",
|
||||||
|
description: "No auth (temporary).",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "clientId", description: "Insurer client Mongo ObjectId" })
|
||||||
|
@ApiResponse({ status: 200, type: ClientExternalInquiriesViewDto })
|
||||||
|
getOne(
|
||||||
|
@Param("clientId") clientId: string,
|
||||||
|
): Promise<ClientExternalInquiriesViewDto> {
|
||||||
|
return this.clientService.getExternalInquirySettings(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(":clientId/external-inquiries")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Replace external inquiry flags for one insurer",
|
||||||
|
description:
|
||||||
|
"No auth (temporary). Omitted inquiry keys default to `false` (mock). Global master switch still applies at runtime.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "clientId", description: "Insurer client Mongo ObjectId" })
|
||||||
|
@ApiBody({ type: ExternalInquiryFlagsDto })
|
||||||
|
@ApiResponse({ status: 200, type: ClientExternalInquiriesViewDto })
|
||||||
|
replace(
|
||||||
|
@Param("clientId") clientId: string,
|
||||||
|
@Body() body: ExternalInquiryFlagsDto,
|
||||||
|
): Promise<ClientExternalInquiriesViewDto> {
|
||||||
|
return this.clientService.replaceExternalInquirySettings(clientId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":clientId/external-inquiries")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Partially update external inquiry flags for one insurer",
|
||||||
|
description: "No auth (temporary). Only supplied flags are changed.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "clientId", description: "Insurer client Mongo ObjectId" })
|
||||||
|
@ApiBody({ type: UpdateClientExternalInquiriesDto })
|
||||||
|
@ApiResponse({ status: 200, type: ClientExternalInquiriesViewDto })
|
||||||
|
patch(
|
||||||
|
@Param("clientId") clientId: string,
|
||||||
|
@Body() body: UpdateClientExternalInquiriesDto,
|
||||||
|
): Promise<ClientExternalInquiriesViewDto> {
|
||||||
|
return this.clientService.patchExternalInquirySettings(clientId, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ import { Module } from "@nestjs/common";
|
|||||||
import { MongooseModule } from "@nestjs/mongoose";
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
|
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
|
||||||
import { ClientController } from "./client.controller";
|
import { ClientController } from "./client.controller";
|
||||||
|
import { ClientExternalInquiriesController } from "./client-external-inquiries.controller";
|
||||||
import { ClientPanelController } from "./client-panel.controller";
|
import { ClientPanelController } from "./client-panel.controller";
|
||||||
import { ClientService } from "./client.service";
|
import { ClientService } from "./client.service";
|
||||||
|
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
|
||||||
import { BranchDbService } from "./entities/db-service/branch.db.service";
|
import { BranchDbService } from "./entities/db-service/branch.db.service";
|
||||||
import { ClientDbService } from "./entities/db-service/client.db.service";
|
import { ClientDbService } from "./entities/db-service/client.db.service";
|
||||||
import { BranchModel, BranchSchema } from "./entities/schema/branch.schema";
|
import { BranchModel, BranchSchema } from "./entities/schema/branch.schema";
|
||||||
@@ -20,8 +22,12 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema";
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [ClientController, ClientPanelController],
|
controllers: [
|
||||||
providers: [ClientService, ClientDbService, BranchDbService],
|
ClientController,
|
||||||
exports: [ClientService, ClientDbService, BranchDbService],
|
ClientPanelController,
|
||||||
|
ClientExternalInquiriesController,
|
||||||
|
],
|
||||||
|
providers: [ClientService, ClientDbService, BranchDbService, ExternalInquirySettingsService],
|
||||||
|
exports: [ClientService, ClientDbService, BranchDbService, ExternalInquirySettingsService],
|
||||||
})
|
})
|
||||||
export class ClientModule {}
|
export class ClientModule {}
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ import {
|
|||||||
} from "src/client/dto/client-settings.dto";
|
} from "src/client/dto/client-settings.dto";
|
||||||
import type { ClientMediaLimits } from "./entities/schema/client.schema";
|
import type { ClientMediaLimits } from "./entities/schema/client.schema";
|
||||||
import { ClientDbService } from "./entities/db-service/client.db.service";
|
import { ClientDbService } from "./entities/db-service/client.db.service";
|
||||||
|
import {
|
||||||
|
ClientExternalInquiriesCatalogDto,
|
||||||
|
ClientExternalInquiriesListDto,
|
||||||
|
ClientExternalInquiriesViewDto,
|
||||||
|
toClientExternalInquiriesView,
|
||||||
|
UpdateClientExternalInquiriesDto,
|
||||||
|
} from "./dto/client-external-inquiries.dto";
|
||||||
|
import {
|
||||||
|
EXTERNAL_INQUIRY_TYPES,
|
||||||
|
mergeExternalInquiryFlags,
|
||||||
|
} from "src/common/types/external-inquiry.types";
|
||||||
|
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
|
||||||
|
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* System-wide default applied when a client document has no
|
* System-wide default applied when a client document has no
|
||||||
@@ -94,9 +107,14 @@ const MEDIA_KINDS: MediaKind[] = ["video", "image", "voice"];
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClientService {
|
export class ClientService {
|
||||||
constructor(private readonly clientDbService: ClientDbService) {}
|
constructor(
|
||||||
|
private readonly clientDbService: ClientDbService,
|
||||||
|
private readonly externalInquirySettingsService: ExternalInquirySettingsService,
|
||||||
|
private readonly systemSettingsService: SystemSettingsService,
|
||||||
|
) {}
|
||||||
async addClient(client: ClientDto): Promise<ClientDtoRs> {
|
async addClient(client: ClientDto): Promise<ClientDtoRs> {
|
||||||
try {
|
try {
|
||||||
|
const smsApiKey = client.property?.smsApiKey?.trim();
|
||||||
const newClient = await this.clientDbService.create({
|
const newClient = await this.clientDbService.create({
|
||||||
clientCode: client.clientCode,
|
clientCode: client.clientCode,
|
||||||
|
|
||||||
@@ -112,9 +130,7 @@ export class ClientService {
|
|||||||
: (client.clientName?.english ?? null),
|
: (client.clientName?.english ?? null),
|
||||||
},
|
},
|
||||||
|
|
||||||
property: {
|
...(smsApiKey ? { property: { smsApiKey } } : {}),
|
||||||
smsApiKey: client.property?.smsApiKey ?? null,
|
|
||||||
},
|
|
||||||
|
|
||||||
useExpertMode: client.useExpertMode ?? null,
|
useExpertMode: client.useExpertMode ?? null,
|
||||||
});
|
});
|
||||||
@@ -393,4 +409,74 @@ export class ClientService {
|
|||||||
|
|
||||||
return this.getPanelSettings(client._id);
|
return this.getPanelSettings(client._id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getExternalInquiriesCatalog(): ClientExternalInquiriesCatalogDto {
|
||||||
|
return {
|
||||||
|
inquiryTypes: [...EXTERNAL_INQUIRY_TYPES],
|
||||||
|
globalSettingPath: "/system-settings (PATCH externalApis.sandHubUseLiveApi)",
|
||||||
|
perClientSettingPath: "/client/{clientId}/external-inquiries",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listExternalInquirySettings(): Promise<ClientExternalInquiriesListDto> {
|
||||||
|
const global = await this.systemSettingsService.isSandHubLiveEnabled();
|
||||||
|
const clients = await this.clientDbService.findAll();
|
||||||
|
return {
|
||||||
|
items: clients.map((c) => toClientExternalInquiriesView(c, global)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExternalInquirySettings(
|
||||||
|
clientKey: string | Types.ObjectId,
|
||||||
|
): Promise<ClientExternalInquiriesViewDto> {
|
||||||
|
const client = await this.loadClientOrThrow(clientKey);
|
||||||
|
const global = await this.systemSettingsService.isSandHubLiveEnabled();
|
||||||
|
return toClientExternalInquiriesView(client, global);
|
||||||
|
}
|
||||||
|
|
||||||
|
async replaceExternalInquirySettings(
|
||||||
|
clientKey: string | Types.ObjectId,
|
||||||
|
body: UpdateClientExternalInquiriesDto,
|
||||||
|
): Promise<ClientExternalInquiriesViewDto> {
|
||||||
|
const client = await this.loadClientOrThrow(clientKey);
|
||||||
|
const flags = mergeExternalInquiryFlags(body as Partial<Record<string, boolean>>);
|
||||||
|
const $set: Record<string, unknown> = {};
|
||||||
|
for (const key of EXTERNAL_INQUIRY_TYPES) {
|
||||||
|
$set[`settings.externalInquiries.${key}`] = flags[key];
|
||||||
|
}
|
||||||
|
const updated = await this.clientDbService.findByIdAndUpdate(
|
||||||
|
client._id.toString(),
|
||||||
|
{ $set },
|
||||||
|
);
|
||||||
|
if (!updated) throw new NotFoundException("Client not found");
|
||||||
|
this.externalInquirySettingsService.invalidateClientCache(
|
||||||
|
client._id.toString(),
|
||||||
|
);
|
||||||
|
return this.getExternalInquirySettings(client._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async patchExternalInquirySettings(
|
||||||
|
clientKey: string | Types.ObjectId,
|
||||||
|
body: UpdateClientExternalInquiriesDto,
|
||||||
|
): Promise<ClientExternalInquiriesViewDto> {
|
||||||
|
const client = await this.loadClientOrThrow(clientKey);
|
||||||
|
const $set: Record<string, unknown> = {};
|
||||||
|
for (const key of EXTERNAL_INQUIRY_TYPES) {
|
||||||
|
if (body[key] !== undefined) {
|
||||||
|
$set[`settings.externalInquiries.${key}`] = body[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys($set).length === 0) {
|
||||||
|
throw new BadRequestException("No external inquiry flags to update.");
|
||||||
|
}
|
||||||
|
const updated = await this.clientDbService.findByIdAndUpdate(
|
||||||
|
client._id.toString(),
|
||||||
|
{ $set },
|
||||||
|
);
|
||||||
|
if (!updated) throw new NotFoundException("Client not found");
|
||||||
|
this.externalInquirySettingsService.invalidateClientCache(
|
||||||
|
client._id.toString(),
|
||||||
|
);
|
||||||
|
return this.getExternalInquirySettings(client._id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
154
src/client/dto/client-external-inquiries.dto.ts
Normal file
154
src/client/dto/client-external-inquiries.dto.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
|
||||||
|
import {
|
||||||
|
EXTERNAL_INQUIRY_TYPES,
|
||||||
|
ExternalInquiryFlags,
|
||||||
|
mergeExternalInquiryFlags,
|
||||||
|
} from "src/common/types/external-inquiry.types";
|
||||||
|
import { IsBoolean, IsMongoId, IsOptional, ValidateNested } from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
|
export class ExternalInquiryFlagsDto implements ExternalInquiryFlags {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Third-party plate / policy block inquiry (Tejarat or ESG policyByPlate).",
|
||||||
|
example: false,
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
thirdPartyPlate: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "CAR_BODY (badane) plate inquiry.",
|
||||||
|
example: false,
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
carBodyPlate: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Personal identity inquiry (national code + birth date).",
|
||||||
|
example: false,
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
personalIdentity: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Sheba account validation.",
|
||||||
|
example: false,
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
sheba: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Driving licence check.",
|
||||||
|
example: false,
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
drivingLicense: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Vehicle ownership validation.",
|
||||||
|
example: false,
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
carOwnership: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateClientExternalInquiriesDto extends PartialType(
|
||||||
|
ExternalInquiryFlagsDto,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export class ClientExternalInquiriesViewDto {
|
||||||
|
@ApiProperty({ example: "664a1b2c3d4e5f6789012345" })
|
||||||
|
clientId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 8 })
|
||||||
|
clientCode: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "بیمه پارسیان" })
|
||||||
|
clientName: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"Global master switch from `system_settings.externalApis.sandHubUseLiveApi`. When false, all inquiries use mocks regardless of these flags.",
|
||||||
|
})
|
||||||
|
globalSandHubLiveEnabled: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ExternalInquiryFlagsDto })
|
||||||
|
externalInquiries: ExternalInquiryFlags;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Effective live flags after applying the global master switch.",
|
||||||
|
type: ExternalInquiryFlagsDto,
|
||||||
|
})
|
||||||
|
effectiveLive: ExternalInquiryFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientExternalInquiriesListDto {
|
||||||
|
@ApiProperty({ type: [ClientExternalInquiriesViewDto] })
|
||||||
|
items: ClientExternalInquiriesViewDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientExternalInquiriesCatalogDto {
|
||||||
|
@ApiProperty({
|
||||||
|
enum: EXTERNAL_INQUIRY_TYPES,
|
||||||
|
isArray: true,
|
||||||
|
description: "Supported inquiry kinds stored on each client document.",
|
||||||
|
})
|
||||||
|
inquiryTypes: readonly string[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"Global master switch path: PATCH /system-settings or PATCH /client/external-inquiries-live",
|
||||||
|
})
|
||||||
|
globalSettingPath: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Per-insurer CRUD base path (public for now).",
|
||||||
|
example: "/client/{clientId}/external-inquiries",
|
||||||
|
})
|
||||||
|
perClientSettingPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a view DTO from a lean client document. */
|
||||||
|
export function toClientExternalInquiriesView(
|
||||||
|
client: {
|
||||||
|
_id?: unknown;
|
||||||
|
clientCode?: number;
|
||||||
|
clientName?: { persian?: string; english?: string } | string;
|
||||||
|
settings?: { externalInquiries?: Partial<ExternalInquiryFlags> };
|
||||||
|
},
|
||||||
|
globalSandHubLiveEnabled: boolean,
|
||||||
|
): ClientExternalInquiriesViewDto {
|
||||||
|
const flags = mergeExternalInquiryFlags(client.settings?.externalInquiries);
|
||||||
|
const effectiveLive = {} as ExternalInquiryFlags;
|
||||||
|
for (const key of EXTERNAL_INQUIRY_TYPES) {
|
||||||
|
effectiveLive[key] = globalSandHubLiveEnabled && flags[key] === true;
|
||||||
|
}
|
||||||
|
const name =
|
||||||
|
typeof client.clientName === "string"
|
||||||
|
? client.clientName
|
||||||
|
: client.clientName?.persian ||
|
||||||
|
client.clientName?.english ||
|
||||||
|
String(client.clientCode ?? "");
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId: String(client._id ?? ""),
|
||||||
|
clientCode: Number(client.clientCode ?? 0),
|
||||||
|
clientName: name,
|
||||||
|
globalSandHubLiveEnabled,
|
||||||
|
externalInquiries: flags,
|
||||||
|
effectiveLive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientIdParamDto {
|
||||||
|
@ApiProperty({ description: "Mongo ObjectId of the insurer client document" })
|
||||||
|
@IsMongoId()
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional nested patch used internally when validating partial bodies. */
|
||||||
|
export class ExternalInquiryFlagsPatchDto {
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => UpdateClientExternalInquiriesDto)
|
||||||
|
externalInquiries?: UpdateClientExternalInquiriesDto;
|
||||||
|
}
|
||||||
@@ -23,8 +23,9 @@ class ClientName {
|
|||||||
}
|
}
|
||||||
class Property {
|
class Property {
|
||||||
@ApiPropertyOptional({})
|
@ApiPropertyOptional({})
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
smsApiKey: string;
|
smsApiKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ClientDto {
|
export class ClientDto {
|
||||||
|
|||||||
@@ -1,15 +1,49 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||||
import { InjectModel } from "@nestjs/mongoose";
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
import { FilterQuery, Model, UpdateQuery } from "mongoose";
|
import { FilterQuery, Model, UpdateQuery } from "mongoose";
|
||||||
import { ClientModel, ClientDocument } from "../schema/client.schema";
|
import { ClientModel, ClientDocument } from "../schema/client.schema";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClientDbService {
|
export class ClientDbService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(ClientDbService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectModel(ClientModel.name)
|
@InjectModel(ClientModel.name)
|
||||||
private readonly clientModel: Model<ClientModel>,
|
private readonly clientModel: Model<ClientModel>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.dropLegacyPropertyUniqueIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `property` was once declared `unique: true` in the schema. Production keeps
|
||||||
|
* `autoIndex: false`, so the stale unique index remained and rejected a second
|
||||||
|
* client with a missing/null SMS key (E11000 duplicate key).
|
||||||
|
*/
|
||||||
|
private async dropLegacyPropertyUniqueIndex(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const indexes = await this.clientModel.collection.indexes();
|
||||||
|
for (const index of indexes) {
|
||||||
|
if (!index.unique || !index.name || index.name === "_id_") continue;
|
||||||
|
|
||||||
|
const keys = Object.keys(index.key ?? {});
|
||||||
|
const isLegacyPropertyIndex =
|
||||||
|
keys.length === 1 &&
|
||||||
|
(keys[0] === "property" || keys[0] === "property.smsApiKey");
|
||||||
|
|
||||||
|
if (!isLegacyPropertyIndex) continue;
|
||||||
|
|
||||||
|
await this.clientModel.collection.dropIndex(index.name);
|
||||||
|
this.logger.warn(
|
||||||
|
`Dropped legacy unique index on clients.${keys[0]}: ${index.name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error("Failed to drop legacy client property index", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async create(client: ClientModel): Promise<ClientModel> {
|
async create(client: ClientModel): Promise<ClientModel> {
|
||||||
return await this.clientModel.create(client);
|
return await this.clientModel.create(client);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
|
import type { ExternalInquiryFlags } from "src/common/types/external-inquiry.types";
|
||||||
|
|
||||||
export type ClientDocument = ClientModel & Document;
|
export type ClientDocument = ClientModel & Document;
|
||||||
|
|
||||||
@@ -37,6 +38,30 @@ export class ClientMediaSettings {
|
|||||||
voice?: ClientMediaLimits;
|
voice?: ClientMediaLimits;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-insurer toggles for outbound inquiry HTTP. Each flag is AND-ed with the
|
||||||
|
* global `system_settings.externalApis.sandHubUseLiveApi` master switch.
|
||||||
|
*/
|
||||||
|
export class ClientExternalInquirySettings implements Partial<ExternalInquiryFlags> {
|
||||||
|
@Prop({ type: Boolean, required: false, default: false })
|
||||||
|
thirdPartyPlate?: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: Boolean, required: false, default: false })
|
||||||
|
carBodyPlate?: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: Boolean, required: false, default: false })
|
||||||
|
personalIdentity?: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: Boolean, required: false, default: false })
|
||||||
|
sheba?: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: Boolean, required: false, default: false })
|
||||||
|
drivingLicense?: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: Boolean, required: false, default: false })
|
||||||
|
carOwnership?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-tenant tunables. Add new policy fields here; consumers read them via
|
* Per-tenant tunables. Add new policy fields here; consumers read them via
|
||||||
* `ClientService` with documented defaults so older client documents keep
|
* `ClientService` with documented defaults so older client documents keep
|
||||||
@@ -57,6 +82,10 @@ export class ClientSettings {
|
|||||||
*/
|
*/
|
||||||
@Prop({ type: ClientMediaSettings, required: false })
|
@Prop({ type: ClientMediaSettings, required: false })
|
||||||
media?: ClientMediaSettings;
|
media?: ClientMediaSettings;
|
||||||
|
|
||||||
|
/** Per-inquiry live/mock toggles (see {@link ClientExternalInquirySettings}). */
|
||||||
|
@Prop({ type: ClientExternalInquirySettings, required: false })
|
||||||
|
externalInquiries?: ClientExternalInquirySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Schema({ collection: "clients", versionKey: false })
|
@Schema({ collection: "clients", versionKey: false })
|
||||||
@@ -67,9 +96,9 @@ export class ClientModel {
|
|||||||
english: string;
|
english: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Prop({ required: false, unique: false, type: Object })
|
@Prop({ required: false, type: Object })
|
||||||
property: {
|
property?: {
|
||||||
smsApiKey: string;
|
smsApiKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Prop({ required: true, unique: false })
|
@Prop({ required: true, unique: false })
|
||||||
|
|||||||
83
src/client/external-inquiry-settings.service.spec.ts
Normal file
83
src/client/external-inquiry-settings.service.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
|
||||||
|
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||||
|
|
||||||
|
describe("ExternalInquirySettingsService", () => {
|
||||||
|
const systemSettings = {
|
||||||
|
isSandHubLiveEnabled: jest.fn(),
|
||||||
|
};
|
||||||
|
const clientDb = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let service: ExternalInquirySettingsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
delete process.env.CLIENT_ID;
|
||||||
|
delete process.env.CLIENT_NAME;
|
||||||
|
service = new ExternalInquirySettingsService(
|
||||||
|
systemSettings as unknown as SystemSettingsService,
|
||||||
|
clientDb as any,
|
||||||
|
);
|
||||||
|
service.invalidateClientCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when global master switch is off", async () => {
|
||||||
|
systemSettings.isSandHubLiveEnabled.mockResolvedValue(false);
|
||||||
|
clientDb.findOne.mockResolvedValue({
|
||||||
|
clientCode: 8,
|
||||||
|
settings: { externalInquiries: { sheba: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.isInquiryLive("sheba", "client-id")).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true only when global and per-insurer flags are on", async () => {
|
||||||
|
const clientId = new Types.ObjectId().toString();
|
||||||
|
systemSettings.isSandHubLiveEnabled.mockResolvedValue(true);
|
||||||
|
clientDb.findOne.mockResolvedValue({
|
||||||
|
clientCode: 8,
|
||||||
|
clientName: { persian: "بیمه پارسیان" },
|
||||||
|
settings: {
|
||||||
|
externalInquiries: {
|
||||||
|
thirdPartyPlate: true,
|
||||||
|
sheba: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.isInquiryLive("thirdPartyPlate", clientId),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
await expect(service.isInquiryLive("sheba", clientId)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses client document for mock company context", async () => {
|
||||||
|
const clientId = new Types.ObjectId().toString();
|
||||||
|
clientDb.findOne.mockResolvedValue({
|
||||||
|
clientCode: 8,
|
||||||
|
clientName: { persian: "بیمه پارسیان" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.getMockCompanyContext(clientId)).resolves.toEqual({
|
||||||
|
companyId: "8",
|
||||||
|
companyName: "بیمه پارسیان",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to deployment env when client is missing", async () => {
|
||||||
|
process.env.CLIENT_ID = "15";
|
||||||
|
process.env.CLIENT_NAME = "بیمه سامان";
|
||||||
|
clientDb.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.getMockCompanyContext()).resolves.toEqual({
|
||||||
|
companyId: "15",
|
||||||
|
companyName: "بیمه سامان",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
120
src/client/external-inquiry-settings.service.ts
Normal file
120
src/client/external-inquiry-settings.service.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import {
|
||||||
|
DEFAULT_EXTERNAL_INQUIRY_FLAGS,
|
||||||
|
ExternalInquiryFlags,
|
||||||
|
ExternalInquiryType,
|
||||||
|
mergeExternalInquiryFlags,
|
||||||
|
MockInquiryCompanyContext,
|
||||||
|
} from "src/common/types/external-inquiry.types";
|
||||||
|
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||||
|
import { ClientDbService } from "./entities/db-service/client.db.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExternalInquirySettingsService {
|
||||||
|
private readonly logger = new Logger(ExternalInquirySettingsService.name);
|
||||||
|
private clientCache = new Map<string, { doc: any; at: number }>();
|
||||||
|
private readonly cacheTtlMs = 15_000;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly systemSettingsService: SystemSettingsService,
|
||||||
|
private readonly clientDbService: ClientDbService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private cacheKey(ref?: string | Types.ObjectId | null): string {
|
||||||
|
if (ref != null && String(ref).trim()) return `id:${String(ref)}`;
|
||||||
|
const code = process.env.CLIENT_ID;
|
||||||
|
return code ? `env:${code}` : "env:default";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadClient(
|
||||||
|
clientRef?: string | Types.ObjectId | null,
|
||||||
|
): Promise<any | null> {
|
||||||
|
const key = this.cacheKey(clientRef);
|
||||||
|
const cached = this.clientCache.get(key);
|
||||||
|
const now = Date.now();
|
||||||
|
if (cached && now - cached.at < this.cacheTtlMs) {
|
||||||
|
return cached.doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
let doc: any = null;
|
||||||
|
if (clientRef != null && Types.ObjectId.isValid(String(clientRef))) {
|
||||||
|
doc = await this.clientDbService.findOne({
|
||||||
|
_id: new Types.ObjectId(String(clientRef)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!doc) {
|
||||||
|
const code = Number(process.env.CLIENT_ID);
|
||||||
|
if (Number.isFinite(code)) {
|
||||||
|
doc = await this.clientDbService.findOne({ clientCode: code });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clientCache.set(key, { doc, at: now });
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateClientCache(clientId?: string): void {
|
||||||
|
if (clientId) {
|
||||||
|
this.clientCache.delete(`id:${clientId}`);
|
||||||
|
} else {
|
||||||
|
this.clientCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFlagsForClient(
|
||||||
|
clientRef?: string | Types.ObjectId | null,
|
||||||
|
): Promise<ExternalInquiryFlags> {
|
||||||
|
const client = await this.loadClient(clientRef);
|
||||||
|
return mergeExternalInquiryFlags(client?.settings?.externalInquiries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live HTTP when global master switch is on AND the per-insurer flag is true.
|
||||||
|
*/
|
||||||
|
async isInquiryLive(
|
||||||
|
type: ExternalInquiryType,
|
||||||
|
clientRef?: string | Types.ObjectId | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!(await this.systemSettingsService.isSandHubLiveEnabled())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const flags = await this.getFlagsForClient(clientRef);
|
||||||
|
return flags[type] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Company fields injected into mocked plate/car-body inquiry payloads. */
|
||||||
|
async getMockCompanyContext(
|
||||||
|
clientRef?: string | Types.ObjectId | null,
|
||||||
|
): Promise<MockInquiryCompanyContext> {
|
||||||
|
const client = await this.loadClient(clientRef);
|
||||||
|
if (client) {
|
||||||
|
const name =
|
||||||
|
typeof client.clientName === "string"
|
||||||
|
? client.clientName
|
||||||
|
: client.clientName?.persian ||
|
||||||
|
client.clientName?.english ||
|
||||||
|
"";
|
||||||
|
return {
|
||||||
|
companyId: String(client.clientCode ?? process.env.CLIENT_ID ?? "15"),
|
||||||
|
companyName: name || process.env.CLIENT_NAME || "بیمه",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
companyId: String(process.env.CLIENT_ID ?? "15"),
|
||||||
|
companyName: process.env.CLIENT_NAME ?? "بیمه سامان",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEffectiveLiveFlags(
|
||||||
|
clientRef?: string | Types.ObjectId | null,
|
||||||
|
): Promise<ExternalInquiryFlags> {
|
||||||
|
const global = await this.systemSettingsService.isSandHubLiveEnabled();
|
||||||
|
const flags = await this.getFlagsForClient(clientRef);
|
||||||
|
const effective = { ...DEFAULT_EXTERNAL_INQUIRY_FLAGS };
|
||||||
|
for (const key of Object.keys(effective) as ExternalInquiryType[]) {
|
||||||
|
effective[key] = global && flags[key] === true;
|
||||||
|
}
|
||||||
|
return effective;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
|
||||||
import { UserRepository } from "src/features/user/repositories";
|
|
||||||
import { UserLoginDto, UserRegisterDto } from "./dtos";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AuthService {
|
|
||||||
constructor(private readonly userRepo: UserRepository) {}
|
|
||||||
|
|
||||||
async registerUser(userRegisterDto: UserRegisterDto) {
|
|
||||||
const user = await this.userRepo.retrieveByCellPhoneNumber(
|
|
||||||
userRegisterDto.cellphoneNumber,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
// Create user + Send OTP and update database
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send OTP to the cellphone number of user and update database
|
|
||||||
// TODO: Create a mock otp for once we got no otp senders or development phase
|
|
||||||
}
|
|
||||||
|
|
||||||
async loginUser(userLoginDto: UserLoginDto) {
|
|
||||||
const user = await this.userRepo.retrieveByCellPhoneNumber(
|
|
||||||
userLoginDto.cellphoneNumber,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!user) throw new BadRequestException("User does not exist");
|
|
||||||
|
|
||||||
// Generate token (access + refresh) and save inside the database, return them to the user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
import { ScryptOptions } from "node:crypto";
|
import { ScryptOptions } from "node:crypto";
|
||||||
|
|
||||||
|
export const CURRENT_ALGORITHM = "scrypt";
|
||||||
|
export const CURRENT_VERSION = 1; // 0 = legacy salt:hash ; 1 = scrypt with different salt and hash and differnet format of salt:hash!
|
||||||
|
|
||||||
export const KEY_LENGTH = 64;
|
export const KEY_LENGTH = 64;
|
||||||
export const SCRYPT_PARAMS: ScryptOptions = {
|
export const SCRYPT_PARAMS: ScryptOptions = {
|
||||||
N: 19456, // CPU/memory cost
|
N: 19456, // CPU/memory cost
|
||||||
r: 8, // block size
|
r: 8, // block size
|
||||||
p: 1, // parallelization
|
p: 1, // parallelization
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const KEY_LENGTH_FOR_OTP = 32;
|
||||||
|
export const SCRYPT_PARAMS_FOR_OTP: ScryptOptions = {
|
||||||
|
N: 1024,
|
||||||
|
r: 8,
|
||||||
|
p: 1,
|
||||||
|
};
|
||||||
|
|||||||
8
src/common/auth/constants/index.ts
Normal file
8
src/common/auth/constants/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
CURRENT_ALGORITHM,
|
||||||
|
CURRENT_VERSION,
|
||||||
|
KEY_LENGTH,
|
||||||
|
KEY_LENGTH_FOR_OTP,
|
||||||
|
SCRYPT_PARAMS,
|
||||||
|
SCRYPT_PARAMS_FOR_OTP,
|
||||||
|
} from "./hash.constants";
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { UserRegisterDto } from "./user-register.dto";
|
|
||||||
export { UserLoginDto } from "./user-login.dto";
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import {
|
|
||||||
BinaryLike,
|
|
||||||
randomBytes,
|
|
||||||
scrypt,
|
|
||||||
ScryptOptions,
|
|
||||||
timingSafeEqual,
|
|
||||||
} from "node:crypto";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { KEY_LENGTH, SCRYPT_PARAMS } from "../constants/hash.constants";
|
|
||||||
|
|
||||||
const scryptAsync = promisify<
|
|
||||||
BinaryLike,
|
|
||||||
BinaryLike,
|
|
||||||
number,
|
|
||||||
ScryptOptions,
|
|
||||||
Buffer
|
|
||||||
>(scrypt);
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class HashService {
|
|
||||||
private readonly pepper: string;
|
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
|
||||||
this.pepper = this.configService.get<string>("HASH_PEPPER");
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyPepper(input: string): string {
|
|
||||||
return this.pepper ? `${input}${this.pepper}` : input;
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateSalt(bytes: number = 16): string {
|
|
||||||
return randomBytes(bytes).toString("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async scrypt(password: string): Promise<[string, string]> {
|
|
||||||
const salt = this.generateSalt();
|
|
||||||
const pepperedInput = this.applyPepper(password);
|
|
||||||
const hashedPassword = await scryptAsync(
|
|
||||||
pepperedInput,
|
|
||||||
salt,
|
|
||||||
KEY_LENGTH,
|
|
||||||
SCRYPT_PARAMS,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [salt, hashedPassword.toString("hex")];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async verifyScrypt(
|
|
||||||
password: string,
|
|
||||||
salt: string,
|
|
||||||
hashedPassword: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const pepperedInput = this.applyPepper(password);
|
|
||||||
const derived = await scryptAsync(
|
|
||||||
pepperedInput,
|
|
||||||
salt,
|
|
||||||
KEY_LENGTH,
|
|
||||||
SCRYPT_PARAMS,
|
|
||||||
);
|
|
||||||
const storedBuffer = Buffer.from(hashedPassword, "hex");
|
|
||||||
|
|
||||||
if (derived.length !== storedBuffer.length) return false;
|
|
||||||
|
|
||||||
return timingSafeEqual(derived, storedBuffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +1,2 @@
|
|||||||
export { JwtPayload } from "./payload.types";
|
export { JwtPayload } from "./payload.types";
|
||||||
|
export { PasswordHash } from "./password.types";
|
||||||
|
|||||||
8
src/common/auth/types/password.types.ts
Normal file
8
src/common/auth/types/password.types.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface PasswordHash {
|
||||||
|
hash: string;
|
||||||
|
salt: string;
|
||||||
|
algorithm: string;
|
||||||
|
version: number;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
role: string;
|
role: string;
|
||||||
clientId?: string;
|
|
||||||
iat?: number;
|
iat?: number;
|
||||||
exp?: number;
|
exp?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
37
src/common/types/external-inquiry.types.ts
Normal file
37
src/common/types/external-inquiry.types.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/** External inquiry kinds toggled per insurer (`clients.settings.externalInquiries`). */
|
||||||
|
export const EXTERNAL_INQUIRY_TYPES = [
|
||||||
|
"thirdPartyPlate",
|
||||||
|
"carBodyPlate",
|
||||||
|
"personalIdentity",
|
||||||
|
"sheba",
|
||||||
|
"drivingLicense",
|
||||||
|
"carOwnership",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ExternalInquiryType = (typeof EXTERNAL_INQUIRY_TYPES)[number];
|
||||||
|
|
||||||
|
/** Safe default: mock/off until explicitly enabled for an insurer. */
|
||||||
|
export const DEFAULT_EXTERNAL_INQUIRY_FLAGS: Record<
|
||||||
|
ExternalInquiryType,
|
||||||
|
boolean
|
||||||
|
> = {
|
||||||
|
thirdPartyPlate: false,
|
||||||
|
carBodyPlate: false,
|
||||||
|
personalIdentity: false,
|
||||||
|
sheba: false,
|
||||||
|
drivingLicense: false,
|
||||||
|
carOwnership: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExternalInquiryFlags = Record<ExternalInquiryType, boolean>;
|
||||||
|
|
||||||
|
export interface MockInquiryCompanyContext {
|
||||||
|
companyId: string;
|
||||||
|
companyName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeExternalInquiryFlags(
|
||||||
|
partial?: Partial<ExternalInquiryFlags> | null,
|
||||||
|
): ExternalInquiryFlags {
|
||||||
|
return { ...DEFAULT_EXTERNAL_INQUIRY_FLAGS, ...(partial ?? {}) };
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ import {
|
|||||||
IsBooleanString,
|
IsBooleanString,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
|
IsNumberString,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsPositive,
|
IsPositive,
|
||||||
IsString,
|
IsString,
|
||||||
IsUrl,
|
IsUrl,
|
||||||
|
Length,
|
||||||
Matches,
|
Matches,
|
||||||
Max,
|
Max,
|
||||||
Min,
|
Min,
|
||||||
@@ -80,9 +82,11 @@ export class EnvironmentVariables {
|
|||||||
JWT_EXPIRY: StringValue;
|
JWT_EXPIRY: StringValue;
|
||||||
|
|
||||||
// --------------------------------------------------------- //
|
// --------------------------------------------------------- //
|
||||||
|
|
||||||
@IsString({ message: "HASH_PEPPER must be string" })
|
@IsString({ message: "HASH_PEPPER must be string" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
HASH_PEPPER?: string;
|
HASH_PEPPER?: string;
|
||||||
|
|
||||||
// --------------------------------------------------------- //
|
// --------------------------------------------------------- //
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -440,12 +440,13 @@ export class ExpertClaimService {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Owner mobile: linked blame party phone when available, else `users.mobile`. */
|
/** Damaged-party mobile for claim SMS: prefer `damagedPartyUserId`, else `owner.userId`. */
|
||||||
private async resolveClaimOwnerPhone(
|
private async resolveClaimOwnerPhone(
|
||||||
claim: any,
|
claim: any,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
if (!claim?.owner?.userId) return undefined;
|
const notifyUserId = claim?.damagedPartyUserId ?? claim?.owner?.userId;
|
||||||
const ownerUserId = String(claim.owner.userId);
|
if (!notifyUserId) return undefined;
|
||||||
|
const ownerUserId = String(notifyUserId);
|
||||||
if (claim.blameRequestId) {
|
if (claim.blameRequestId) {
|
||||||
const blame = await this.blameRequestDbService.findById(
|
const blame = await this.blameRequestDbService.findById(
|
||||||
claim.blameRequestId.toString(),
|
claim.blameRequestId.toString(),
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Body, Controller, Post } from "@nestjs/common";
|
import { Body, Controller, Post } from "@nestjs/common";
|
||||||
import { Public } from "./decorators";
|
|
||||||
import { UserLoginDto, UserRegisterDto } from "./dtos";
|
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
|
import { Public } from "src/common/auth/decorators";
|
||||||
|
import { UserLoginDto, UserRegisterDto } from "../user/dtos";
|
||||||
|
|
||||||
@Controller("auth")
|
@Controller("auth")
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post("user/register")
|
@Post("user/send-otp")
|
||||||
async register(@Body() userRegisterDto: UserRegisterDto) {
|
async register(@Body() userRegisterDto: UserRegisterDto) {
|
||||||
return await this.authService.registerUser(userRegisterDto);
|
return await this.authService.registerUser(userRegisterDto);
|
||||||
}
|
}
|
||||||
@@ -3,10 +3,10 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { APP_GUARD } from "@nestjs/core";
|
import { APP_GUARD } from "@nestjs/core";
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
import { StringValue } from "ms";
|
import { StringValue } from "ms";
|
||||||
import { AuthGuard, RolesGuard } from "./guards";
|
import { AuthGuard, RolesGuard } from "src/common/auth/guards";
|
||||||
|
import { HashService, OtpService } from "./providers";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { HashService } from "./providers";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -27,6 +27,7 @@ import { HashService } from "./providers";
|
|||||||
{ provide: APP_GUARD, useClass: RolesGuard },
|
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||||
AuthService,
|
AuthService,
|
||||||
HashService,
|
HashService,
|
||||||
|
OtpService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
98
src/features/auth/auth.service.ts
Normal file
98
src/features/auth/auth.service.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { UserRepository } from "src/features/user/repositories";
|
||||||
|
import { HashService, OtpService } from "./providers";
|
||||||
|
import { UserLoginDto, UserRegisterDto } from "../user/dtos";
|
||||||
|
import { Otp } from "../user/schemas";
|
||||||
|
import { Environment } from "src/core/config/config.schema";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
otpDigits: number;
|
||||||
|
otpExpiryMinutes: number;
|
||||||
|
environment: Environment;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly userRepo: UserRepository,
|
||||||
|
private readonly hashService: HashService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly otpService: OtpService,
|
||||||
|
) {
|
||||||
|
this.otpDigits = this.configService.get<number>("DEFAULT_OTP_DIGITS") ?? 5;
|
||||||
|
this.otpExpiryMinutes =
|
||||||
|
(this.configService.get<number>("DEFAULT_OTP_EXPIRY_MINUTES") ?? 2) * 60;
|
||||||
|
this.environment = this.configService.get<Environment>("NODE_ENV");
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerUser(dto: UserRegisterDto) {
|
||||||
|
let user = await this.userRepo.retrieveByCellPhoneNumber(
|
||||||
|
dto.cellphoneNumber,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user && new Date() < new Date(user.otp.expiresAt))
|
||||||
|
throw new BadRequestException("OTP_STILL_VALID");
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
user = await this.userRepo.create(dto.cellphoneNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
const otpCode = this.otpService.generateOtp(this.otpDigits);
|
||||||
|
const hashedOtp = await this.hashService.hashOtp(otpCode);
|
||||||
|
const otp: Otp = {
|
||||||
|
hash: hashedOtp,
|
||||||
|
expiresAt: new Date(Date.now() + this.otpExpiryMinutes),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.userRepo.saveOtp(user.cellphoneNumber, otp);
|
||||||
|
|
||||||
|
if (this.environment === "development") {
|
||||||
|
return otpCode;
|
||||||
|
}
|
||||||
|
//TODO: Implement Integrations module and integrate with kavenegar, or whatever the provider is
|
||||||
|
//TODO: Send sms
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginUser(userLoginDto: UserLoginDto) {
|
||||||
|
const user = await this.userRepo.retrieveByCellPhoneNumber(
|
||||||
|
userLoginDto.cellphoneNumber,
|
||||||
|
);
|
||||||
|
if (!user) throw new BadRequestException("USER_NOT_FOUND");
|
||||||
|
|
||||||
|
const storedOtp = (user as any).otp;
|
||||||
|
if (!storedOtp) {
|
||||||
|
throw new BadRequestException("OTP_NOT_REQUESTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiry first — don't increment attempts on expired OTP
|
||||||
|
if (new Date() > new Date(storedOtp.expiresAt)) {
|
||||||
|
throw new BadRequestException("OTP_EXPIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check attempt count before verifying — prevent brute force
|
||||||
|
if (storedOtp.attempts >= this.otpService.maximum_otp_attemps) {
|
||||||
|
throw new ForbiddenException("OTP_MAX_ATTEMPTS_EXCEEDED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await this.hashService.verifyOtp(
|
||||||
|
userLoginDto.otp,
|
||||||
|
storedOtp.hash,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
await this.userRepo.incrementOtpAttempts(userLoginDto.cellphoneNumber);
|
||||||
|
throw new BadRequestException("OTP_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// OTP is correct — clear it so it can't be reused
|
||||||
|
await this.userRepo.clearOtp(userLoginDto.cellphoneNumber);
|
||||||
|
|
||||||
|
//TODO: Generate access+refresh token and send it to user
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/features/auth/providers/hash.provider.ts
Normal file
167
src/features/auth/providers/hash.provider.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import {
|
||||||
|
BinaryLike,
|
||||||
|
randomBytes,
|
||||||
|
scrypt,
|
||||||
|
ScryptOptions,
|
||||||
|
timingSafeEqual,
|
||||||
|
} from "node:crypto";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import {
|
||||||
|
CURRENT_ALGORITHM,
|
||||||
|
CURRENT_VERSION,
|
||||||
|
KEY_LENGTH,
|
||||||
|
KEY_LENGTH_FOR_OTP,
|
||||||
|
SCRYPT_PARAMS,
|
||||||
|
SCRYPT_PARAMS_FOR_OTP,
|
||||||
|
} from "src/common/auth/constants";
|
||||||
|
import { PasswordHash } from "src/common/auth/types";
|
||||||
|
|
||||||
|
const scryptAsync = promisify<
|
||||||
|
BinaryLike,
|
||||||
|
BinaryLike,
|
||||||
|
number,
|
||||||
|
ScryptOptions,
|
||||||
|
Buffer
|
||||||
|
>(scrypt);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HashService {
|
||||||
|
private readonly pepper: string;
|
||||||
|
private readonly current_alogrithm: string;
|
||||||
|
private readonly current_version: number;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.pepper = this.configService.get<string>("HASH_PEPPER") ?? "";
|
||||||
|
this.current_alogrithm = CURRENT_ALGORITHM;
|
||||||
|
this.current_version = CURRENT_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyPepper(input: string): string {
|
||||||
|
return this.pepper ? `${input}${this.pepper}` : input;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSalt(bytes: number = 16): string {
|
||||||
|
return randomBytes(bytes).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
private isLegacyPasswordFormat(stored: unknown): stored is string {
|
||||||
|
return typeof stored === "string" && stored.includes(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
private isVersionedPasswordFormat(stored: unknown): stored is PasswordHash {
|
||||||
|
return (
|
||||||
|
typeof stored === "object" &&
|
||||||
|
stored !== null &&
|
||||||
|
"hash" in stored &&
|
||||||
|
"salt" in stored &&
|
||||||
|
"algorithm" in stored &&
|
||||||
|
"version" in stored
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scrypt(input: string): Promise<[string, string]> {
|
||||||
|
const salt = this.generateSalt();
|
||||||
|
const pepperedInput = this.applyPepper(input);
|
||||||
|
const derived = await scryptAsync(
|
||||||
|
pepperedInput,
|
||||||
|
salt,
|
||||||
|
KEY_LENGTH,
|
||||||
|
SCRYPT_PARAMS,
|
||||||
|
);
|
||||||
|
return [salt, derived.toString("hex")];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyScrypt(
|
||||||
|
password: string,
|
||||||
|
salt: string,
|
||||||
|
hashHex: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const pepperedInput = this.applyPepper(password);
|
||||||
|
const derived = await scryptAsync(
|
||||||
|
pepperedInput,
|
||||||
|
salt,
|
||||||
|
KEY_LENGTH,
|
||||||
|
SCRYPT_PARAMS,
|
||||||
|
);
|
||||||
|
const storedBuffer = Buffer.from(hashHex, "hex");
|
||||||
|
if (derived.length !== storedBuffer.length) return false;
|
||||||
|
return timingSafeEqual(derived, storedBuffer);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async needsRehash(stored: string | PasswordHash): Promise<boolean> {
|
||||||
|
if (this.isLegacyPasswordFormat(stored)) return true;
|
||||||
|
if (!this.isVersionedPasswordFormat(stored)) return true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
stored.algorithm !== this.current_alogrithm ||
|
||||||
|
stored.version !== this.current_version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hashPassword(password: string): Promise<PasswordHash> {
|
||||||
|
const [salt, hash] = await this.scrypt(password);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
salt,
|
||||||
|
algorithm: CURRENT_ALGORITHM,
|
||||||
|
version: CURRENT_VERSION,
|
||||||
|
params: { ...SCRYPT_PARAMS, keyLength: KEY_LENGTH },
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyPassword(
|
||||||
|
password: string,
|
||||||
|
stored: string | PasswordHash,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (this.isLegacyPasswordFormat(stored)) {
|
||||||
|
const [salt, hash] = stored.split(":");
|
||||||
|
return this.verifyScrypt(password, salt, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isVersionedPasswordFormat(stored)) return false;
|
||||||
|
|
||||||
|
switch (stored.algorithm) {
|
||||||
|
case "scrypt":
|
||||||
|
return this.verifyScrypt(password, stored.salt, stored.hash);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async hashOtp(otp: string): Promise<string> {
|
||||||
|
const salt = this.generateSalt(8);
|
||||||
|
const derived = await scryptAsync(
|
||||||
|
otp,
|
||||||
|
salt,
|
||||||
|
KEY_LENGTH_FOR_OTP,
|
||||||
|
SCRYPT_PARAMS_FOR_OTP,
|
||||||
|
);
|
||||||
|
return `${salt}:${derived.toString("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyOtp(otp: string, stored: string): Promise<boolean> {
|
||||||
|
const [salt, hashHex] = stored.split(":");
|
||||||
|
if (!salt || !hashHex) return false;
|
||||||
|
try {
|
||||||
|
const derived = await scryptAsync(
|
||||||
|
otp,
|
||||||
|
salt,
|
||||||
|
KEY_LENGTH_FOR_OTP,
|
||||||
|
SCRYPT_PARAMS_FOR_OTP,
|
||||||
|
);
|
||||||
|
const storedBuffer = Buffer.from(hashHex, "hex");
|
||||||
|
if (derived.length !== storedBuffer.length) return false;
|
||||||
|
return timingSafeEqual(derived, storedBuffer);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export { HashService } from "./hash.provider";
|
export { HashService } from "./hash.provider";
|
||||||
|
export { OtpService } from "./otp.provider";
|
||||||
22
src/features/auth/providers/otp.provider.ts
Normal file
22
src/features/auth/providers/otp.provider.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Environment } from "src/core/config/config.schema";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OtpService {
|
||||||
|
maximum_otp_attemps: number;
|
||||||
|
environment: Environment;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.maximum_otp_attemps =
|
||||||
|
this.configService.get<number>("MAX_OTP_ATTEMPTS");
|
||||||
|
this.environment = this.configService.get<Environment>("NODE_ENV");
|
||||||
|
}
|
||||||
|
|
||||||
|
generateOtp(digits: number): string {
|
||||||
|
if (this.environment === "development") {
|
||||||
|
return this.configService.get<string>("DEFAULT_MOCK_OTP") ?? "11111";
|
||||||
|
}
|
||||||
|
return String(Math.floor(Math.random() * 1_000_000)).padStart(digits, "0");
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/features/user/dtos/index.ts
Normal file
2
src/features/user/dtos/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { UserRegisterDto } from "./requests/user-register.dto";
|
||||||
|
export { UserLoginDto } from "./requests/user-login.dto";
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
import { IsMobilePhone, IsNumberString, IsString } from "class-validator";
|
import {
|
||||||
|
IsMobilePhone,
|
||||||
|
IsNumberString,
|
||||||
|
IsString,
|
||||||
|
Length,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
export class UserLoginDto {
|
export class UserLoginDto {
|
||||||
@IsString({ message: "Cellphone number must be string" })
|
@IsString({ message: "Cellphone number must be string" })
|
||||||
@@ -6,5 +11,6 @@ export class UserLoginDto {
|
|||||||
cellphoneNumber: string;
|
cellphoneNumber: string;
|
||||||
|
|
||||||
@IsNumberString()
|
@IsNumberString()
|
||||||
|
@Length(4, 6)
|
||||||
otp: string;
|
otp: string;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsMobilePhone, IsNumberString, IsString } from "class-validator";
|
import { IsMobilePhone, IsString } from "class-validator";
|
||||||
|
|
||||||
export class UserRegisterDto {
|
export class UserRegisterDto {
|
||||||
@IsString({ message: "Cellphone number must be string" })
|
@IsString({ message: "Cellphone number must be string" })
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { InjectModel } from "@nestjs/mongoose";
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
import { User } from "../schemas";
|
|
||||||
import { Model } from "mongoose";
|
import { Model } from "mongoose";
|
||||||
|
import { Otp, User } from "../schemas";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserRepository {
|
export class UserRepository {
|
||||||
@@ -10,7 +10,9 @@ export class UserRepository {
|
|||||||
private readonly model: Model<User>,
|
private readonly model: Model<User>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async addUser() {}
|
async create(cellphoneNumber: string): Promise<User> {
|
||||||
|
return this.model.create({ cellphoneNumber });
|
||||||
|
}
|
||||||
|
|
||||||
async retrieveByCellPhoneNumber(
|
async retrieveByCellPhoneNumber(
|
||||||
cellphoneNumber: string,
|
cellphoneNumber: string,
|
||||||
@@ -19,4 +21,19 @@ export class UserRepository {
|
|||||||
cellphoneNumber,
|
cellphoneNumber,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async saveOtp(cellphoneNumber: string, otp: Otp): Promise<void> {
|
||||||
|
await this.model.updateOne({ cellphoneNumber }, { $set: { otp } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearOtp(cellphoneNumber: string): Promise<void> {
|
||||||
|
await this.model.updateOne({ cellphoneNumber }, { $unset: { otp: "" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementOtpAttempts(cellphoneNumber: string): Promise<void> {
|
||||||
|
await this.model.updateOne(
|
||||||
|
{ cellphoneNumber },
|
||||||
|
{ $inc: { "otp.attempts": 1 } },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,14 @@ export class Otp {
|
|||||||
@Prop()
|
@Prop()
|
||||||
hash: string;
|
hash: string;
|
||||||
|
|
||||||
@Prop()
|
|
||||||
salt: string;
|
|
||||||
|
|
||||||
@Prop()
|
@Prop()
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
|
|
||||||
@Prop({ default: 0 })
|
@Prop({ default: 0 })
|
||||||
attempts: number;
|
attempts?: number;
|
||||||
|
|
||||||
@Prop({ default: Date.now })
|
@Prop({ default: Date.now })
|
||||||
generatedAt: Date;
|
generatedAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OtpSchema = SchemaFactory.createForClass(Otp);
|
export const OtpSchema = SchemaFactory.createForClass(Otp);
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
import { UserRepository } from "./repositories";
|
import { UserRepository } from "./repositories";
|
||||||
|
import { User, UserSchema } from "./schemas";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{
|
||||||
|
name: User.name,
|
||||||
|
schema: UserSchema,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [UserRepository],
|
providers: [UserRepository],
|
||||||
exports: [UserRepository],
|
exports: [UserRepository],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Types } from "mongoose";
|
|||||||
import { partyPersonMatchesUser } from "./iran-mobile";
|
import { partyPersonMatchesUser } from "./iran-mobile";
|
||||||
import {
|
import {
|
||||||
blameDamagedPartyMatchesUser,
|
blameDamagedPartyMatchesUser,
|
||||||
|
resolveClaimOwnerFieldsFromBlame,
|
||||||
resolveDamagedPartyPerson,
|
resolveDamagedPartyPerson,
|
||||||
resolveGuiltyPartyUserId,
|
resolveGuiltyPartyUserId,
|
||||||
} from "./blame-damaged-party";
|
} from "./blame-damaged-party";
|
||||||
@@ -46,13 +47,30 @@ describe("blame damaged party helpers", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches damaged party by phone when userId differs (duplicate accounts)", () => {
|
it("resolveClaimOwnerFieldsFromBlame uses damaged userId and guilty clientId", () => {
|
||||||
expect(
|
const guiltyClientId = new Types.ObjectId();
|
||||||
partyPersonMatchesUser(
|
const blame = {
|
||||||
{ userId: damagedId, phoneNumber: "9123456789" },
|
type: "THIRD_PARTY",
|
||||||
{ sub: "other-user-id", username: "09123456789" },
|
expert: { decision: { guiltyPartyId: guiltyId } },
|
||||||
[],
|
parties: [
|
||||||
),
|
{
|
||||||
).toBe(true);
|
role: "FIRST",
|
||||||
|
person: {
|
||||||
|
userId: guiltyId,
|
||||||
|
phoneNumber: "09111111111",
|
||||||
|
clientId: guiltyClientId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "SECOND",
|
||||||
|
person: { userId: damagedId, phoneNumber: "09912356917" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(resolveClaimOwnerFieldsFromBlame(blame)).toEqual({
|
||||||
|
userId: String(damagedId),
|
||||||
|
userRole: "SECOND",
|
||||||
|
clientId: String(guiltyClientId),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -119,3 +119,45 @@ export function resolveDamagedPartyUserId(
|
|||||||
const person = resolveDamagedPartyPerson(blame);
|
const person = resolveDamagedPartyPerson(blame);
|
||||||
return person?.userId != null ? String(person.userId) : null;
|
return person?.userId != null ? String(person.userId) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Guilty party row on the blame case (payer's insurer is on `person.clientId`). */
|
||||||
|
export function resolveGuiltyPartyPerson(
|
||||||
|
blame: Parameters<typeof resolveClaimOwnerParty>[0],
|
||||||
|
): { userId?: unknown; phoneNumber?: string; clientId?: unknown } | null {
|
||||||
|
const party = resolveClaimOwnerParty(blame);
|
||||||
|
return party?.person ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim `owner` fields for initiator-filled IN_PERSON claims (field expert / registrar).
|
||||||
|
* - `userId` = damaged party (SMS, user sign-off, claim beneficiary)
|
||||||
|
* - `clientId` = guilty party's insurer (damage-expert tenant queue)
|
||||||
|
*/
|
||||||
|
export function resolveClaimOwnerFieldsFromBlame(
|
||||||
|
blame: Parameters<typeof resolveDamagedPartyPerson>[0],
|
||||||
|
): {
|
||||||
|
userId: string;
|
||||||
|
userRole?: string;
|
||||||
|
clientId?: string;
|
||||||
|
} | null {
|
||||||
|
const damagedPerson = resolveDamagedPartyPerson(blame);
|
||||||
|
if (damagedPerson?.userId == null) return null;
|
||||||
|
|
||||||
|
const parties = blame?.parties ?? [];
|
||||||
|
const damagedUserId = String(damagedPerson.userId);
|
||||||
|
const damagedPartyRow = parties.find(
|
||||||
|
(p) =>
|
||||||
|
p.person?.userId != null &&
|
||||||
|
String(p.person.userId) === damagedUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const guiltyPerson = resolveGuiltyPartyPerson(blame);
|
||||||
|
const payingClientId =
|
||||||
|
guiltyPerson?.clientId != null ? String(guiltyPerson.clientId) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: damagedUserId,
|
||||||
|
userRole: damagedPartyRow?.role,
|
||||||
|
...(payingClientId ? { clientId: payingClientId } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1114,11 +1114,20 @@ export class RequestManagementService {
|
|||||||
// ---- External inquiry 1: Tejarat block inquiry ----
|
// ---- External inquiry 1: Tejarat block inquiry ----
|
||||||
let inquiryRaw: any;
|
let inquiryRaw: any;
|
||||||
let inquiryMapped: any;
|
let inquiryMapped: any;
|
||||||
|
const inquiryClientId = party.person?.clientId
|
||||||
|
? String(party.person.clientId)
|
||||||
|
: undefined;
|
||||||
|
const inquiryOptions = inquiryClientId
|
||||||
|
? { clientId: inquiryClientId }
|
||||||
|
: undefined;
|
||||||
try {
|
try {
|
||||||
const inquiry = await this.sandHubService.getTejaratBlockInquiry({
|
const inquiry = await this.sandHubService.getTejaratBlockInquiry(
|
||||||
plate: body.plate,
|
{
|
||||||
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
|
plate: body.plate,
|
||||||
});
|
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
|
||||||
|
},
|
||||||
|
inquiryOptions,
|
||||||
|
);
|
||||||
inquiryRaw = inquiry.raw;
|
inquiryRaw = inquiry.raw;
|
||||||
inquiryMapped = inquiry.mapped;
|
inquiryMapped = inquiry.mapped;
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -1199,6 +1208,7 @@ export class RequestManagementService {
|
|||||||
const personalInquiry = await this.sandHubService.getPersonalInquiry(
|
const personalInquiry = await this.sandHubService.getPersonalInquiry(
|
||||||
personalNationalCode,
|
personalNationalCode,
|
||||||
personalBirthDate,
|
personalBirthDate,
|
||||||
|
inquiryOptions,
|
||||||
);
|
);
|
||||||
this.recordPartyCaseInquiryStatus(
|
this.recordPartyCaseInquiryStatus(
|
||||||
req,
|
req,
|
||||||
@@ -1312,10 +1322,15 @@ export class RequestManagementService {
|
|||||||
if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) {
|
if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) {
|
||||||
let carBodyInfo: any;
|
let carBodyInfo: any;
|
||||||
try {
|
try {
|
||||||
carBodyInfo = await this.sandHubService.getTejaratCarBodyInquiry({
|
carBodyInfo = await this.sandHubService.getTejaratCarBodyInquiry(
|
||||||
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
|
{
|
||||||
plate: body.plate,
|
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
|
||||||
});
|
plate: body.plate,
|
||||||
|
},
|
||||||
|
resolvedClientId
|
||||||
|
? { clientId: String(resolvedClientId) }
|
||||||
|
: inquiryOptions,
|
||||||
|
);
|
||||||
this.recordPartyCaseInquiryStatus(req, "carBody", role, true, {
|
this.recordPartyCaseInquiryStatus(req, "carBody", role, true, {
|
||||||
source: "TEJARAT_CAR_BODY_INQUIRY",
|
source: "TEJARAT_CAR_BODY_INQUIRY",
|
||||||
raw: carBodyInfo.raw,
|
raw: carBodyInfo.raw,
|
||||||
@@ -4046,9 +4061,6 @@ export class RequestManagementService {
|
|||||||
persian: data.clientName,
|
persian: data.clientName,
|
||||||
english: null,
|
english: null,
|
||||||
},
|
},
|
||||||
property: {
|
|
||||||
smsApiKey: null,
|
|
||||||
},
|
|
||||||
useExpertMode: "genuine",
|
useExpertMode: "genuine",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -5633,6 +5645,7 @@ export class RequestManagementService {
|
|||||||
userId: firstPartyUserId,
|
userId: firstPartyUserId,
|
||||||
phoneNumber: normalizeIranMobile(formData.firstPartyPhoneNumber) ??
|
phoneNumber: normalizeIranMobile(formData.firstPartyPhoneNumber) ??
|
||||||
formData.firstPartyPhoneNumber,
|
formData.firstPartyPhoneNumber,
|
||||||
|
clientId: (client as any)?._id ?? (client as any)?._doc?._id,
|
||||||
nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer,
|
nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer,
|
||||||
nationalCodeOfDriver: firstPartyPlate.nationalCodeOfDriver,
|
nationalCodeOfDriver: firstPartyPlate.nationalCodeOfDriver,
|
||||||
insurerLicense: firstPartyPlate.insurerLicense,
|
insurerLicense: firstPartyPlate.insurerLicense,
|
||||||
@@ -5813,11 +5826,14 @@ export class RequestManagementService {
|
|||||||
`Client not found for company: ${clientName}`,
|
`Client not found for company: ${clientName}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const resolvedClientId =
|
||||||
|
(client as any)?._id ?? (client as any)?._doc?._id;
|
||||||
return {
|
return {
|
||||||
role,
|
role,
|
||||||
person: {
|
person: {
|
||||||
userId,
|
userId,
|
||||||
phoneNumber: normalizeIranMobile(phoneNumber) ?? phoneNumber,
|
phoneNumber: normalizeIranMobile(phoneNumber) ?? phoneNumber,
|
||||||
|
...(resolvedClientId ? { clientId: resolvedClientId } : {}),
|
||||||
nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer,
|
nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer,
|
||||||
nationalCodeOfDriver: plateDto.nationalCodeOfDriver,
|
nationalCodeOfDriver: plateDto.nationalCodeOfDriver,
|
||||||
insurerLicense: plateDto.insurerLicense,
|
insurerLicense: plateDto.insurerLicense,
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { PlatesDto } from "src/plates/dto/plate.dto";
|
import { PlatesDto } from "src/plates/dto/plate.dto";
|
||||||
import { Plates } from "src/Types&Enums/plate.interface";
|
import { Plates } from "src/Types&Enums/plate.interface";
|
||||||
|
|
||||||
|
/** Optional insurer scope for per-client inquiry toggles and mock company fields. */
|
||||||
|
export class SandHubInquiryOptionsDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Insurer client Mongo ObjectId. When omitted, resolves from deployment CLIENT_ID env.",
|
||||||
|
})
|
||||||
|
clientId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SandHubInquiryOptions = SandHubInquiryOptionsDto;
|
||||||
|
|
||||||
export class SandHubLoginDtoRs {
|
export class SandHubLoginDtoRs {
|
||||||
public loginToken: string;
|
public loginToken: string;
|
||||||
constructor(token: string) {
|
constructor(token: string) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { HttpModule } from "@nestjs/axios";
|
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { MongooseModule } from "@nestjs/mongoose";
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
|
import { ClientModule } from "src/client/client.module";
|
||||||
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
|
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
|
||||||
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
|
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
|
||||||
import { SandHubModel, SandHubSchema } from "./entity/schema/sand-hub.schema";
|
import { SandHubModel, SandHubSchema } from "./entity/schema/sand-hub.schema";
|
||||||
@@ -10,6 +11,7 @@ import { SandHubService } from "./sand-hub.service";
|
|||||||
imports: [
|
imports: [
|
||||||
HttpModule,
|
HttpModule,
|
||||||
SystemSettingsModule,
|
SystemSettingsModule,
|
||||||
|
ClientModule,
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{ name: SandHubModel.name, schema: SandHubSchema },
|
{ name: SandHubModel.name, schema: SandHubSchema },
|
||||||
]),
|
]),
|
||||||
|
|||||||
85
src/sand-hub/sand-hub.service.spec.ts
Normal file
85
src/sand-hub/sand-hub.service.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { SandHubService } from "./sand-hub.service";
|
||||||
|
import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service";
|
||||||
|
import { SandHubDetailDto } from "./dto/sand-hub.dto";
|
||||||
|
|
||||||
|
describe("SandHubService inquiry mocks", () => {
|
||||||
|
const httpService = { post: jest.fn() };
|
||||||
|
const sandHubDbService = { findOneBySandHubId: jest.fn() };
|
||||||
|
const externalInquirySettings = {
|
||||||
|
isInquiryLive: jest.fn(),
|
||||||
|
getMockCompanyContext: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let service: SandHubService;
|
||||||
|
|
||||||
|
const userDetail = {
|
||||||
|
nationalCodeOfInsurer: "1234567890",
|
||||||
|
plate: {
|
||||||
|
leftDigits: 16,
|
||||||
|
centerAlphabet: "12",
|
||||||
|
centerDigits: 498,
|
||||||
|
ir: 60,
|
||||||
|
nationalCode: "1234567890",
|
||||||
|
},
|
||||||
|
} as SandHubDetailDto;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
delete process.env.CLIENT_ID;
|
||||||
|
service = new SandHubService(
|
||||||
|
httpService as any,
|
||||||
|
sandHubDbService as any,
|
||||||
|
externalInquirySettings as unknown as ExternalInquirySettingsService,
|
||||||
|
);
|
||||||
|
externalInquirySettings.isInquiryLive.mockResolvedValue(false);
|
||||||
|
externalInquirySettings.getMockCompanyContext.mockResolvedValue({
|
||||||
|
companyId: "8",
|
||||||
|
companyName: "بیمه پارسیان",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns car-body mock without HTTP when carBodyPlate is off", async () => {
|
||||||
|
const result = await service.getTejaratCarBodyInquiry(userDetail);
|
||||||
|
|
||||||
|
expect(httpService.post).not.toHaveBeenCalled();
|
||||||
|
expect(externalInquirySettings.isInquiryLive).toHaveBeenCalledWith(
|
||||||
|
"carBodyPlate",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(result.raw?.isSuccess).toBe(true);
|
||||||
|
expect(result.raw?.data?.companyId).toBe(8);
|
||||||
|
expect(result.raw?.data?.companyName).toBe("بیمه پارسیان");
|
||||||
|
expect(result.mapped.policyNumber).toBe("1405/1143-70591/220/1/0");
|
||||||
|
expect(result.mapped.CompanyName).toBe("بیمه پارسیان");
|
||||||
|
expect(result.mapped.companyId).toBe(8);
|
||||||
|
expect(result.mapped.insurerNationalCode).toBe("1234567890");
|
||||||
|
expect(result.mapped.platePartOne).toBe(16);
|
||||||
|
expect(result.mapped.platePartThree).toBe(498);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses car-body mock shape in Tejarat helper when inquiry is off", async () => {
|
||||||
|
const raw = await (service as any).makeTejaratRequest(
|
||||||
|
"http://example/block-inquiry-tejarat/badane",
|
||||||
|
{
|
||||||
|
part1: 16,
|
||||||
|
part2: 12,
|
||||||
|
part3: 498,
|
||||||
|
part4: 60,
|
||||||
|
nationalCode: "1234567890",
|
||||||
|
},
|
||||||
|
"carBodyPlate",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(httpService.post).not.toHaveBeenCalled();
|
||||||
|
expect(raw?.data?.printNumber).toBe("1405/1143-70591/220/1/0");
|
||||||
|
expect(raw?.data?.companyName).toBe("بیمه پارسیان");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns third-party plate mock for block inquiry when inquiry is off", async () => {
|
||||||
|
const result = await service.getTejaratBlockInquiry(userDetail);
|
||||||
|
|
||||||
|
expect(httpService.post).not.toHaveBeenCalled();
|
||||||
|
expect(result.mapped?.CompanyName).toBe("بیمه پارسیان");
|
||||||
|
expect(result.mapped?.CompanyCode).toBe("8");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,13 +11,18 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
|
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
|
||||||
import { SandHubModel } from "src/sand-hub/entity/schema/sand-hub.schema";
|
import { SandHubModel } from "src/sand-hub/entity/schema/sand-hub.schema";
|
||||||
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service";
|
||||||
import { SandHubDetailDto } from "./dto/sand-hub.dto";
|
import type { ExternalInquiryType } from "src/common/types/external-inquiry.types";
|
||||||
|
import type { MockInquiryCompanyContext } from "src/common/types/external-inquiry.types";
|
||||||
|
import { SandHubDetailDto, SandHubInquiryOptions } from "./dto/sand-hub.dto";
|
||||||
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
|
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SandHubService {
|
export class SandHubService {
|
||||||
|
private static readonly ESG_INQUIRY_UNAVAILABLE_MESSAGE =
|
||||||
|
"استعلام در دسترس نیست";
|
||||||
|
|
||||||
private readonly logger = new Logger(SandHubService.name);
|
private readonly logger = new Logger(SandHubService.name);
|
||||||
private loginToken: string | null = null;
|
private loginToken: string | null = null;
|
||||||
private tokenExpiry: Date | null = null;
|
private tokenExpiry: Date | null = null;
|
||||||
@@ -33,32 +38,39 @@ export class SandHubService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
private readonly sandHubDbService: SandHubDbService,
|
private readonly sandHubDbService: SandHubDbService,
|
||||||
private readonly systemSettingsService: SystemSettingsService,
|
private readonly externalInquirySettings: ExternalInquirySettingsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
private clientRefFrom(options?: SandHubInquiryOptions): string | undefined {
|
||||||
* When false, no outbound HTTP to SandHub/Tejarat — inquiries use permissive mocks.
|
return options?.clientId;
|
||||||
* Controlled by `system_settings.externalApis.sandHubUseLiveApi` (PATCH /system-settings).
|
|
||||||
*/
|
|
||||||
private async useLiveSandHubApis(): Promise<boolean> {
|
|
||||||
return this.systemSettingsService.isSandHubLiveEnabled();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tenant-specific company fields for mocked external inquiries (MOCK_INQUIRY_COMPANY_*). */
|
private async isInquiryLive(
|
||||||
private getMockInquiryCompanyId(): string {
|
type: ExternalInquiryType,
|
||||||
return process.env.CLIENT_ID ?? "15";
|
options?: SandHubInquiryOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.externalInquirySettings.isInquiryLive(
|
||||||
|
type,
|
||||||
|
this.clientRefFrom(options),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mockCompanyContext(
|
||||||
|
options?: SandHubInquiryOptions,
|
||||||
|
): Promise<MockInquiryCompanyContext> {
|
||||||
|
return this.externalInquirySettings.getMockCompanyContext(
|
||||||
|
this.clientRefFrom(options),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldUseEsgInquiryProvider(): boolean {
|
private shouldUseEsgInquiryProvider(): boolean {
|
||||||
return String(process.env.CLIENT_ID ?? "") === "8";
|
return String(process.env.CLIENT_ID ?? "") === "8";
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMockInquiryCompanyName(): string {
|
|
||||||
return process.env.CLIENT_NAME ?? "بیمه سامان";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */
|
/** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */
|
||||||
private getDefaultMockPlateInquiryRaw(): Record<string, unknown> {
|
private buildMockPlateInquiryRaw(
|
||||||
|
ctx: MockInquiryCompanyContext,
|
||||||
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
PrntPlcyCmpDocNo: "1404/1143-70591/200/123",
|
PrntPlcyCmpDocNo: "1404/1143-70591/200/123",
|
||||||
MapTypNam: "فائو ( FAW )",
|
MapTypNam: "فائو ( FAW )",
|
||||||
@@ -107,8 +119,8 @@ export class SandHubService {
|
|||||||
InstallDateField: "1403/03/01",
|
InstallDateField: "1403/03/01",
|
||||||
AxelNumberField: "2",
|
AxelNumberField: "2",
|
||||||
WheelNumberField: "4",
|
WheelNumberField: "4",
|
||||||
CompanyName: process.env.CLIENT_NAME ?? "بیمه سامان",
|
CompanyName: ctx.companyName,
|
||||||
CompanyCode: `${process.env.CLIENT_ID ?? 15}`,
|
CompanyCode: ctx.companyId,
|
||||||
IssueDate: "1403/04/06",
|
IssueDate: "1403/04/06",
|
||||||
SatrtDate: "1403/04/06",
|
SatrtDate: "1403/04/06",
|
||||||
EndDate: "1404/04/06",
|
EndDate: "1404/04/06",
|
||||||
@@ -133,17 +145,51 @@ export class SandHubService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultMockCarBodyInquiryRaw(
|
/** Build {@link SandHubDetailDto} from Tejarat badane / block-inquiry request bodies. */
|
||||||
|
private tejaratPayloadToSandHubDetail(payload: {
|
||||||
|
nationalCode?: string | number;
|
||||||
|
part1?: string | number;
|
||||||
|
part2?: string | number;
|
||||||
|
part3?: string | number;
|
||||||
|
part4?: string | number;
|
||||||
|
leftTwoDigits?: string | number;
|
||||||
|
serialLetter?: string | number;
|
||||||
|
threeDigits?: string | number;
|
||||||
|
rightTwoDigits?: string | number;
|
||||||
|
}): SandHubDetailDto {
|
||||||
|
const nationalCode = String(payload.nationalCode ?? "");
|
||||||
|
return {
|
||||||
|
nationalCodeOfInsurer: nationalCode,
|
||||||
|
plate: {
|
||||||
|
leftDigits: Number(payload.part1 ?? payload.leftTwoDigits ?? 16),
|
||||||
|
centerAlphabet: String(
|
||||||
|
payload.part2 ?? payload.serialLetter ?? "12",
|
||||||
|
),
|
||||||
|
centerDigits: Number(payload.part3 ?? payload.threeDigits ?? 498),
|
||||||
|
ir: Number(payload.part4 ?? payload.rightTwoDigits ?? 60),
|
||||||
|
nationalCode,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMockCarBodyInquiryRaw(
|
||||||
userDetail: SandHubDetailDto,
|
userDetail: SandHubDetailDto,
|
||||||
|
ctx: MockInquiryCompanyContext,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const companyId = Number(this.getMockInquiryCompanyId());
|
const companyId = Number(ctx.companyId);
|
||||||
|
const plate = userDetail?.plate;
|
||||||
|
const platePartOne = Number(plate?.leftDigits ?? 16);
|
||||||
|
const platePartThree = Number(plate?.centerDigits ?? 498);
|
||||||
|
const plateSerialNumber = Number(plate?.ir ?? 60);
|
||||||
|
const plateLetterid = Number(plate?.centerAlphabet ?? 12);
|
||||||
|
const nationalCode = String(userDetail?.nationalCodeOfInsurer ?? "");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
id: 70016075946,
|
id: 70016075946,
|
||||||
printNumber: "1405/1143-70591/220/1/0",
|
printNumber: "1405/1143-70591/220/1/0",
|
||||||
companyId: Number.isNaN(companyId) ? 15 : companyId,
|
companyId: Number.isNaN(companyId) ? 15 : companyId,
|
||||||
companyName: this.getMockInquiryCompanyName(),
|
companyName: ctx.companyName,
|
||||||
beginDate: "1405/01/17",
|
beginDate: "1405/01/17",
|
||||||
endDate: "1406/01/17",
|
endDate: "1406/01/17",
|
||||||
issueDate: "1405/01/16",
|
issueDate: "1405/01/16",
|
||||||
@@ -153,19 +199,19 @@ export class SandHubService {
|
|||||||
vin: "LFP8C7PC3R1K12157",
|
vin: "LFP8C7PC3R1K12157",
|
||||||
plateTypeId: 9,
|
plateTypeId: 9,
|
||||||
plateTypeTitle: "پلاک قدیمی",
|
plateTypeTitle: "پلاک قدیمی",
|
||||||
platePartOne: 16,
|
platePartOne,
|
||||||
plateSerialNumber: 60,
|
plateSerialNumber,
|
||||||
plateLetterid: 12,
|
plateLetterid,
|
||||||
plateLetterTitle: "م ",
|
plateLetterTitle: "م ",
|
||||||
vehicleSystemTitle: "فائو ( FAW )",
|
vehicleSystemTitle: "فائو ( FAW )",
|
||||||
vehicleGroupId: 2,
|
vehicleGroupId: 2,
|
||||||
vehicleGroupTitle: "سواری چهار سیلندر",
|
vehicleGroupTitle: "سواری چهار سیلندر",
|
||||||
insurerNationalCode: userDetail.nationalCodeOfInsurer,
|
insurerNationalCode: nationalCode,
|
||||||
insurerName: "هانيه کارخانهء",
|
insurerName: "هانيه کارخانهء",
|
||||||
ownerNationalCode: userDetail.nationalCodeOfInsurer,
|
ownerNationalCode: nationalCode,
|
||||||
previousId: 70006331822,
|
previousId: 70006331822,
|
||||||
noLossYearsCount: 4,
|
noLossYearsCount: 4,
|
||||||
platePartThree: 498,
|
platePartThree,
|
||||||
lossDocuments: [],
|
lossDocuments: [],
|
||||||
},
|
},
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
@@ -174,10 +220,31 @@ export class SandHubService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildMockInquiryRaw(
|
||||||
|
inquiryType: ExternalInquiryType,
|
||||||
|
ctx: MockInquiryCompanyContext,
|
||||||
|
payload?: unknown,
|
||||||
|
userDetail?: SandHubDetailDto,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (inquiryType === "carBodyPlate") {
|
||||||
|
const detail =
|
||||||
|
userDetail ??
|
||||||
|
this.tejaratPayloadToSandHubDetail(
|
||||||
|
(payload ?? {}) as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
return this.buildMockCarBodyInquiryRaw(detail, ctx);
|
||||||
|
}
|
||||||
|
return this.buildMockPlateInquiryRaw(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bodies returned from `makeSandHubRequest` in mock mode (same shape callers expect from real API).
|
* Bodies returned from `makeSandHubRequest` in mock mode (same shape callers expect from real API).
|
||||||
*/
|
*/
|
||||||
private buildMockSandHubResponseForUrl(url: string, payload?: unknown): any {
|
private buildMockSandHubResponseForUrl(
|
||||||
|
url: string,
|
||||||
|
payload?: unknown,
|
||||||
|
plateMock?: Record<string, unknown>,
|
||||||
|
): any {
|
||||||
const u = (url || "").toLowerCase();
|
const u = (url || "").toLowerCase();
|
||||||
if (u.includes("personal-inquiry")) {
|
if (u.includes("personal-inquiry")) {
|
||||||
const p = payload as { nationalCode?: string } | undefined;
|
const p = payload as { nationalCode?: string } | undefined;
|
||||||
@@ -215,9 +282,27 @@ export class SandHubService {
|
|||||||
Message: "mock-sheba-ok",
|
Message: "mock-sheba-ok",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (u.includes("badane")) {
|
||||||
|
return (
|
||||||
|
plateMock ??
|
||||||
|
this.buildMockCarBodyInquiryRaw(
|
||||||
|
this.tejaratPayloadToSandHubDetail(
|
||||||
|
(payload ?? {}) as Record<string, unknown>,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
companyId: process.env.CLIENT_ID ?? "15",
|
||||||
|
companyName: process.env.CLIENT_NAME ?? "بیمه سامان",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
if (u.includes("block-inquiry") || u.includes("tejarat")) {
|
if (u.includes("block-inquiry") || u.includes("tejarat")) {
|
||||||
return this.mapNewApiResponseToOldFormat(
|
return this.mapNewApiResponseToOldFormat(
|
||||||
this.getDefaultMockPlateInquiryRaw(),
|
plateMock ??
|
||||||
|
this.buildMockPlateInquiryRaw({
|
||||||
|
companyId: process.env.CLIENT_ID ?? "15",
|
||||||
|
companyName: process.env.CLIENT_NAME ?? "بیمه سامان",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -227,9 +312,6 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getAccessToken(): Promise<string> {
|
private async getAccessToken(): Promise<string> {
|
||||||
if (!(await this.useLiveSandHubApis())) {
|
|
||||||
return "mock-sandhub-access-token";
|
|
||||||
}
|
|
||||||
if (this.loginToken && this.tokenExpiry && this.tokenExpiry > new Date()) {
|
if (this.loginToken && this.tokenExpiry && this.tokenExpiry > new Date()) {
|
||||||
return this.loginToken;
|
return this.loginToken;
|
||||||
}
|
}
|
||||||
@@ -266,9 +348,6 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getTejaratAccessToken(): Promise<string> {
|
private async getTejaratAccessToken(): Promise<string> {
|
||||||
if (!(await this.useLiveSandHubApis())) {
|
|
||||||
return "mock-tejarat-access-token";
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
this.tejaratAccessToken &&
|
this.tejaratAccessToken &&
|
||||||
this.tejaratTokenExpiry &&
|
this.tejaratTokenExpiry &&
|
||||||
@@ -329,9 +408,6 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getEsgAccessToken(): Promise<string> {
|
private async getEsgAccessToken(): Promise<string> {
|
||||||
if (!(await this.useLiveSandHubApis())) {
|
|
||||||
return "mock-esg-access-token";
|
|
||||||
}
|
|
||||||
if (this.esgAccessToken && this.esgTokenExpiry && this.esgTokenExpiry > new Date()) {
|
if (this.esgAccessToken && this.esgTokenExpiry && this.esgTokenExpiry > new Date()) {
|
||||||
return this.esgAccessToken;
|
return this.esgAccessToken;
|
||||||
}
|
}
|
||||||
@@ -384,10 +460,31 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async makeEsgRequest(url: string, payload: any, maxRetries = 2) {
|
private async makeEsgRequest(
|
||||||
if (!(await this.useLiveSandHubApis())) {
|
url: string,
|
||||||
this.logger.log(`[MOCK] ESG POST skipped: ${url}`);
|
payload: any,
|
||||||
return this.getDefaultMockPlateInquiryRaw();
|
inquiryType: ExternalInquiryType,
|
||||||
|
options?: SandHubInquiryOptions,
|
||||||
|
maxRetries = 2,
|
||||||
|
) {
|
||||||
|
if (!(await this.isInquiryLive(inquiryType, options))) {
|
||||||
|
this.logger.log(`[MOCK] ESG POST skipped (${inquiryType}): ${url}`);
|
||||||
|
const ctx = await this.mockCompanyContext(options);
|
||||||
|
if (inquiryType === "personalIdentity") {
|
||||||
|
const nin =
|
||||||
|
typeof payload?.nationalCode === "string"
|
||||||
|
? payload.nationalCode
|
||||||
|
: "-";
|
||||||
|
return this.getDefaultMockPersonInquiry(nin);
|
||||||
|
}
|
||||||
|
if (inquiryType === "sheba") {
|
||||||
|
return {
|
||||||
|
ReturnValue: true,
|
||||||
|
HasError: false,
|
||||||
|
Message: "mock-sheba-ok",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return this.buildMockInquiryRaw(inquiryType, ctx, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_DELAY = 500;
|
const INITIAL_DELAY = 500;
|
||||||
@@ -434,17 +531,13 @@ export class SandHubService {
|
|||||||
if (!raw) return raw;
|
if (!raw) return raw;
|
||||||
|
|
||||||
if (raw?.success === false) {
|
if (raw?.success === false) {
|
||||||
|
this.logger.warn(
|
||||||
|
"ESG policyByPlate inquiry returned success=false",
|
||||||
|
raw,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
Error: {
|
Error: {
|
||||||
Message:
|
Message: SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
|
||||||
raw?.error?.message ||
|
|
||||||
raw?.message ||
|
|
||||||
"ESG policyByPlate inquiry returned an error",
|
|
||||||
Code: raw?.error?.code || raw?.error?.providerCode || "ESG_INQUIRY_ERROR",
|
|
||||||
ProviderMessage: raw?.error?.providerMessage,
|
|
||||||
ProviderCode: raw?.error?.providerCode,
|
|
||||||
TrackingCode: raw?.trackingCode,
|
|
||||||
Conflict: raw?.error?.conflict,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -528,10 +621,9 @@ export class SandHubService {
|
|||||||
|
|
||||||
private mapEsgPersonInquiryToOldFormat(raw: any): Record<string, unknown> {
|
private mapEsgPersonInquiryToOldFormat(raw: any): Record<string, unknown> {
|
||||||
if (raw?.success === false) {
|
if (raw?.success === false) {
|
||||||
throw new NotFoundException(
|
this.logger.warn("ESG person inquiry returned success=false", raw);
|
||||||
raw?.error?.message ||
|
throw new BadRequestException(
|
||||||
raw?.message ||
|
SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
|
||||||
"Personal inquiry failed: Record not found for the given national code and birth date.",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,10 +646,9 @@ export class SandHubService {
|
|||||||
|
|
||||||
private mapEsgShebaInquiryToOldFormat(raw: any): Record<string, unknown> {
|
private mapEsgShebaInquiryToOldFormat(raw: any): Record<string, unknown> {
|
||||||
if (raw?.success === false) {
|
if (raw?.success === false) {
|
||||||
|
this.logger.warn("ESG sheba inquiry returned success=false", raw);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
raw?.error?.message ||
|
SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
|
||||||
raw?.message ||
|
|
||||||
"Sheba ID validation failed. The provided Sheba ID does not match the national ID.",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,10 +662,17 @@ export class SandHubService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async makeTejaratRequest(url: string, payload: any, maxRetries = 2) {
|
private async makeTejaratRequest(
|
||||||
if (!(await this.useLiveSandHubApis())) {
|
url: string,
|
||||||
this.logger.log(`[MOCK] Tejarat POST skipped: ${url}`);
|
payload: any,
|
||||||
return this.getDefaultMockPlateInquiryRaw();
|
inquiryType: ExternalInquiryType,
|
||||||
|
options?: SandHubInquiryOptions,
|
||||||
|
maxRetries = 2,
|
||||||
|
) {
|
||||||
|
if (!(await this.isInquiryLive(inquiryType, options))) {
|
||||||
|
this.logger.log(`[MOCK] Tejarat POST skipped (${inquiryType}): ${url}`);
|
||||||
|
const ctx = await this.mockCompanyContext(options);
|
||||||
|
return this.buildMockInquiryRaw(inquiryType, ctx, payload);
|
||||||
}
|
}
|
||||||
const INITIAL_DELAY = 500;
|
const INITIAL_DELAY = 500;
|
||||||
const BACKOFF_FACTOR = 2;
|
const BACKOFF_FACTOR = 2;
|
||||||
@@ -624,10 +722,14 @@ export class SandHubService {
|
|||||||
* Tejarat block inquiry (replaces SandHub call for V2 flows).
|
* Tejarat block inquiry (replaces SandHub call for V2 flows).
|
||||||
* Returns both raw + mapped (old-format) response.
|
* Returns both raw + mapped (old-format) response.
|
||||||
*/
|
*/
|
||||||
async getTejaratBlockInquiry(userDetail: SandHubDetailDto): Promise<{
|
async getTejaratBlockInquiry(
|
||||||
|
userDetail: SandHubDetailDto,
|
||||||
|
options?: SandHubInquiryOptions,
|
||||||
|
): Promise<{
|
||||||
raw: any;
|
raw: any;
|
||||||
mapped: any;
|
mapped: any;
|
||||||
}> {
|
}> {
|
||||||
|
const ctx = await this.mockCompanyContext(options);
|
||||||
if (this.shouldUseEsgInquiryProvider()) {
|
if (this.shouldUseEsgInquiryProvider()) {
|
||||||
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
@@ -639,10 +741,15 @@ export class SandHubService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const requestUrl = `${baseUrl}/inquiry/policyByPlate`;
|
const requestUrl = `${baseUrl}/inquiry/policyByPlate`;
|
||||||
const live = await this.useLiveSandHubApis();
|
const live = await this.isInquiryLive("thirdPartyPlate", options);
|
||||||
const raw = live
|
const raw = live
|
||||||
? await this.makeEsgRequest(requestUrl, requestPayload)
|
? await this.makeEsgRequest(
|
||||||
: this.getDefaultMockPlateInquiryRaw();
|
requestUrl,
|
||||||
|
requestPayload,
|
||||||
|
"thirdPartyPlate",
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
: this.buildMockPlateInquiryRaw(ctx);
|
||||||
if (!live) {
|
if (!live) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`[MOCK] getEsgPolicyByPlateInquiry plate=${JSON.stringify(requestPayload)}`,
|
`[MOCK] getEsgPolicyByPlateInquiry plate=${JSON.stringify(requestPayload)}`,
|
||||||
@@ -663,10 +770,15 @@ export class SandHubService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const requestUrl = `${baseUrl}/block-inquiry-tejarat`;
|
const requestUrl = `${baseUrl}/block-inquiry-tejarat`;
|
||||||
const live = await this.useLiveSandHubApis();
|
const live = await this.isInquiryLive("thirdPartyPlate", options);
|
||||||
const raw = live
|
const raw = live
|
||||||
? await this.makeTejaratRequest(requestUrl, requestPayload)
|
? await this.makeTejaratRequest(
|
||||||
: this.getDefaultMockPlateInquiryRaw();
|
requestUrl,
|
||||||
|
requestPayload,
|
||||||
|
"thirdPartyPlate",
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
: this.buildMockPlateInquiryRaw(ctx);
|
||||||
if (!live) {
|
if (!live) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
|
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
|
||||||
@@ -680,7 +792,10 @@ export class SandHubService {
|
|||||||
return await this.sandHubDbService.findOneBySandHubId(sandHubId);
|
return await this.sandHubDbService.findOneBySandHubId(sandHubId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTejaratCarBodyInquiry(userDetail: SandHubDetailDto): Promise<{
|
async getTejaratCarBodyInquiry(
|
||||||
|
userDetail: SandHubDetailDto,
|
||||||
|
options?: SandHubInquiryOptions,
|
||||||
|
): Promise<{
|
||||||
raw: any;
|
raw: any;
|
||||||
mapped: Record<string, unknown>;
|
mapped: Record<string, unknown>;
|
||||||
}> {
|
}> {
|
||||||
@@ -696,39 +811,20 @@ export class SandHubService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const requestUrl = `${baseUrl}/block-inquiry-tejarat/badane`;
|
const requestUrl = `${baseUrl}/block-inquiry-tejarat/badane`;
|
||||||
const live = await this.useLiveSandHubApis();
|
const live = await this.isInquiryLive("carBodyPlate", options);
|
||||||
|
if (!live) {
|
||||||
let raw: any;
|
|
||||||
|
|
||||||
if (live) {
|
|
||||||
const token = await this.getTejaratAccessToken();
|
|
||||||
try {
|
|
||||||
const response = await firstValueFrom(
|
|
||||||
this.httpService.post(requestUrl, requestPayload, {
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
timeout: 30000,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
raw = response.data;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`[TEJARAT BADANE ERROR] Request failed for ${requestUrl}: ${error?.message || error}`,
|
|
||||||
error?.stack,
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Mock — reuse the default mock and adapt it to the car-body shape
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`[MOCK] getTejaratCarBodyInquiry plate=${JSON.stringify(requestPayload)}`,
|
`[MOCK] getTejaratCarBodyInquiry plate=${JSON.stringify(requestPayload)}`,
|
||||||
);
|
);
|
||||||
raw = this.getDefaultMockCarBodyInquiryRaw(userDetail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const raw = await this.makeTejaratRequest(
|
||||||
|
requestUrl,
|
||||||
|
requestPayload,
|
||||||
|
"carBodyPlate",
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
const mapped = this.mapCarBodyInquiryResponse(raw);
|
const mapped = this.mapCarBodyInquiryResponse(raw);
|
||||||
return { raw, mapped };
|
return { raw, mapped };
|
||||||
}
|
}
|
||||||
@@ -797,10 +893,24 @@ export class SandHubService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async makeSandHubRequest(url: string, payload: any, maxRetries = 3) {
|
private async makeSandHubRequest(
|
||||||
if (!(await this.useLiveSandHubApis())) {
|
url: string,
|
||||||
this.logger.log(`[MOCK] SandHub POST skipped: ${url}`);
|
payload: any,
|
||||||
return this.buildMockSandHubResponseForUrl(url, payload);
|
inquiryType: ExternalInquiryType,
|
||||||
|
options?: SandHubInquiryOptions,
|
||||||
|
maxRetries = 3,
|
||||||
|
) {
|
||||||
|
if (!(await this.isInquiryLive(inquiryType, options))) {
|
||||||
|
this.logger.log(`[MOCK] SandHub POST skipped (${inquiryType}): ${url}`);
|
||||||
|
const ctx = await this.mockCompanyContext(options);
|
||||||
|
if (inquiryType === "carBodyPlate") {
|
||||||
|
return this.buildMockInquiryRaw(inquiryType, ctx, payload);
|
||||||
|
}
|
||||||
|
const plateMock =
|
||||||
|
inquiryType === "thirdPartyPlate"
|
||||||
|
? this.buildMockPlateInquiryRaw(ctx)
|
||||||
|
: undefined;
|
||||||
|
return this.buildMockSandHubResponseForUrl(url, payload, plateMock);
|
||||||
}
|
}
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
const INITIAL_DELAY = 1000;
|
const INITIAL_DELAY = 1000;
|
||||||
@@ -909,7 +1019,10 @@ export class SandHubService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSandHubResponse(userDetail: SandHubDetailDto) {
|
async getSandHubResponse(
|
||||||
|
userDetail: SandHubDetailDto,
|
||||||
|
options?: SandHubInquiryOptions,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
leftTwoDigits: String(userDetail.plate.leftDigits),
|
leftTwoDigits: String(userDetail.plate.leftDigits),
|
||||||
@@ -922,13 +1035,19 @@ export class SandHubService {
|
|||||||
const requestUrl = `${base}/block-inquiry-tejarat`;
|
const requestUrl = `${base}/block-inquiry-tejarat`;
|
||||||
|
|
||||||
let response: any;
|
let response: any;
|
||||||
if (await this.useLiveSandHubApis()) {
|
if (await this.isInquiryLive("thirdPartyPlate", options)) {
|
||||||
response = await this.makeSandHubRequest(requestUrl, requestPayload);
|
response = await this.makeSandHubRequest(
|
||||||
|
requestUrl,
|
||||||
|
requestPayload,
|
||||||
|
"thirdPartyPlate",
|
||||||
|
options,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`[MOCK] getSandHubResponse plate=${JSON.stringify(requestPayload)}`,
|
`[MOCK] getSandHubResponse plate=${JSON.stringify(requestPayload)}`,
|
||||||
);
|
);
|
||||||
response = this.getDefaultMockPlateInquiryRaw();
|
const ctx = await this.mockCompanyContext(options);
|
||||||
|
response = this.buildMockPlateInquiryRaw(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = this.mapNewApiResponseToOldFormat(response);
|
const result = this.mapNewApiResponseToOldFormat(response);
|
||||||
@@ -948,7 +1067,11 @@ export class SandHubService {
|
|||||||
* - CLIENT_ID=8 (Parsian/ESG): Jalali birth date sent as-is to `/inquiry/person`.
|
* - CLIENT_ID=8 (Parsian/ESG): Jalali birth date sent as-is to `/inquiry/person`.
|
||||||
* - Other tenants: Tejarat/SandHub hub with Gregorian conversion.
|
* - Other tenants: Tejarat/SandHub hub with Gregorian conversion.
|
||||||
*/
|
*/
|
||||||
async getPersonalInquiry(nationalCode: string, birthDate: number | string) {
|
async getPersonalInquiry(
|
||||||
|
nationalCode: string,
|
||||||
|
birthDate: number | string,
|
||||||
|
options?: SandHubInquiryOptions,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
if (this.shouldUseEsgInquiryProvider()) {
|
if (this.shouldUseEsgInquiryProvider()) {
|
||||||
const jalaliBirthDate = this.normalizeJalaliBirthDateForEsg(birthDate);
|
const jalaliBirthDate = this.normalizeJalaliBirthDateForEsg(birthDate);
|
||||||
@@ -965,7 +1088,7 @@ export class SandHubService {
|
|||||||
dateHasPostfix: 0,
|
dateHasPostfix: 0,
|
||||||
};
|
};
|
||||||
const requestUrl = `${baseUrl}/inquiry/person`;
|
const requestUrl = `${baseUrl}/inquiry/person`;
|
||||||
const live = await this.useLiveSandHubApis();
|
const live = await this.isInquiryLive("personalIdentity", options);
|
||||||
|
|
||||||
if (!live) {
|
if (!live) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
@@ -974,7 +1097,12 @@ export class SandHubService {
|
|||||||
return this.getDefaultMockPersonInquiry(String(nationalCode));
|
return this.getDefaultMockPersonInquiry(String(nationalCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = await this.makeEsgRequest(requestUrl, requestPayload);
|
const raw = await this.makeEsgRequest(
|
||||||
|
requestUrl,
|
||||||
|
requestPayload,
|
||||||
|
"personalIdentity",
|
||||||
|
options,
|
||||||
|
);
|
||||||
return this.mapEsgPersonInquiryToOldFormat(raw);
|
return this.mapEsgPersonInquiryToOldFormat(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -995,6 +1123,8 @@ export class SandHubService {
|
|||||||
const response = await this.makeSandHubRequest(
|
const response = await this.makeSandHubRequest(
|
||||||
requestUrl,
|
requestUrl,
|
||||||
requestPayload,
|
requestPayload,
|
||||||
|
"personalIdentity",
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response?.message?.includes("err.record.not.found")) {
|
if (response?.message?.includes("err.record.not.found")) {
|
||||||
@@ -1017,6 +1147,7 @@ export class SandHubService {
|
|||||||
async getDrivingLicenseInfo(
|
async getDrivingLicenseInfo(
|
||||||
nationalCode: string,
|
nationalCode: string,
|
||||||
driverLicenseNumber: string,
|
driverLicenseNumber: string,
|
||||||
|
options?: SandHubInquiryOptions,
|
||||||
) {
|
) {
|
||||||
const requestUrl = `${process.env.SANHUB_BASE_URL}/driver-license-check`;
|
const requestUrl = `${process.env.SANHUB_BASE_URL}/driver-license-check`;
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
@@ -1032,6 +1163,8 @@ export class SandHubService {
|
|||||||
const response = await this.makeSandHubRequest(
|
const response = await this.makeSandHubRequest(
|
||||||
requestUrl,
|
requestUrl,
|
||||||
requestPayload,
|
requestPayload,
|
||||||
|
"drivingLicense",
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response?.data?.IsSucceed === false) {
|
if (response?.data?.IsSucceed === false) {
|
||||||
@@ -1055,7 +1188,11 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCarOwnershipInfo(plate: any, nationalCode: string) {
|
async getCarOwnershipInfo(
|
||||||
|
plate: any,
|
||||||
|
nationalCode: string,
|
||||||
|
options?: SandHubInquiryOptions,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const requestUrl = `${process.env.SANHUB_BASE_URL}/ownership`;
|
const requestUrl = `${process.env.SANHUB_BASE_URL}/ownership`;
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
@@ -1072,6 +1209,8 @@ export class SandHubService {
|
|||||||
const response = await this.makeSandHubRequest(
|
const response = await this.makeSandHubRequest(
|
||||||
requestUrl,
|
requestUrl,
|
||||||
requestPayload,
|
requestPayload,
|
||||||
|
"carOwnership",
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check the 'IsSuccess' field in the nested 'data' object.
|
// Check the 'IsSuccess' field in the nested 'data' object.
|
||||||
@@ -1091,7 +1230,11 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getShebaValidation(nationalId: string, shebaId: string) {
|
async getShebaValidation(
|
||||||
|
nationalId: string,
|
||||||
|
shebaId: string,
|
||||||
|
options?: SandHubInquiryOptions,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
if (this.shouldUseEsgInquiryProvider()) {
|
if (this.shouldUseEsgInquiryProvider()) {
|
||||||
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
||||||
@@ -1102,7 +1245,7 @@ export class SandHubService {
|
|||||||
sheba: String(shebaId),
|
sheba: String(shebaId),
|
||||||
};
|
};
|
||||||
const requestUrl = `${baseUrl}/inquiry/sheba`;
|
const requestUrl = `${baseUrl}/inquiry/sheba`;
|
||||||
const live = await this.useLiveSandHubApis();
|
const live = await this.isInquiryLive("sheba", options);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Validating Sheba ID via ESG for national code: ${nationalId}`,
|
`Validating Sheba ID via ESG for national code: ${nationalId}`,
|
||||||
@@ -1119,7 +1262,12 @@ export class SandHubService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = await this.makeEsgRequest(requestUrl, requestPayload);
|
const raw = await this.makeEsgRequest(
|
||||||
|
requestUrl,
|
||||||
|
requestPayload,
|
||||||
|
"sheba",
|
||||||
|
options,
|
||||||
|
);
|
||||||
const response = this.mapEsgShebaInquiryToOldFormat(raw);
|
const response = this.mapEsgShebaInquiryToOldFormat(raw);
|
||||||
|
|
||||||
if (response?.ReturnValue === false || response?.HasError === true) {
|
if (response?.ReturnValue === false || response?.HasError === true) {
|
||||||
@@ -1146,6 +1294,8 @@ export class SandHubService {
|
|||||||
const response = await this.makeSandHubRequest(
|
const response = await this.makeSandHubRequest(
|
||||||
requestUrl,
|
requestUrl,
|
||||||
requestPayload,
|
requestPayload,
|
||||||
|
"sheba",
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response?.ReturnValue === false || response?.HasError === true) {
|
if (response?.ReturnValue === false || response?.HasError === true) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Type } from "class-transformer";
|
|||||||
export class ExternalApisSettingsDto {
|
export class ExternalApisSettingsDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
"When true, SandHub/Tejarat inquiry HTTP APIs are called. When false, mocks are used and validations pass.",
|
"Master switch: when false, all inquiries use mocks regardless of per-insurer flags (`clients.settings.externalInquiries`). Manage granular flags via `/client/{clientId}/external-inquiries`.",
|
||||||
example: false,
|
example: false,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -159,6 +159,9 @@ export class DamageExpertModel {
|
|||||||
@Prop({ type: "string" })
|
@Prop({ type: "string" })
|
||||||
city: string;
|
city: string;
|
||||||
|
|
||||||
|
@Prop({type: "string", required: false})
|
||||||
|
expertCode?: string;
|
||||||
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export class ExpertModel {
|
|||||||
@Prop({ type: "string" })
|
@Prop({ type: "string" })
|
||||||
otp: string;
|
otp: string;
|
||||||
|
|
||||||
|
@Prop({type: "string", required: false})
|
||||||
|
expertCode?: string;
|
||||||
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export class FieldExpertModel {
|
|||||||
@Prop({ type: "string", default: "" })
|
@Prop({ type: "string", default: "" })
|
||||||
otp: string;
|
otp: string;
|
||||||
|
|
||||||
|
@Prop({type: "string", required: false})
|
||||||
|
expertCode?: string;
|
||||||
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export class InsurerExpertModel {
|
|||||||
@Prop()
|
@Prop()
|
||||||
address?: string;
|
address?: string;
|
||||||
|
|
||||||
|
@Prop({type: "string", required: false})
|
||||||
|
expertCode?: string;
|
||||||
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
export const InsurerExpertDbSchema =
|
export const InsurerExpertDbSchema =
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export class RegistrarModel {
|
|||||||
|
|
||||||
@Prop({ type: "string", default: "" })
|
@Prop({ type: "string", default: "" })
|
||||||
otp: string;
|
otp: string;
|
||||||
|
|
||||||
|
@Prop({type: "string", required: false})
|
||||||
|
expertCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RegistrarDbSchema = SchemaFactory.createForClass(RegistrarModel);
|
export const RegistrarDbSchema = SchemaFactory.createForClass(RegistrarModel);
|
||||||
|
|||||||
Reference in New Issue
Block a user