Compare commits

...

13 Commits

Author SHA1 Message Date
0442d04f20 Merge pull request 'Fixed smsApiKey index error and err handling in ESG' (#142) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#142
2026-06-21 12:19:15 +03:30
SepehrYahyaee
d0e7694374 Fixed smsApiKey index error and err handling in ESG 2026-06-21 12:18:08 +03:30
570fa865de Merge pull request 'Fix expert codes' (#141) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#141
2026-06-20 16:36:41 +03:30
SepehrYahyaee
8741d2ba82 Fix expert codes 2026-06-20 16:30:57 +03:30
7b53c98791 Merge pull request 'Fixed mock data' (#140) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#140
2026-06-20 15:49:27 +03:30
SepehrYahyaee
59eddb8e0e Fixed mock data 2026-06-20 15:48:39 +03:30
4e20fc5c96 Merge pull request 'YARA-1034, YARA-1035' (#139) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#139
2026-06-20 14:52:23 +03:30
SepehrYahyaee
4fabed77e5 YARA-1035 2026-06-20 14:51:01 +03:30
SepehrYahyaee
2e4b10455b YARA-1034 2026-06-20 14:30:29 +03:30
1bbf0de960 Merge pull request 'Added common utils' (#138) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#138
2026-06-20 12:05:15 +03:30
SepehrYahyaee
15fcb011aa Added common utils 2026-06-20 12:04:51 +03:30
2c52c14e03 Merge pull request 'Fixed mismatched userId on field expert for claim' (#137) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#137
2026-06-20 11:55:34 +03:30
SepehrYahyaee
5a89a0ff16 Fixed mismatched userId on field expert for claim 2026-06-20 11:53:18 +03:30
54 changed files with 1540 additions and 342 deletions

View File

@@ -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",

View File

@@ -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({

View File

@@ -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,
),
} }
: {}), : {}),
}, },

View 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);
}
}

View File

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

View File

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

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

View File

@@ -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 {

View File

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

View File

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

View 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: "بیمه سامان",
});
});
});

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

View File

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

View File

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

View 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";

View File

@@ -1,2 +0,0 @@
export { UserRegisterDto } from "./user-register.dto";
export { UserLoginDto } from "./user-login.dto";

View File

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

View File

@@ -1 +1,2 @@
export { JwtPayload } from "./payload.types"; export { JwtPayload } from "./payload.types";
export { PasswordHash } from "./password.types";

View File

@@ -0,0 +1,8 @@
export interface PasswordHash {
hash: string;
salt: string;
algorithm: string;
version: number;
params: Record<string, unknown>;
createdAt: Date;
}

View File

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

View 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 ?? {}) };
}

View File

@@ -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()

View File

@@ -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(),

View File

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

View File

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

View 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 {};
}
}

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

View File

@@ -1 +1,2 @@
export { HashService } from "./hash.provider"; export { HashService } from "./hash.provider";
export { OtpService } from "./otp.provider";

View 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");
}
}

View File

@@ -0,0 +1,2 @@
export { UserRegisterDto } from "./requests/user-register.dto";
export { UserLoginDto } from "./requests/user-login.dto";

View File

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

View File

@@ -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" })

View File

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

View File

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

View File

@@ -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],

View File

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

View File

@@ -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 } : {}),
};
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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 },
]), ]),

View 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");
});
});

View File

@@ -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) {

View File

@@ -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()

View File

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

View File

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

View File

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

View File

@@ -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 =

View File

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