YARA-1035

This commit is contained in:
SepehrYahyaee
2026-06-20 14:51:01 +03:30
parent 2e4b10455b
commit 4fabed77e5
27 changed files with 1329 additions and 74 deletions

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

View File

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

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

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

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