forked from Yara724/api
Merge pull request 'YARA-1034, YARA-1035' (#139) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#139
This commit is contained in:
@@ -31,7 +31,8 @@
|
||||
"branchName": "شعبه غرب تهران(210050)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||
"expertCode": "4645"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0066868521",
|
||||
@@ -40,7 +41,8 @@
|
||||
"lastName": "گودرزي پور",
|
||||
"branchCode": "210050",
|
||||
"branchName": "شعبه غرب تهران(210050)",
|
||||
"title": "كارشناس ارزياب خسارت بدنه"
|
||||
"title": "كارشناس ارزياب خسارت بدنه",
|
||||
"expertCode": "16"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0076988961",
|
||||
@@ -60,7 +62,8 @@
|
||||
"lastName": "شاملوفرد",
|
||||
"branchCode": "210050",
|
||||
"branchName": "شعبه غرب تهران(210050)",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||
"expertCode": "72"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0083730397",
|
||||
@@ -80,7 +83,8 @@
|
||||
"lastName": "کرکي",
|
||||
"branchCode": "210050",
|
||||
"branchName": "شعبه غرب تهران(210050)",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||
"expertCode": "29"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0440245151",
|
||||
@@ -100,7 +104,8 @@
|
||||
"branchName": "شعبه غرب تهران(210050)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||
"expertCode": "3545"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0670358118",
|
||||
|
||||
@@ -33,6 +33,7 @@ type FieldExpertSeed = {
|
||||
city?: string;
|
||||
state?: string;
|
||||
title?: string;
|
||||
expertCode?: string;
|
||||
};
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
@@ -172,6 +173,7 @@ const FieldExpertSchema = new Schema(
|
||||
phone: { type: String },
|
||||
role: { type: String, default: "field_expert" },
|
||||
otp: { type: String, default: "" },
|
||||
expertCode: { type: String, required: false },
|
||||
},
|
||||
{ collection: "field-expert", versionKey: false, timestamps: true },
|
||||
);
|
||||
@@ -273,6 +275,7 @@ async function main() {
|
||||
mobile: expert.mobile,
|
||||
role: "field_expert",
|
||||
otp: "",
|
||||
expertCode: expert.expertCode,
|
||||
};
|
||||
|
||||
const existing = await FieldExpert.findOne({
|
||||
|
||||
97
src/client/client-external-inquiries.controller.ts
Normal file
97
src/client/client-external-inquiries.controller.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Put,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { ClientService } from "./client.service";
|
||||
import {
|
||||
ClientExternalInquiriesCatalogDto,
|
||||
ClientExternalInquiriesListDto,
|
||||
ClientExternalInquiriesViewDto,
|
||||
ExternalInquiryFlagsDto,
|
||||
UpdateClientExternalInquiriesDto,
|
||||
} from "./dto/client-external-inquiries.dto";
|
||||
|
||||
/**
|
||||
* Per-insurer external inquiry toggles (public for now — lock down when super-admin exists).
|
||||
*/
|
||||
@ApiTags("client-external-inquiries")
|
||||
@Controller("client")
|
||||
export class ClientExternalInquiriesController {
|
||||
constructor(private readonly clientService: ClientService) {}
|
||||
|
||||
@Get("external-inquiries/catalog")
|
||||
@ApiOperation({
|
||||
summary: "List supported external inquiry kinds and API paths",
|
||||
description: "No auth (temporary). Describes global master switch + per-client flags.",
|
||||
})
|
||||
@ApiResponse({ status: 200, type: ClientExternalInquiriesCatalogDto })
|
||||
getCatalog(): ClientExternalInquiriesCatalogDto {
|
||||
return this.clientService.getExternalInquiriesCatalog();
|
||||
}
|
||||
|
||||
@Get("external-inquiries")
|
||||
@ApiOperation({
|
||||
summary: "List external inquiry settings for all insurers",
|
||||
description:
|
||||
"No auth (temporary). Returns stored flags and effective live flags (after global master switch).",
|
||||
})
|
||||
@ApiResponse({ status: 200, type: ClientExternalInquiriesListDto })
|
||||
listAll(): Promise<ClientExternalInquiriesListDto> {
|
||||
return this.clientService.listExternalInquirySettings();
|
||||
}
|
||||
|
||||
@Get(":clientId/external-inquiries")
|
||||
@ApiOperation({
|
||||
summary: "Get external inquiry settings for one insurer",
|
||||
description: "No auth (temporary).",
|
||||
})
|
||||
@ApiParam({ name: "clientId", description: "Insurer client Mongo ObjectId" })
|
||||
@ApiResponse({ status: 200, type: ClientExternalInquiriesViewDto })
|
||||
getOne(
|
||||
@Param("clientId") clientId: string,
|
||||
): Promise<ClientExternalInquiriesViewDto> {
|
||||
return this.clientService.getExternalInquirySettings(clientId);
|
||||
}
|
||||
|
||||
@Put(":clientId/external-inquiries")
|
||||
@ApiOperation({
|
||||
summary: "Replace external inquiry flags for one insurer",
|
||||
description:
|
||||
"No auth (temporary). Omitted inquiry keys default to `false` (mock). Global master switch still applies at runtime.",
|
||||
})
|
||||
@ApiParam({ name: "clientId", description: "Insurer client Mongo ObjectId" })
|
||||
@ApiBody({ type: ExternalInquiryFlagsDto })
|
||||
@ApiResponse({ status: 200, type: ClientExternalInquiriesViewDto })
|
||||
replace(
|
||||
@Param("clientId") clientId: string,
|
||||
@Body() body: ExternalInquiryFlagsDto,
|
||||
): Promise<ClientExternalInquiriesViewDto> {
|
||||
return this.clientService.replaceExternalInquirySettings(clientId, body);
|
||||
}
|
||||
|
||||
@Patch(":clientId/external-inquiries")
|
||||
@ApiOperation({
|
||||
summary: "Partially update external inquiry flags for one insurer",
|
||||
description: "No auth (temporary). Only supplied flags are changed.",
|
||||
})
|
||||
@ApiParam({ name: "clientId", description: "Insurer client Mongo ObjectId" })
|
||||
@ApiBody({ type: UpdateClientExternalInquiriesDto })
|
||||
@ApiResponse({ status: 200, type: ClientExternalInquiriesViewDto })
|
||||
patch(
|
||||
@Param("clientId") clientId: string,
|
||||
@Body() body: UpdateClientExternalInquiriesDto,
|
||||
): Promise<ClientExternalInquiriesViewDto> {
|
||||
return this.clientService.patchExternalInquirySettings(clientId, body);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import { Module } from "@nestjs/common";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
|
||||
import { ClientController } from "./client.controller";
|
||||
import { ClientExternalInquiriesController } from "./client-external-inquiries.controller";
|
||||
import { ClientPanelController } from "./client-panel.controller";
|
||||
import { ClientService } from "./client.service";
|
||||
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
|
||||
import { BranchDbService } from "./entities/db-service/branch.db.service";
|
||||
import { ClientDbService } from "./entities/db-service/client.db.service";
|
||||
import { BranchModel, BranchSchema } from "./entities/schema/branch.schema";
|
||||
@@ -20,8 +22,12 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema";
|
||||
},
|
||||
]),
|
||||
],
|
||||
controllers: [ClientController, ClientPanelController],
|
||||
providers: [ClientService, ClientDbService, BranchDbService],
|
||||
exports: [ClientService, ClientDbService, BranchDbService],
|
||||
controllers: [
|
||||
ClientController,
|
||||
ClientPanelController,
|
||||
ClientExternalInquiriesController,
|
||||
],
|
||||
providers: [ClientService, ClientDbService, BranchDbService, ExternalInquirySettingsService],
|
||||
exports: [ClientService, ClientDbService, BranchDbService, ExternalInquirySettingsService],
|
||||
})
|
||||
export class ClientModule {}
|
||||
|
||||
@@ -17,6 +17,19 @@ import {
|
||||
} from "src/client/dto/client-settings.dto";
|
||||
import type { ClientMediaLimits } from "./entities/schema/client.schema";
|
||||
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
|
||||
@@ -94,7 +107,11 @@ const MEDIA_KINDS: MediaKind[] = ["video", "image", "voice"];
|
||||
|
||||
@Injectable()
|
||||
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> {
|
||||
try {
|
||||
const newClient = await this.clientDbService.create({
|
||||
@@ -393,4 +410,74 @@ export class ClientService {
|
||||
|
||||
return this.getPanelSettings(client._id);
|
||||
}
|
||||
|
||||
getExternalInquiriesCatalog(): ClientExternalInquiriesCatalogDto {
|
||||
return {
|
||||
inquiryTypes: [...EXTERNAL_INQUIRY_TYPES],
|
||||
globalSettingPath: "/system-settings (PATCH externalApis.sandHubUseLiveApi)",
|
||||
perClientSettingPath: "/client/{clientId}/external-inquiries",
|
||||
};
|
||||
}
|
||||
|
||||
async listExternalInquirySettings(): Promise<ClientExternalInquiriesListDto> {
|
||||
const global = await this.systemSettingsService.isSandHubLiveEnabled();
|
||||
const clients = await this.clientDbService.findAll();
|
||||
return {
|
||||
items: clients.map((c) => toClientExternalInquiriesView(c, global)),
|
||||
};
|
||||
}
|
||||
|
||||
async getExternalInquirySettings(
|
||||
clientKey: string | Types.ObjectId,
|
||||
): Promise<ClientExternalInquiriesViewDto> {
|
||||
const client = await this.loadClientOrThrow(clientKey);
|
||||
const global = await this.systemSettingsService.isSandHubLiveEnabled();
|
||||
return toClientExternalInquiriesView(client, global);
|
||||
}
|
||||
|
||||
async replaceExternalInquirySettings(
|
||||
clientKey: string | Types.ObjectId,
|
||||
body: UpdateClientExternalInquiriesDto,
|
||||
): Promise<ClientExternalInquiriesViewDto> {
|
||||
const client = await this.loadClientOrThrow(clientKey);
|
||||
const flags = mergeExternalInquiryFlags(body as Partial<Record<string, boolean>>);
|
||||
const $set: Record<string, unknown> = {};
|
||||
for (const key of EXTERNAL_INQUIRY_TYPES) {
|
||||
$set[`settings.externalInquiries.${key}`] = flags[key];
|
||||
}
|
||||
const updated = await this.clientDbService.findByIdAndUpdate(
|
||||
client._id.toString(),
|
||||
{ $set },
|
||||
);
|
||||
if (!updated) throw new NotFoundException("Client not found");
|
||||
this.externalInquirySettingsService.invalidateClientCache(
|
||||
client._id.toString(),
|
||||
);
|
||||
return this.getExternalInquirySettings(client._id);
|
||||
}
|
||||
|
||||
async patchExternalInquirySettings(
|
||||
clientKey: string | Types.ObjectId,
|
||||
body: UpdateClientExternalInquiriesDto,
|
||||
): Promise<ClientExternalInquiriesViewDto> {
|
||||
const client = await this.loadClientOrThrow(clientKey);
|
||||
const $set: Record<string, unknown> = {};
|
||||
for (const key of EXTERNAL_INQUIRY_TYPES) {
|
||||
if (body[key] !== undefined) {
|
||||
$set[`settings.externalInquiries.${key}`] = body[key];
|
||||
}
|
||||
}
|
||||
if (Object.keys($set).length === 0) {
|
||||
throw new BadRequestException("No external inquiry flags to update.");
|
||||
}
|
||||
const updated = await this.clientDbService.findByIdAndUpdate(
|
||||
client._id.toString(),
|
||||
{ $set },
|
||||
);
|
||||
if (!updated) throw new NotFoundException("Client not found");
|
||||
this.externalInquirySettingsService.invalidateClientCache(
|
||||
client._id.toString(),
|
||||
);
|
||||
return this.getExternalInquirySettings(client._id);
|
||||
}
|
||||
}
|
||||
|
||||
154
src/client/dto/client-external-inquiries.dto.ts
Normal file
154
src/client/dto/client-external-inquiries.dto.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
|
||||
import {
|
||||
EXTERNAL_INQUIRY_TYPES,
|
||||
ExternalInquiryFlags,
|
||||
mergeExternalInquiryFlags,
|
||||
} from "src/common/types/external-inquiry.types";
|
||||
import { IsBoolean, IsMongoId, IsOptional, ValidateNested } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
export class ExternalInquiryFlagsDto implements ExternalInquiryFlags {
|
||||
@ApiProperty({
|
||||
description: "Third-party plate / policy block inquiry (Tejarat or ESG policyByPlate).",
|
||||
example: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
thirdPartyPlate: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: "CAR_BODY (badane) plate inquiry.",
|
||||
example: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
carBodyPlate: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Personal identity inquiry (national code + birth date).",
|
||||
example: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
personalIdentity: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Sheba account validation.",
|
||||
example: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
sheba: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Driving licence check.",
|
||||
example: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
drivingLicense: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Vehicle ownership validation.",
|
||||
example: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
carOwnership: boolean;
|
||||
}
|
||||
|
||||
export class UpdateClientExternalInquiriesDto extends PartialType(
|
||||
ExternalInquiryFlagsDto,
|
||||
) {}
|
||||
|
||||
export class ClientExternalInquiriesViewDto {
|
||||
@ApiProperty({ example: "664a1b2c3d4e5f6789012345" })
|
||||
clientId: string;
|
||||
|
||||
@ApiProperty({ example: 8 })
|
||||
clientCode: number;
|
||||
|
||||
@ApiProperty({ example: "بیمه پارسیان" })
|
||||
clientName: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Global master switch from `system_settings.externalApis.sandHubUseLiveApi`. When false, all inquiries use mocks regardless of these flags.",
|
||||
})
|
||||
globalSandHubLiveEnabled: boolean;
|
||||
|
||||
@ApiProperty({ type: ExternalInquiryFlagsDto })
|
||||
externalInquiries: ExternalInquiryFlags;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Effective live flags after applying the global master switch.",
|
||||
type: ExternalInquiryFlagsDto,
|
||||
})
|
||||
effectiveLive: ExternalInquiryFlags;
|
||||
}
|
||||
|
||||
export class ClientExternalInquiriesListDto {
|
||||
@ApiProperty({ type: [ClientExternalInquiriesViewDto] })
|
||||
items: ClientExternalInquiriesViewDto[];
|
||||
}
|
||||
|
||||
export class ClientExternalInquiriesCatalogDto {
|
||||
@ApiProperty({
|
||||
enum: EXTERNAL_INQUIRY_TYPES,
|
||||
isArray: true,
|
||||
description: "Supported inquiry kinds stored on each client document.",
|
||||
})
|
||||
inquiryTypes: readonly string[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Global master switch path: PATCH /system-settings or PATCH /client/external-inquiries-live",
|
||||
})
|
||||
globalSettingPath: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Per-insurer CRUD base path (public for now).",
|
||||
example: "/client/{clientId}/external-inquiries",
|
||||
})
|
||||
perClientSettingPath: string;
|
||||
}
|
||||
|
||||
/** Build a view DTO from a lean client document. */
|
||||
export function toClientExternalInquiriesView(
|
||||
client: {
|
||||
_id?: unknown;
|
||||
clientCode?: number;
|
||||
clientName?: { persian?: string; english?: string } | string;
|
||||
settings?: { externalInquiries?: Partial<ExternalInquiryFlags> };
|
||||
},
|
||||
globalSandHubLiveEnabled: boolean,
|
||||
): ClientExternalInquiriesViewDto {
|
||||
const flags = mergeExternalInquiryFlags(client.settings?.externalInquiries);
|
||||
const effectiveLive = {} as ExternalInquiryFlags;
|
||||
for (const key of EXTERNAL_INQUIRY_TYPES) {
|
||||
effectiveLive[key] = globalSandHubLiveEnabled && flags[key] === true;
|
||||
}
|
||||
const name =
|
||||
typeof client.clientName === "string"
|
||||
? client.clientName
|
||||
: client.clientName?.persian ||
|
||||
client.clientName?.english ||
|
||||
String(client.clientCode ?? "");
|
||||
|
||||
return {
|
||||
clientId: String(client._id ?? ""),
|
||||
clientCode: Number(client.clientCode ?? 0),
|
||||
clientName: name,
|
||||
globalSandHubLiveEnabled,
|
||||
externalInquiries: flags,
|
||||
effectiveLive,
|
||||
};
|
||||
}
|
||||
|
||||
export class ClientIdParamDto {
|
||||
@ApiProperty({ description: "Mongo ObjectId of the insurer client document" })
|
||||
@IsMongoId()
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
/** Optional nested patch used internally when validating partial bodies. */
|
||||
export class ExternalInquiryFlagsPatchDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => UpdateClientExternalInquiriesDto)
|
||||
externalInquiries?: UpdateClientExternalInquiriesDto;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import type { ExternalInquiryFlags } from "src/common/types/external-inquiry.types";
|
||||
|
||||
export type ClientDocument = ClientModel & Document;
|
||||
|
||||
@@ -37,6 +38,30 @@ export class ClientMediaSettings {
|
||||
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
|
||||
* `ClientService` with documented defaults so older client documents keep
|
||||
@@ -57,6 +82,10 @@ export class ClientSettings {
|
||||
*/
|
||||
@Prop({ type: ClientMediaSettings, required: false })
|
||||
media?: ClientMediaSettings;
|
||||
|
||||
/** Per-inquiry live/mock toggles (see {@link ClientExternalInquirySettings}). */
|
||||
@Prop({ type: ClientExternalInquirySettings, required: false })
|
||||
externalInquiries?: ClientExternalInquirySettings;
|
||||
}
|
||||
|
||||
@Schema({ collection: "clients", versionKey: false })
|
||||
|
||||
83
src/client/external-inquiry-settings.service.spec.ts
Normal file
83
src/client/external-inquiry-settings.service.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Types } from "mongoose";
|
||||
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
|
||||
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||
|
||||
describe("ExternalInquirySettingsService", () => {
|
||||
const systemSettings = {
|
||||
isSandHubLiveEnabled: jest.fn(),
|
||||
};
|
||||
const clientDb = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
let service: ExternalInquirySettingsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env.CLIENT_ID;
|
||||
delete process.env.CLIENT_NAME;
|
||||
service = new ExternalInquirySettingsService(
|
||||
systemSettings as unknown as SystemSettingsService,
|
||||
clientDb as any,
|
||||
);
|
||||
service.invalidateClientCache();
|
||||
});
|
||||
|
||||
it("returns false when global master switch is off", async () => {
|
||||
systemSettings.isSandHubLiveEnabled.mockResolvedValue(false);
|
||||
clientDb.findOne.mockResolvedValue({
|
||||
clientCode: 8,
|
||||
settings: { externalInquiries: { sheba: true } },
|
||||
});
|
||||
|
||||
await expect(service.isInquiryLive("sheba", "client-id")).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true only when global and per-insurer flags are on", async () => {
|
||||
const clientId = new Types.ObjectId().toString();
|
||||
systemSettings.isSandHubLiveEnabled.mockResolvedValue(true);
|
||||
clientDb.findOne.mockResolvedValue({
|
||||
clientCode: 8,
|
||||
clientName: { persian: "بیمه پارسیان" },
|
||||
settings: {
|
||||
externalInquiries: {
|
||||
thirdPartyPlate: true,
|
||||
sheba: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.isInquiryLive("thirdPartyPlate", clientId),
|
||||
).resolves.toBe(true);
|
||||
await expect(service.isInquiryLive("sheba", clientId)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses client document for mock company context", async () => {
|
||||
const clientId = new Types.ObjectId().toString();
|
||||
clientDb.findOne.mockResolvedValue({
|
||||
clientCode: 8,
|
||||
clientName: { persian: "بیمه پارسیان" },
|
||||
});
|
||||
|
||||
await expect(service.getMockCompanyContext(clientId)).resolves.toEqual({
|
||||
companyId: "8",
|
||||
companyName: "بیمه پارسیان",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to deployment env when client is missing", async () => {
|
||||
process.env.CLIENT_ID = "15";
|
||||
process.env.CLIENT_NAME = "بیمه سامان";
|
||||
clientDb.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getMockCompanyContext()).resolves.toEqual({
|
||||
companyId: "15",
|
||||
companyName: "بیمه سامان",
|
||||
});
|
||||
});
|
||||
});
|
||||
120
src/client/external-inquiry-settings.service.ts
Normal file
120
src/client/external-inquiry-settings.service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
DEFAULT_EXTERNAL_INQUIRY_FLAGS,
|
||||
ExternalInquiryFlags,
|
||||
ExternalInquiryType,
|
||||
mergeExternalInquiryFlags,
|
||||
MockInquiryCompanyContext,
|
||||
} from "src/common/types/external-inquiry.types";
|
||||
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||
import { ClientDbService } from "./entities/db-service/client.db.service";
|
||||
|
||||
@Injectable()
|
||||
export class ExternalInquirySettingsService {
|
||||
private readonly logger = new Logger(ExternalInquirySettingsService.name);
|
||||
private clientCache = new Map<string, { doc: any; at: number }>();
|
||||
private readonly cacheTtlMs = 15_000;
|
||||
|
||||
constructor(
|
||||
private readonly systemSettingsService: SystemSettingsService,
|
||||
private readonly clientDbService: ClientDbService,
|
||||
) {}
|
||||
|
||||
private cacheKey(ref?: string | Types.ObjectId | null): string {
|
||||
if (ref != null && String(ref).trim()) return `id:${String(ref)}`;
|
||||
const code = process.env.CLIENT_ID;
|
||||
return code ? `env:${code}` : "env:default";
|
||||
}
|
||||
|
||||
private async loadClient(
|
||||
clientRef?: string | Types.ObjectId | null,
|
||||
): Promise<any | null> {
|
||||
const key = this.cacheKey(clientRef);
|
||||
const cached = this.clientCache.get(key);
|
||||
const now = Date.now();
|
||||
if (cached && now - cached.at < this.cacheTtlMs) {
|
||||
return cached.doc;
|
||||
}
|
||||
|
||||
let doc: any = null;
|
||||
if (clientRef != null && Types.ObjectId.isValid(String(clientRef))) {
|
||||
doc = await this.clientDbService.findOne({
|
||||
_id: new Types.ObjectId(String(clientRef)),
|
||||
});
|
||||
}
|
||||
if (!doc) {
|
||||
const code = Number(process.env.CLIENT_ID);
|
||||
if (Number.isFinite(code)) {
|
||||
doc = await this.clientDbService.findOne({ clientCode: code });
|
||||
}
|
||||
}
|
||||
|
||||
this.clientCache.set(key, { doc, at: now });
|
||||
return doc;
|
||||
}
|
||||
|
||||
invalidateClientCache(clientId?: string): void {
|
||||
if (clientId) {
|
||||
this.clientCache.delete(`id:${clientId}`);
|
||||
} else {
|
||||
this.clientCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async getFlagsForClient(
|
||||
clientRef?: string | Types.ObjectId | null,
|
||||
): Promise<ExternalInquiryFlags> {
|
||||
const client = await this.loadClient(clientRef);
|
||||
return mergeExternalInquiryFlags(client?.settings?.externalInquiries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live HTTP when global master switch is on AND the per-insurer flag is true.
|
||||
*/
|
||||
async isInquiryLive(
|
||||
type: ExternalInquiryType,
|
||||
clientRef?: string | Types.ObjectId | null,
|
||||
): Promise<boolean> {
|
||||
if (!(await this.systemSettingsService.isSandHubLiveEnabled())) {
|
||||
return false;
|
||||
}
|
||||
const flags = await this.getFlagsForClient(clientRef);
|
||||
return flags[type] === true;
|
||||
}
|
||||
|
||||
/** Company fields injected into mocked plate/car-body inquiry payloads. */
|
||||
async getMockCompanyContext(
|
||||
clientRef?: string | Types.ObjectId | null,
|
||||
): Promise<MockInquiryCompanyContext> {
|
||||
const client = await this.loadClient(clientRef);
|
||||
if (client) {
|
||||
const name =
|
||||
typeof client.clientName === "string"
|
||||
? client.clientName
|
||||
: client.clientName?.persian ||
|
||||
client.clientName?.english ||
|
||||
"";
|
||||
return {
|
||||
companyId: String(client.clientCode ?? process.env.CLIENT_ID ?? "15"),
|
||||
companyName: name || process.env.CLIENT_NAME || "بیمه",
|
||||
};
|
||||
}
|
||||
return {
|
||||
companyId: String(process.env.CLIENT_ID ?? "15"),
|
||||
companyName: process.env.CLIENT_NAME ?? "بیمه سامان",
|
||||
};
|
||||
}
|
||||
|
||||
async getEffectiveLiveFlags(
|
||||
clientRef?: string | Types.ObjectId | null,
|
||||
): Promise<ExternalInquiryFlags> {
|
||||
const global = await this.systemSettingsService.isSandHubLiveEnabled();
|
||||
const flags = await this.getFlagsForClient(clientRef);
|
||||
const effective = { ...DEFAULT_EXTERNAL_INQUIRY_FLAGS };
|
||||
for (const key of Object.keys(effective) as ExternalInquiryType[]) {
|
||||
effective[key] = global && flags[key] === true;
|
||||
}
|
||||
return effective;
|
||||
}
|
||||
}
|
||||
37
src/common/types/external-inquiry.types.ts
Normal file
37
src/common/types/external-inquiry.types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/** External inquiry kinds toggled per insurer (`clients.settings.externalInquiries`). */
|
||||
export const EXTERNAL_INQUIRY_TYPES = [
|
||||
"thirdPartyPlate",
|
||||
"carBodyPlate",
|
||||
"personalIdentity",
|
||||
"sheba",
|
||||
"drivingLicense",
|
||||
"carOwnership",
|
||||
] as const;
|
||||
|
||||
export type ExternalInquiryType = (typeof EXTERNAL_INQUIRY_TYPES)[number];
|
||||
|
||||
/** Safe default: mock/off until explicitly enabled for an insurer. */
|
||||
export const DEFAULT_EXTERNAL_INQUIRY_FLAGS: Record<
|
||||
ExternalInquiryType,
|
||||
boolean
|
||||
> = {
|
||||
thirdPartyPlate: false,
|
||||
carBodyPlate: false,
|
||||
personalIdentity: false,
|
||||
sheba: false,
|
||||
drivingLicense: false,
|
||||
carOwnership: false,
|
||||
};
|
||||
|
||||
export type ExternalInquiryFlags = Record<ExternalInquiryType, boolean>;
|
||||
|
||||
export interface MockInquiryCompanyContext {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
export function mergeExternalInquiryFlags(
|
||||
partial?: Partial<ExternalInquiryFlags> | null,
|
||||
): ExternalInquiryFlags {
|
||||
return { ...DEFAULT_EXTERNAL_INQUIRY_FLAGS, ...(partial ?? {}) };
|
||||
}
|
||||
21
src/features/auth/auth.controller.ts
Normal file
21
src/features/auth/auth.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Body, Controller, Post } from "@nestjs/common";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { Public } from "src/common/auth/decorators";
|
||||
import { UserLoginDto, UserRegisterDto } from "../user/dtos";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post("user/send-otp")
|
||||
async register(@Body() userRegisterDto: UserRegisterDto) {
|
||||
return await this.authService.registerUser(userRegisterDto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("user/login")
|
||||
async login(@Body() userLoginDto: UserLoginDto) {
|
||||
return await this.authService.loginUser(userLoginDto);
|
||||
}
|
||||
}
|
||||
33
src/features/auth/auth.module.ts
Normal file
33
src/features/auth/auth.module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { StringValue } from "ms";
|
||||
import { AuthGuard, RolesGuard } from "src/common/auth/guards";
|
||||
import { HashService, OtpService } from "./providers";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.registerAsync({
|
||||
global: true,
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>("JWT_SECRET"),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<StringValue>("JWT_EXPIRY"),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||
AuthService,
|
||||
HashService,
|
||||
OtpService,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
98
src/features/auth/auth.service.ts
Normal file
98
src/features/auth/auth.service.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { UserRepository } from "src/features/user/repositories";
|
||||
import { HashService, OtpService } from "./providers";
|
||||
import { UserLoginDto, UserRegisterDto } from "../user/dtos";
|
||||
import { Otp } from "../user/schemas";
|
||||
import { Environment } from "src/core/config/config.schema";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
otpDigits: number;
|
||||
otpExpiryMinutes: number;
|
||||
environment: Environment;
|
||||
|
||||
constructor(
|
||||
private readonly userRepo: UserRepository,
|
||||
private readonly hashService: HashService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly otpService: OtpService,
|
||||
) {
|
||||
this.otpDigits = this.configService.get<number>("DEFAULT_OTP_DIGITS") ?? 5;
|
||||
this.otpExpiryMinutes =
|
||||
(this.configService.get<number>("DEFAULT_OTP_EXPIRY_MINUTES") ?? 2) * 60;
|
||||
this.environment = this.configService.get<Environment>("NODE_ENV");
|
||||
}
|
||||
|
||||
async registerUser(dto: UserRegisterDto) {
|
||||
let user = await this.userRepo.retrieveByCellPhoneNumber(
|
||||
dto.cellphoneNumber,
|
||||
);
|
||||
|
||||
if (user && new Date() < new Date(user.otp.expiresAt))
|
||||
throw new BadRequestException("OTP_STILL_VALID");
|
||||
|
||||
if (!user) {
|
||||
user = await this.userRepo.create(dto.cellphoneNumber);
|
||||
}
|
||||
|
||||
const otpCode = this.otpService.generateOtp(this.otpDigits);
|
||||
const hashedOtp = await this.hashService.hashOtp(otpCode);
|
||||
const otp: Otp = {
|
||||
hash: hashedOtp,
|
||||
expiresAt: new Date(Date.now() + this.otpExpiryMinutes),
|
||||
};
|
||||
|
||||
await this.userRepo.saveOtp(user.cellphoneNumber, otp);
|
||||
|
||||
if (this.environment === "development") {
|
||||
return otpCode;
|
||||
}
|
||||
//TODO: Implement Integrations module and integrate with kavenegar, or whatever the provider is
|
||||
//TODO: Send sms
|
||||
return {};
|
||||
}
|
||||
|
||||
async loginUser(userLoginDto: UserLoginDto) {
|
||||
const user = await this.userRepo.retrieveByCellPhoneNumber(
|
||||
userLoginDto.cellphoneNumber,
|
||||
);
|
||||
if (!user) throw new BadRequestException("USER_NOT_FOUND");
|
||||
|
||||
const storedOtp = (user as any).otp;
|
||||
if (!storedOtp) {
|
||||
throw new BadRequestException("OTP_NOT_REQUESTED");
|
||||
}
|
||||
|
||||
// Check expiry first — don't increment attempts on expired OTP
|
||||
if (new Date() > new Date(storedOtp.expiresAt)) {
|
||||
throw new BadRequestException("OTP_EXPIRED");
|
||||
}
|
||||
|
||||
// Check attempt count before verifying — prevent brute force
|
||||
if (storedOtp.attempts >= this.otpService.maximum_otp_attemps) {
|
||||
throw new ForbiddenException("OTP_MAX_ATTEMPTS_EXCEEDED");
|
||||
}
|
||||
|
||||
const isValid = await this.hashService.verifyOtp(
|
||||
userLoginDto.otp,
|
||||
storedOtp.hash,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
await this.userRepo.incrementOtpAttempts(userLoginDto.cellphoneNumber);
|
||||
throw new BadRequestException("OTP_INVALID");
|
||||
}
|
||||
|
||||
// OTP is correct — clear it so it can't be reused
|
||||
await this.userRepo.clearOtp(userLoginDto.cellphoneNumber);
|
||||
|
||||
//TODO: Generate access+refresh token and send it to user
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
167
src/features/auth/providers/hash.provider.ts
Normal file
167
src/features/auth/providers/hash.provider.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
BinaryLike,
|
||||
randomBytes,
|
||||
scrypt,
|
||||
ScryptOptions,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
CURRENT_ALGORITHM,
|
||||
CURRENT_VERSION,
|
||||
KEY_LENGTH,
|
||||
KEY_LENGTH_FOR_OTP,
|
||||
SCRYPT_PARAMS,
|
||||
SCRYPT_PARAMS_FOR_OTP,
|
||||
} from "src/common/auth/constants";
|
||||
import { PasswordHash } from "src/common/auth/types";
|
||||
|
||||
const scryptAsync = promisify<
|
||||
BinaryLike,
|
||||
BinaryLike,
|
||||
number,
|
||||
ScryptOptions,
|
||||
Buffer
|
||||
>(scrypt);
|
||||
|
||||
@Injectable()
|
||||
export class HashService {
|
||||
private readonly pepper: string;
|
||||
private readonly current_alogrithm: string;
|
||||
private readonly current_version: number;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.pepper = this.configService.get<string>("HASH_PEPPER") ?? "";
|
||||
this.current_alogrithm = CURRENT_ALGORITHM;
|
||||
this.current_version = CURRENT_VERSION;
|
||||
}
|
||||
|
||||
private applyPepper(input: string): string {
|
||||
return this.pepper ? `${input}${this.pepper}` : input;
|
||||
}
|
||||
|
||||
private generateSalt(bytes: number = 16): string {
|
||||
return randomBytes(bytes).toString("hex");
|
||||
}
|
||||
|
||||
private isLegacyPasswordFormat(stored: unknown): stored is string {
|
||||
return typeof stored === "string" && stored.includes(":");
|
||||
}
|
||||
|
||||
private isVersionedPasswordFormat(stored: unknown): stored is PasswordHash {
|
||||
return (
|
||||
typeof stored === "object" &&
|
||||
stored !== null &&
|
||||
"hash" in stored &&
|
||||
"salt" in stored &&
|
||||
"algorithm" in stored &&
|
||||
"version" in stored
|
||||
);
|
||||
}
|
||||
|
||||
private async scrypt(input: string): Promise<[string, string]> {
|
||||
const salt = this.generateSalt();
|
||||
const pepperedInput = this.applyPepper(input);
|
||||
const derived = await scryptAsync(
|
||||
pepperedInput,
|
||||
salt,
|
||||
KEY_LENGTH,
|
||||
SCRYPT_PARAMS,
|
||||
);
|
||||
return [salt, derived.toString("hex")];
|
||||
}
|
||||
|
||||
private async verifyScrypt(
|
||||
password: string,
|
||||
salt: string,
|
||||
hashHex: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const pepperedInput = this.applyPepper(password);
|
||||
const derived = await scryptAsync(
|
||||
pepperedInput,
|
||||
salt,
|
||||
KEY_LENGTH,
|
||||
SCRYPT_PARAMS,
|
||||
);
|
||||
const storedBuffer = Buffer.from(hashHex, "hex");
|
||||
if (derived.length !== storedBuffer.length) return false;
|
||||
return timingSafeEqual(derived, storedBuffer);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async needsRehash(stored: string | PasswordHash): Promise<boolean> {
|
||||
if (this.isLegacyPasswordFormat(stored)) return true;
|
||||
if (!this.isVersionedPasswordFormat(stored)) return true;
|
||||
|
||||
return (
|
||||
stored.algorithm !== this.current_alogrithm ||
|
||||
stored.version !== this.current_version
|
||||
);
|
||||
}
|
||||
|
||||
async hashPassword(password: string): Promise<PasswordHash> {
|
||||
const [salt, hash] = await this.scrypt(password);
|
||||
|
||||
return {
|
||||
hash,
|
||||
salt,
|
||||
algorithm: CURRENT_ALGORITHM,
|
||||
version: CURRENT_VERSION,
|
||||
params: { ...SCRYPT_PARAMS, keyLength: KEY_LENGTH },
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyPassword(
|
||||
password: string,
|
||||
stored: string | PasswordHash,
|
||||
): Promise<boolean> {
|
||||
if (this.isLegacyPasswordFormat(stored)) {
|
||||
const [salt, hash] = stored.split(":");
|
||||
return this.verifyScrypt(password, salt, hash);
|
||||
}
|
||||
|
||||
if (!this.isVersionedPasswordFormat(stored)) return false;
|
||||
|
||||
switch (stored.algorithm) {
|
||||
case "scrypt":
|
||||
return this.verifyScrypt(password, stored.salt, stored.hash);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async hashOtp(otp: string): Promise<string> {
|
||||
const salt = this.generateSalt(8);
|
||||
const derived = await scryptAsync(
|
||||
otp,
|
||||
salt,
|
||||
KEY_LENGTH_FOR_OTP,
|
||||
SCRYPT_PARAMS_FOR_OTP,
|
||||
);
|
||||
return `${salt}:${derived.toString("hex")}`;
|
||||
}
|
||||
|
||||
async verifyOtp(otp: string, stored: string): Promise<boolean> {
|
||||
const [salt, hashHex] = stored.split(":");
|
||||
if (!salt || !hashHex) return false;
|
||||
try {
|
||||
const derived = await scryptAsync(
|
||||
otp,
|
||||
salt,
|
||||
KEY_LENGTH_FOR_OTP,
|
||||
SCRYPT_PARAMS_FOR_OTP,
|
||||
);
|
||||
const storedBuffer = Buffer.from(hashHex, "hex");
|
||||
if (derived.length !== storedBuffer.length) return false;
|
||||
return timingSafeEqual(derived, storedBuffer);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/features/auth/providers/index.ts
Normal file
2
src/features/auth/providers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { HashService } from "./hash.provider";
|
||||
export { OtpService } from "./otp.provider";
|
||||
22
src/features/auth/providers/otp.provider.ts
Normal file
22
src/features/auth/providers/otp.provider.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Environment } from "src/core/config/config.schema";
|
||||
|
||||
@Injectable()
|
||||
export class OtpService {
|
||||
maximum_otp_attemps: number;
|
||||
environment: Environment;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.maximum_otp_attemps =
|
||||
this.configService.get<number>("MAX_OTP_ATTEMPTS");
|
||||
this.environment = this.configService.get<Environment>("NODE_ENV");
|
||||
}
|
||||
|
||||
generateOtp(digits: number): string {
|
||||
if (this.environment === "development") {
|
||||
return this.configService.get<string>("DEFAULT_MOCK_OTP") ?? "11111";
|
||||
}
|
||||
return String(Math.floor(Math.random() * 1_000_000)).padStart(digits, "0");
|
||||
}
|
||||
}
|
||||
32
src/features/expert-initiated/dtos/login.dto.ts
Normal file
32
src/features/expert-initiated/dtos/login.dto.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class FieldExpertLoginDto {
|
||||
@ApiProperty()
|
||||
@IsEmail({ allow_underscores: true })
|
||||
@IsString({
|
||||
message: "email is not string",
|
||||
context: {
|
||||
errorCode: 4000,
|
||||
note: "The validated email type must be string",
|
||||
},
|
||||
})
|
||||
readonly email: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNotEmpty({
|
||||
message: "password is empty",
|
||||
context: {
|
||||
errorCode: 4001,
|
||||
note: "The validated password must not be empty",
|
||||
},
|
||||
})
|
||||
@IsString({
|
||||
message: "password is not string",
|
||||
context: {
|
||||
errorCode: 4002,
|
||||
note: "The validated password type must be string",
|
||||
},
|
||||
})
|
||||
readonly password: string;
|
||||
}
|
||||
16
src/features/expert-initiated/expert-initiated.controller.ts
Normal file
16
src/features/expert-initiated/expert-initiated.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Body, Controller, Post } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { FieldExpertLoginDto } from "./dtos/login.dto";
|
||||
import { FieldExpertService } from "./expert-initiated.service";
|
||||
|
||||
@Controller("expert-initiated")
|
||||
@ApiTags("Expert Initiated Flow (Field Expert)")
|
||||
@ApiBearerAuth()
|
||||
export class FieldExpertController {
|
||||
constructor(private readonly fieldExpertService: FieldExpertService) {}
|
||||
|
||||
@Post("login")
|
||||
async login(@Body() fieldExpertLoginDto: FieldExpertLoginDto) {
|
||||
return await this.fieldExpertService.login(fieldExpertLoginDto);
|
||||
}
|
||||
}
|
||||
25
src/features/expert-initiated/expert-initiated.module.ts
Normal file
25
src/features/expert-initiated/expert-initiated.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { FieldExpert, FieldExpertSchema } from "./schemas/field-expert.schema";
|
||||
import { FieldExpertController } from "./expert-initiated.controller";
|
||||
import { FieldExpertService } from "./expert-initiated.service";
|
||||
import { FieldExpertRepository } from "./repositories/field-expert.repository";
|
||||
import { AuthModule } from "src/auth/auth.module";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{
|
||||
name: FieldExpert.name,
|
||||
schema: FieldExpertSchema,
|
||||
},
|
||||
]),
|
||||
AuthModule,
|
||||
JwtModule,
|
||||
],
|
||||
controllers: [FieldExpertController],
|
||||
providers: [FieldExpertRepository, FieldExpertService],
|
||||
exports: [],
|
||||
})
|
||||
export class ExpertInitiatedModule {}
|
||||
32
src/features/expert-initiated/expert-initiated.service.ts
Normal file
32
src/features/expert-initiated/expert-initiated.service.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { FieldExpertRepository } from "./repositories/field-expert.repository";
|
||||
import { FieldExpertLoginDto } from "./dtos/login.dto";
|
||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||
|
||||
@Injectable()
|
||||
export class FieldExpertService {
|
||||
constructor(
|
||||
private readonly fieldExpertRepository: FieldExpertRepository,
|
||||
private readonly authService: UserAuthService,
|
||||
) {}
|
||||
|
||||
async login(fieldExpertLoginDto: FieldExpertLoginDto) {
|
||||
const user = await this.fieldExpertRepository.retrieveByEmail(
|
||||
fieldExpertLoginDto.email,
|
||||
);
|
||||
|
||||
if (!user) throw new NotFoundException("User not found");
|
||||
|
||||
const validate = await this.authService.validateUser(
|
||||
user.email,
|
||||
user.password,
|
||||
);
|
||||
|
||||
if (!validate)
|
||||
throw new BadRequestException("Username/Password does not match");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectModel } from "@nestjs/mongoose";
|
||||
import { Model } from "mongoose";
|
||||
import { FieldExpert } from "../schemas/field-expert.schema";
|
||||
|
||||
@Injectable()
|
||||
export class FieldExpertRepository {
|
||||
constructor(
|
||||
@InjectModel(FieldExpert.name)
|
||||
private readonly fieldExpertModel: Model<FieldExpert>,
|
||||
) {}
|
||||
|
||||
async retrieveById(id: string): Promise<FieldExpert> {
|
||||
return await this.fieldExpertModel.findById(id).exec();
|
||||
}
|
||||
|
||||
async retrieveByEmail(email: string): Promise<FieldExpert> {
|
||||
return await this.fieldExpertModel.findOne({
|
||||
email,
|
||||
});
|
||||
}
|
||||
}
|
||||
27
src/features/expert-initiated/schemas/field-expert.schema.ts
Normal file
27
src/features/expert-initiated/schemas/field-expert.schema.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { HydratedDocument, Types } from "mongoose";
|
||||
|
||||
export type FieldExpertDocument = HydratedDocument<FieldExpert>;
|
||||
|
||||
@Schema({
|
||||
id: true,
|
||||
timestamps: true,
|
||||
})
|
||||
export class FieldExpert {
|
||||
@Prop({ unique: true })
|
||||
email: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
password: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
firstName: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
lastName: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
cellphone: string;
|
||||
}
|
||||
|
||||
export const FieldExpertSchema = SchemaFactory.createForClass(FieldExpert);
|
||||
2
src/features/user/dtos/index.ts
Normal file
2
src/features/user/dtos/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { UserRegisterDto } from "./requests/user-register.dto";
|
||||
export { UserLoginDto } from "./requests/user-login.dto";
|
||||
16
src/features/user/dtos/requests/user-login.dto.ts
Normal file
16
src/features/user/dtos/requests/user-login.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
IsMobilePhone,
|
||||
IsNumberString,
|
||||
IsString,
|
||||
Length,
|
||||
} from "class-validator";
|
||||
|
||||
export class UserLoginDto {
|
||||
@IsString({ message: "Cellphone number must be string" })
|
||||
@IsMobilePhone("fa-IR")
|
||||
cellphoneNumber: string;
|
||||
|
||||
@IsNumberString()
|
||||
@Length(4, 6)
|
||||
otp: string;
|
||||
}
|
||||
7
src/features/user/dtos/requests/user-register.dto.ts
Normal file
7
src/features/user/dtos/requests/user-register.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsMobilePhone, IsString } from "class-validator";
|
||||
|
||||
export class UserRegisterDto {
|
||||
@IsString({ message: "Cellphone number must be string" })
|
||||
@IsMobilePhone("fa-IR")
|
||||
cellphoneNumber: string;
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { PlatesDto } from "src/plates/dto/plate.dto";
|
||||
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 {
|
||||
public loginToken: string;
|
||||
constructor(token: string) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { ClientModule } from "src/client/client.module";
|
||||
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
|
||||
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
|
||||
import { SandHubModel, SandHubSchema } from "./entity/schema/sand-hub.schema";
|
||||
@@ -10,6 +11,7 @@ import { SandHubService } from "./sand-hub.service";
|
||||
imports: [
|
||||
HttpModule,
|
||||
SystemSettingsModule,
|
||||
ClientModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: SandHubModel.name, schema: SandHubSchema },
|
||||
]),
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
} from "@nestjs/common";
|
||||
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 { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||
import { SandHubDetailDto } from "./dto/sand-hub.dto";
|
||||
import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service";
|
||||
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 { firstValueFrom } from "rxjs";
|
||||
|
||||
@@ -33,32 +35,39 @@ export class SandHubService {
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly sandHubDbService: SandHubDbService,
|
||||
private readonly systemSettingsService: SystemSettingsService,
|
||||
private readonly externalInquirySettings: ExternalInquirySettingsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* When false, no outbound HTTP to SandHub/Tejarat — inquiries use permissive mocks.
|
||||
* Controlled by `system_settings.externalApis.sandHubUseLiveApi` (PATCH /system-settings).
|
||||
*/
|
||||
private async useLiveSandHubApis(): Promise<boolean> {
|
||||
return this.systemSettingsService.isSandHubLiveEnabled();
|
||||
private clientRefFrom(options?: SandHubInquiryOptions): string | undefined {
|
||||
return options?.clientId;
|
||||
}
|
||||
|
||||
/** Tenant-specific company fields for mocked external inquiries (MOCK_INQUIRY_COMPANY_*). */
|
||||
private getMockInquiryCompanyId(): string {
|
||||
return process.env.CLIENT_ID ?? "15";
|
||||
private async isInquiryLive(
|
||||
type: ExternalInquiryType,
|
||||
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 {
|
||||
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. */
|
||||
private getDefaultMockPlateInquiryRaw(): Record<string, unknown> {
|
||||
private buildMockPlateInquiryRaw(
|
||||
ctx: MockInquiryCompanyContext,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
PrntPlcyCmpDocNo: "1404/1143-70591/200/123",
|
||||
MapTypNam: "فائو ( FAW )",
|
||||
@@ -107,8 +116,8 @@ export class SandHubService {
|
||||
InstallDateField: "1403/03/01",
|
||||
AxelNumberField: "2",
|
||||
WheelNumberField: "4",
|
||||
CompanyName: process.env.CLIENT_NAME ?? "بیمه سامان",
|
||||
CompanyCode: `${process.env.CLIENT_ID ?? 15}`,
|
||||
CompanyName: ctx.companyName,
|
||||
CompanyCode: ctx.companyId,
|
||||
IssueDate: "1403/04/06",
|
||||
SatrtDate: "1403/04/06",
|
||||
EndDate: "1404/04/06",
|
||||
@@ -133,17 +142,18 @@ export class SandHubService {
|
||||
};
|
||||
}
|
||||
|
||||
private getDefaultMockCarBodyInquiryRaw(
|
||||
private buildMockCarBodyInquiryRaw(
|
||||
userDetail: SandHubDetailDto,
|
||||
ctx: MockInquiryCompanyContext,
|
||||
): Record<string, unknown> {
|
||||
const companyId = Number(this.getMockInquiryCompanyId());
|
||||
const companyId = Number(ctx.companyId);
|
||||
|
||||
return {
|
||||
data: {
|
||||
id: 70016075946,
|
||||
printNumber: "1405/1143-70591/220/1/0",
|
||||
companyId: Number.isNaN(companyId) ? 15 : companyId,
|
||||
companyName: this.getMockInquiryCompanyName(),
|
||||
companyName: ctx.companyName,
|
||||
beginDate: "1405/01/17",
|
||||
endDate: "1406/01/17",
|
||||
issueDate: "1405/01/16",
|
||||
@@ -177,7 +187,11 @@ export class SandHubService {
|
||||
/**
|
||||
* 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();
|
||||
if (u.includes("personal-inquiry")) {
|
||||
const p = payload as { nationalCode?: string } | undefined;
|
||||
@@ -217,7 +231,10 @@ export class SandHubService {
|
||||
}
|
||||
if (u.includes("block-inquiry") || u.includes("tejarat")) {
|
||||
return this.mapNewApiResponseToOldFormat(
|
||||
this.getDefaultMockPlateInquiryRaw(),
|
||||
plateMock ?? this.buildMockPlateInquiryRaw({
|
||||
companyId: process.env.CLIENT_ID ?? "15",
|
||||
companyName: process.env.CLIENT_NAME ?? "بیمه سامان",
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.logger.warn(
|
||||
@@ -227,9 +244,6 @@ export class SandHubService {
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
if (!(await this.useLiveSandHubApis())) {
|
||||
return "mock-sandhub-access-token";
|
||||
}
|
||||
if (this.loginToken && this.tokenExpiry && this.tokenExpiry > new Date()) {
|
||||
return this.loginToken;
|
||||
}
|
||||
@@ -266,9 +280,6 @@ export class SandHubService {
|
||||
}
|
||||
|
||||
private async getTejaratAccessToken(): Promise<string> {
|
||||
if (!(await this.useLiveSandHubApis())) {
|
||||
return "mock-tejarat-access-token";
|
||||
}
|
||||
if (
|
||||
this.tejaratAccessToken &&
|
||||
this.tejaratTokenExpiry &&
|
||||
@@ -329,9 +340,6 @@ export class SandHubService {
|
||||
}
|
||||
|
||||
private async getEsgAccessToken(): Promise<string> {
|
||||
if (!(await this.useLiveSandHubApis())) {
|
||||
return "mock-esg-access-token";
|
||||
}
|
||||
if (this.esgAccessToken && this.esgTokenExpiry && this.esgTokenExpiry > new Date()) {
|
||||
return this.esgAccessToken;
|
||||
}
|
||||
@@ -384,10 +392,34 @@ export class SandHubService {
|
||||
}
|
||||
}
|
||||
|
||||
private async makeEsgRequest(url: string, payload: any, maxRetries = 2) {
|
||||
if (!(await this.useLiveSandHubApis())) {
|
||||
this.logger.log(`[MOCK] ESG POST skipped: ${url}`);
|
||||
return this.getDefaultMockPlateInquiryRaw();
|
||||
private async makeEsgRequest(
|
||||
url: string,
|
||||
payload: any,
|
||||
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 === "thirdPartyPlate") {
|
||||
return this.buildMockPlateInquiryRaw(ctx);
|
||||
}
|
||||
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.buildMockPlateInquiryRaw(ctx);
|
||||
}
|
||||
|
||||
const INITIAL_DELAY = 500;
|
||||
@@ -571,10 +603,17 @@ export class SandHubService {
|
||||
};
|
||||
}
|
||||
|
||||
private async makeTejaratRequest(url: string, payload: any, maxRetries = 2) {
|
||||
if (!(await this.useLiveSandHubApis())) {
|
||||
this.logger.log(`[MOCK] Tejarat POST skipped: ${url}`);
|
||||
return this.getDefaultMockPlateInquiryRaw();
|
||||
private async makeTejaratRequest(
|
||||
url: string,
|
||||
payload: any,
|
||||
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.buildMockPlateInquiryRaw(ctx);
|
||||
}
|
||||
const INITIAL_DELAY = 500;
|
||||
const BACKOFF_FACTOR = 2;
|
||||
@@ -624,10 +663,14 @@ export class SandHubService {
|
||||
* Tejarat block inquiry (replaces SandHub call for V2 flows).
|
||||
* Returns both raw + mapped (old-format) response.
|
||||
*/
|
||||
async getTejaratBlockInquiry(userDetail: SandHubDetailDto): Promise<{
|
||||
async getTejaratBlockInquiry(
|
||||
userDetail: SandHubDetailDto,
|
||||
options?: SandHubInquiryOptions,
|
||||
): Promise<{
|
||||
raw: any;
|
||||
mapped: any;
|
||||
}> {
|
||||
const ctx = await this.mockCompanyContext(options);
|
||||
if (this.shouldUseEsgInquiryProvider()) {
|
||||
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
||||
const requestPayload = {
|
||||
@@ -639,10 +682,15 @@ export class SandHubService {
|
||||
};
|
||||
|
||||
const requestUrl = `${baseUrl}/inquiry/policyByPlate`;
|
||||
const live = await this.useLiveSandHubApis();
|
||||
const live = await this.isInquiryLive("thirdPartyPlate", options);
|
||||
const raw = live
|
||||
? await this.makeEsgRequest(requestUrl, requestPayload)
|
||||
: this.getDefaultMockPlateInquiryRaw();
|
||||
? await this.makeEsgRequest(
|
||||
requestUrl,
|
||||
requestPayload,
|
||||
"thirdPartyPlate",
|
||||
options,
|
||||
)
|
||||
: this.buildMockPlateInquiryRaw(ctx);
|
||||
if (!live) {
|
||||
this.logger.debug(
|
||||
`[MOCK] getEsgPolicyByPlateInquiry plate=${JSON.stringify(requestPayload)}`,
|
||||
@@ -663,10 +711,15 @@ export class SandHubService {
|
||||
};
|
||||
|
||||
const requestUrl = `${baseUrl}/block-inquiry-tejarat`;
|
||||
const live = await this.useLiveSandHubApis();
|
||||
const live = await this.isInquiryLive("thirdPartyPlate", options);
|
||||
const raw = live
|
||||
? await this.makeTejaratRequest(requestUrl, requestPayload)
|
||||
: this.getDefaultMockPlateInquiryRaw();
|
||||
? await this.makeTejaratRequest(
|
||||
requestUrl,
|
||||
requestPayload,
|
||||
"thirdPartyPlate",
|
||||
options,
|
||||
)
|
||||
: this.buildMockPlateInquiryRaw(ctx);
|
||||
if (!live) {
|
||||
this.logger.debug(
|
||||
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
|
||||
@@ -680,7 +733,10 @@ export class SandHubService {
|
||||
return await this.sandHubDbService.findOneBySandHubId(sandHubId);
|
||||
}
|
||||
|
||||
async getTejaratCarBodyInquiry(userDetail: SandHubDetailDto): Promise<{
|
||||
async getTejaratCarBodyInquiry(
|
||||
userDetail: SandHubDetailDto,
|
||||
options?: SandHubInquiryOptions,
|
||||
): Promise<{
|
||||
raw: any;
|
||||
mapped: Record<string, unknown>;
|
||||
}> {
|
||||
@@ -696,7 +752,8 @@ export class SandHubService {
|
||||
};
|
||||
|
||||
const requestUrl = `${baseUrl}/block-inquiry-tejarat/badane`;
|
||||
const live = await this.useLiveSandHubApis();
|
||||
const live = await this.isInquiryLive("carBodyPlate", options);
|
||||
const ctx = await this.mockCompanyContext(options);
|
||||
|
||||
let raw: any;
|
||||
|
||||
@@ -722,11 +779,10 @@ export class SandHubService {
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// Mock — reuse the default mock and adapt it to the car-body shape
|
||||
this.logger.debug(
|
||||
`[MOCK] getTejaratCarBodyInquiry plate=${JSON.stringify(requestPayload)}`,
|
||||
);
|
||||
raw = this.getDefaultMockCarBodyInquiryRaw(userDetail);
|
||||
raw = this.buildMockCarBodyInquiryRaw(userDetail, ctx);
|
||||
}
|
||||
|
||||
const mapped = this.mapCarBodyInquiryResponse(raw);
|
||||
@@ -797,10 +853,21 @@ export class SandHubService {
|
||||
};
|
||||
}
|
||||
|
||||
private async makeSandHubRequest(url: string, payload: any, maxRetries = 3) {
|
||||
if (!(await this.useLiveSandHubApis())) {
|
||||
this.logger.log(`[MOCK] SandHub POST skipped: ${url}`);
|
||||
return this.buildMockSandHubResponseForUrl(url, payload);
|
||||
private async makeSandHubRequest(
|
||||
url: string,
|
||||
payload: any,
|
||||
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);
|
||||
const plateMock =
|
||||
inquiryType === "thirdPartyPlate" || inquiryType === "carBodyPlate"
|
||||
? this.buildMockPlateInquiryRaw(ctx)
|
||||
: undefined;
|
||||
return this.buildMockSandHubResponseForUrl(url, payload, plateMock);
|
||||
}
|
||||
const token = await this.getAccessToken();
|
||||
const INITIAL_DELAY = 1000;
|
||||
@@ -909,7 +976,10 @@ export class SandHubService {
|
||||
};
|
||||
}
|
||||
|
||||
async getSandHubResponse(userDetail: SandHubDetailDto) {
|
||||
async getSandHubResponse(
|
||||
userDetail: SandHubDetailDto,
|
||||
options?: SandHubInquiryOptions,
|
||||
) {
|
||||
try {
|
||||
const requestPayload = {
|
||||
leftTwoDigits: String(userDetail.plate.leftDigits),
|
||||
@@ -922,13 +992,19 @@ export class SandHubService {
|
||||
const requestUrl = `${base}/block-inquiry-tejarat`;
|
||||
|
||||
let response: any;
|
||||
if (await this.useLiveSandHubApis()) {
|
||||
response = await this.makeSandHubRequest(requestUrl, requestPayload);
|
||||
if (await this.isInquiryLive("thirdPartyPlate", options)) {
|
||||
response = await this.makeSandHubRequest(
|
||||
requestUrl,
|
||||
requestPayload,
|
||||
"thirdPartyPlate",
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(
|
||||
`[MOCK] getSandHubResponse plate=${JSON.stringify(requestPayload)}`,
|
||||
);
|
||||
response = this.getDefaultMockPlateInquiryRaw();
|
||||
const ctx = await this.mockCompanyContext(options);
|
||||
response = this.buildMockPlateInquiryRaw(ctx);
|
||||
}
|
||||
|
||||
const result = this.mapNewApiResponseToOldFormat(response);
|
||||
@@ -948,7 +1024,11 @@ export class SandHubService {
|
||||
* - CLIENT_ID=8 (Parsian/ESG): Jalali birth date sent as-is to `/inquiry/person`.
|
||||
* - 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 {
|
||||
if (this.shouldUseEsgInquiryProvider()) {
|
||||
const jalaliBirthDate = this.normalizeJalaliBirthDateForEsg(birthDate);
|
||||
@@ -965,7 +1045,7 @@ export class SandHubService {
|
||||
dateHasPostfix: 0,
|
||||
};
|
||||
const requestUrl = `${baseUrl}/inquiry/person`;
|
||||
const live = await this.useLiveSandHubApis();
|
||||
const live = await this.isInquiryLive("personalIdentity", options);
|
||||
|
||||
if (!live) {
|
||||
this.logger.debug(
|
||||
@@ -974,7 +1054,12 @@ export class SandHubService {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -995,6 +1080,8 @@ export class SandHubService {
|
||||
const response = await this.makeSandHubRequest(
|
||||
requestUrl,
|
||||
requestPayload,
|
||||
"personalIdentity",
|
||||
options,
|
||||
);
|
||||
|
||||
if (response?.message?.includes("err.record.not.found")) {
|
||||
@@ -1017,6 +1104,7 @@ export class SandHubService {
|
||||
async getDrivingLicenseInfo(
|
||||
nationalCode: string,
|
||||
driverLicenseNumber: string,
|
||||
options?: SandHubInquiryOptions,
|
||||
) {
|
||||
const requestUrl = `${process.env.SANHUB_BASE_URL}/driver-license-check`;
|
||||
const requestPayload = {
|
||||
@@ -1032,6 +1120,8 @@ export class SandHubService {
|
||||
const response = await this.makeSandHubRequest(
|
||||
requestUrl,
|
||||
requestPayload,
|
||||
"drivingLicense",
|
||||
options,
|
||||
);
|
||||
|
||||
if (response?.data?.IsSucceed === false) {
|
||||
@@ -1055,7 +1145,11 @@ export class SandHubService {
|
||||
}
|
||||
}
|
||||
|
||||
async getCarOwnershipInfo(plate: any, nationalCode: string) {
|
||||
async getCarOwnershipInfo(
|
||||
plate: any,
|
||||
nationalCode: string,
|
||||
options?: SandHubInquiryOptions,
|
||||
) {
|
||||
try {
|
||||
const requestUrl = `${process.env.SANHUB_BASE_URL}/ownership`;
|
||||
const requestPayload = {
|
||||
@@ -1072,6 +1166,8 @@ export class SandHubService {
|
||||
const response = await this.makeSandHubRequest(
|
||||
requestUrl,
|
||||
requestPayload,
|
||||
"carOwnership",
|
||||
options,
|
||||
);
|
||||
|
||||
// Check the 'IsSuccess' field in the nested 'data' object.
|
||||
@@ -1091,7 +1187,11 @@ export class SandHubService {
|
||||
}
|
||||
}
|
||||
|
||||
async getShebaValidation(nationalId: string, shebaId: string) {
|
||||
async getShebaValidation(
|
||||
nationalId: string,
|
||||
shebaId: string,
|
||||
options?: SandHubInquiryOptions,
|
||||
) {
|
||||
try {
|
||||
if (this.shouldUseEsgInquiryProvider()) {
|
||||
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
||||
@@ -1102,7 +1202,7 @@ export class SandHubService {
|
||||
sheba: String(shebaId),
|
||||
};
|
||||
const requestUrl = `${baseUrl}/inquiry/sheba`;
|
||||
const live = await this.useLiveSandHubApis();
|
||||
const live = await this.isInquiryLive("sheba", options);
|
||||
|
||||
this.logger.log(
|
||||
`Validating Sheba ID via ESG for national code: ${nationalId}`,
|
||||
@@ -1119,7 +1219,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);
|
||||
|
||||
if (response?.ReturnValue === false || response?.HasError === true) {
|
||||
@@ -1146,6 +1251,8 @@ export class SandHubService {
|
||||
const response = await this.makeSandHubRequest(
|
||||
requestUrl,
|
||||
requestPayload,
|
||||
"sheba",
|
||||
options,
|
||||
);
|
||||
|
||||
if (response?.ReturnValue === false || response?.HasError === true) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Type } from "class-transformer";
|
||||
export class ExternalApisSettingsDto {
|
||||
@ApiPropertyOptional({
|
||||
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,
|
||||
})
|
||||
@IsOptional()
|
||||
|
||||
@@ -159,6 +159,9 @@ export class DamageExpertModel {
|
||||
@Prop({ type: "string" })
|
||||
city: string;
|
||||
|
||||
@Prop({type: "string", required: false})
|
||||
expertCode?: string;
|
||||
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ export class ExpertModel {
|
||||
@Prop({ type: "string" })
|
||||
otp: string;
|
||||
|
||||
@Prop({type: "string", required: false})
|
||||
expertCode?: string;
|
||||
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ export class FieldExpertModel {
|
||||
@Prop({ type: "string", default: "" })
|
||||
otp: string;
|
||||
|
||||
@Prop({type: "string", required: false})
|
||||
expertCode?: string;
|
||||
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ export class InsurerExpertModel {
|
||||
@Prop()
|
||||
address?: string;
|
||||
|
||||
@Prop({type: "string", required: false})
|
||||
expertCode?: string;
|
||||
|
||||
createdAt: Date;
|
||||
}
|
||||
export const InsurerExpertDbSchema =
|
||||
|
||||
@@ -27,6 +27,9 @@ export class RegistrarModel {
|
||||
|
||||
@Prop({ type: "string", default: "" })
|
||||
otp: string;
|
||||
|
||||
@Prop({type: "string", required: false})
|
||||
expertCode?: string;
|
||||
}
|
||||
|
||||
export const RegistrarDbSchema = SchemaFactory.createForClass(RegistrarModel);
|
||||
|
||||
Reference in New Issue
Block a user