diff --git a/src/client/client-external-inquiries.controller.ts b/src/client/client-external-inquiries.controller.ts new file mode 100644 index 0000000..865a7f0 --- /dev/null +++ b/src/client/client-external-inquiries.controller.ts @@ -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 { + 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 { + 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 { + 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 { + return this.clientService.patchExternalInquirySettings(clientId, body); + } +} diff --git a/src/client/client.module.ts b/src/client/client.module.ts index 02a62f0..06ee7ee 100644 --- a/src/client/client.module.ts +++ b/src/client/client.module.ts @@ -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 {} diff --git a/src/client/client.service.ts b/src/client/client.service.ts index 5faaaeb..6c20a7c 100644 --- a/src/client/client.service.ts +++ b/src/client/client.service.ts @@ -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 { 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 { + 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 { + 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 { + const client = await this.loadClientOrThrow(clientKey); + const flags = mergeExternalInquiryFlags(body as Partial>); + const $set: Record = {}; + 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 { + const client = await this.loadClientOrThrow(clientKey); + const $set: Record = {}; + 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); + } } diff --git a/src/client/dto/client-external-inquiries.dto.ts b/src/client/dto/client-external-inquiries.dto.ts new file mode 100644 index 0000000..5b8d781 --- /dev/null +++ b/src/client/dto/client-external-inquiries.dto.ts @@ -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 }; + }, + 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; +} diff --git a/src/client/entities/schema/client.schema.ts b/src/client/entities/schema/client.schema.ts index b6a60d0..1e11d52 100644 --- a/src/client/entities/schema/client.schema.ts +++ b/src/client/entities/schema/client.schema.ts @@ -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 { + @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 }) diff --git a/src/client/external-inquiry-settings.service.spec.ts b/src/client/external-inquiry-settings.service.spec.ts new file mode 100644 index 0000000..767d784 --- /dev/null +++ b/src/client/external-inquiry-settings.service.spec.ts @@ -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: "بیمه سامان", + }); + }); +}); diff --git a/src/client/external-inquiry-settings.service.ts b/src/client/external-inquiry-settings.service.ts new file mode 100644 index 0000000..9a5cf26 --- /dev/null +++ b/src/client/external-inquiry-settings.service.ts @@ -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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/common/types/external-inquiry.types.ts b/src/common/types/external-inquiry.types.ts new file mode 100644 index 0000000..5e5e415 --- /dev/null +++ b/src/common/types/external-inquiry.types.ts @@ -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; + +export interface MockInquiryCompanyContext { + companyId: string; + companyName: string; +} + +export function mergeExternalInquiryFlags( + partial?: Partial | null, +): ExternalInquiryFlags { + return { ...DEFAULT_EXTERNAL_INQUIRY_FLAGS, ...(partial ?? {}) }; +} diff --git a/src/features/auth/auth.controller.ts b/src/features/auth/auth.controller.ts new file mode 100644 index 0000000..2f3b1d9 --- /dev/null +++ b/src/features/auth/auth.controller.ts @@ -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); + } +} diff --git a/src/features/auth/auth.module.ts b/src/features/auth/auth.module.ts new file mode 100644 index 0000000..cca86e8 --- /dev/null +++ b/src/features/auth/auth.module.ts @@ -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("JWT_SECRET"), + signOptions: { + expiresIn: configService.get("JWT_EXPIRY"), + }, + }), + }), + ], + controllers: [AuthController], + providers: [ + { provide: APP_GUARD, useClass: AuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + AuthService, + HashService, + OtpService, + ], +}) +export class AuthModule {} diff --git a/src/features/auth/auth.service.ts b/src/features/auth/auth.service.ts new file mode 100644 index 0000000..56fca0b --- /dev/null +++ b/src/features/auth/auth.service.ts @@ -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("DEFAULT_OTP_DIGITS") ?? 5; + this.otpExpiryMinutes = + (this.configService.get("DEFAULT_OTP_EXPIRY_MINUTES") ?? 2) * 60; + this.environment = this.configService.get("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 {}; + } +} diff --git a/src/features/auth/providers/hash.provider.ts b/src/features/auth/providers/hash.provider.ts new file mode 100644 index 0000000..b9c0aef --- /dev/null +++ b/src/features/auth/providers/hash.provider.ts @@ -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("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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/src/features/auth/providers/index.ts b/src/features/auth/providers/index.ts new file mode 100644 index 0000000..03284ce --- /dev/null +++ b/src/features/auth/providers/index.ts @@ -0,0 +1,2 @@ +export { HashService } from "./hash.provider"; +export { OtpService } from "./otp.provider"; diff --git a/src/features/auth/providers/otp.provider.ts b/src/features/auth/providers/otp.provider.ts new file mode 100644 index 0000000..c0c60f3 --- /dev/null +++ b/src/features/auth/providers/otp.provider.ts @@ -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("MAX_OTP_ATTEMPTS"); + this.environment = this.configService.get("NODE_ENV"); + } + + generateOtp(digits: number): string { + if (this.environment === "development") { + return this.configService.get("DEFAULT_MOCK_OTP") ?? "11111"; + } + return String(Math.floor(Math.random() * 1_000_000)).padStart(digits, "0"); + } +} diff --git a/src/features/expert-initiated/dtos/login.dto.ts b/src/features/expert-initiated/dtos/login.dto.ts new file mode 100644 index 0000000..68b650b --- /dev/null +++ b/src/features/expert-initiated/dtos/login.dto.ts @@ -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; +} diff --git a/src/features/expert-initiated/expert-initiated.controller.ts b/src/features/expert-initiated/expert-initiated.controller.ts new file mode 100644 index 0000000..8f2a1e8 --- /dev/null +++ b/src/features/expert-initiated/expert-initiated.controller.ts @@ -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); + } +} diff --git a/src/features/expert-initiated/expert-initiated.module.ts b/src/features/expert-initiated/expert-initiated.module.ts new file mode 100644 index 0000000..2050ef1 --- /dev/null +++ b/src/features/expert-initiated/expert-initiated.module.ts @@ -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 {} diff --git a/src/features/expert-initiated/expert-initiated.service.ts b/src/features/expert-initiated/expert-initiated.service.ts new file mode 100644 index 0000000..2a88ce7 --- /dev/null +++ b/src/features/expert-initiated/expert-initiated.service.ts @@ -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"); + } +} diff --git a/src/features/expert-initiated/repositories/field-expert.repository.ts b/src/features/expert-initiated/repositories/field-expert.repository.ts new file mode 100644 index 0000000..2e4784d --- /dev/null +++ b/src/features/expert-initiated/repositories/field-expert.repository.ts @@ -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, + ) {} + + async retrieveById(id: string): Promise { + return await this.fieldExpertModel.findById(id).exec(); + } + + async retrieveByEmail(email: string): Promise { + return await this.fieldExpertModel.findOne({ + email, + }); + } +} diff --git a/src/features/expert-initiated/schemas/field-expert.schema.ts b/src/features/expert-initiated/schemas/field-expert.schema.ts new file mode 100644 index 0000000..cde1c7d --- /dev/null +++ b/src/features/expert-initiated/schemas/field-expert.schema.ts @@ -0,0 +1,27 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { HydratedDocument, Types } from "mongoose"; + +export type FieldExpertDocument = HydratedDocument; + +@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); diff --git a/src/features/user/dtos/index.ts b/src/features/user/dtos/index.ts new file mode 100644 index 0000000..82c08c6 --- /dev/null +++ b/src/features/user/dtos/index.ts @@ -0,0 +1,2 @@ +export { UserRegisterDto } from "./requests/user-register.dto"; +export { UserLoginDto } from "./requests/user-login.dto"; diff --git a/src/features/user/dtos/requests/user-login.dto.ts b/src/features/user/dtos/requests/user-login.dto.ts new file mode 100644 index 0000000..a8eb9e5 --- /dev/null +++ b/src/features/user/dtos/requests/user-login.dto.ts @@ -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; +} diff --git a/src/features/user/dtos/requests/user-register.dto.ts b/src/features/user/dtos/requests/user-register.dto.ts new file mode 100644 index 0000000..089c2c7 --- /dev/null +++ b/src/features/user/dtos/requests/user-register.dto.ts @@ -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; +} diff --git a/src/sand-hub/dto/sand-hub.dto.ts b/src/sand-hub/dto/sand-hub.dto.ts index d6f6b0b..4cdf1e2 100644 --- a/src/sand-hub/dto/sand-hub.dto.ts +++ b/src/sand-hub/dto/sand-hub.dto.ts @@ -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) { diff --git a/src/sand-hub/sand-hub.module.ts b/src/sand-hub/sand-hub.module.ts index 4f77ace..4b3393c 100644 --- a/src/sand-hub/sand-hub.module.ts +++ b/src/sand-hub/sand-hub.module.ts @@ -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 }, ]), diff --git a/src/sand-hub/sand-hub.service.ts b/src/sand-hub/sand-hub.service.ts index 1bc6444..934440f 100644 --- a/src/sand-hub/sand-hub.service.ts +++ b/src/sand-hub/sand-hub.service.ts @@ -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 { - 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 { + return this.externalInquirySettings.isInquiryLive( + type, + this.clientRefFrom(options), + ); + } + + private async mockCompanyContext( + options?: SandHubInquiryOptions, + ): Promise { + 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 { + private buildMockPlateInquiryRaw( + ctx: MockInquiryCompanyContext, + ): Record { 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 { - 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, + ): 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 { - 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 { - 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 { - 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; }> { @@ -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) { diff --git a/src/system-settings/dto/system-settings.dto.ts b/src/system-settings/dto/system-settings.dto.ts index 474a695..8e69a20 100644 --- a/src/system-settings/dto/system-settings.dto.ts +++ b/src/system-settings/dto/system-settings.dto.ts @@ -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()