diff --git a/src/app.module.ts b/src/app.module.ts index ebd86b2..1ff0d4c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,7 @@ import { LookupsModule } from "./lookups/lookups.module"; import { PlatesModule } from "./plates/plates.module"; import { ProfileModule } from "./profile/profile.module"; import { SandHubModule } from "./sand-hub/sand-hub.module"; +import { SystemSettingsModule } from "./system-settings/system-settings.module"; import { ReportsModule } from "./reports/reports.module"; import { RequestManagementModule } from "./request-management/request-management.module"; import { UsersModule } from "./users/users.module"; @@ -58,6 +59,7 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` }); PlatesModule, RequestManagementModule, SandHubModule, + SystemSettingsModule, ExpertBlameModule, ClaimRequestManagementModule, ExpertClaimModule, diff --git a/src/auth/guards/settings-jwt.guard.ts b/src/auth/guards/settings-jwt.guard.ts new file mode 100644 index 0000000..e6c7ff8 --- /dev/null +++ b/src/auth/guards/settings-jwt.guard.ts @@ -0,0 +1,41 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { Request } from "express"; + +/** + * Verifies Bearer JWT for platform settings routes. Does not restrict by role; + * pair with {@link RolesGuard} on handlers. + */ +@Injectable() +export class SettingsJwtGuard implements CanActivate { + constructor(private readonly jwtService: JwtService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + if (!token) { + throw new UnauthorizedException("Token not found"); + } + + try { + const payload = await this.jwtService.verifyAsync(token, { + secret: `${process.env.SECRET}`, + }); + (request as any).user = payload; + (request as any).identity = payload; + return true; + } catch { + throw new UnauthorizedException("Invalid token"); + } + } + + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(" ") ?? []; + return type === "Bearer" ? token : undefined; + } +} diff --git a/src/sand-hub/sand-hub.module.ts b/src/sand-hub/sand-hub.module.ts index d44393c..4f77ace 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 { MongooseModule } from "@nestjs/mongoose"; +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"; import { SandHubService } from "./sand-hub.service"; @@ -8,6 +9,7 @@ import { SandHubService } from "./sand-hub.service"; @Module({ imports: [ HttpModule, + SystemSettingsModule, 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 c4abad6..31719e4 100644 --- a/src/sand-hub/sand-hub.service.ts +++ b/src/sand-hub/sand-hub.service.ts @@ -12,6 +12,7 @@ import { import axios from "axios"; 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 { jalaliToGregorianDate } from "src/helpers/date-jalali"; @@ -28,14 +29,15 @@ export class SandHubService { constructor( private readonly httpService: HttpService, private readonly sandHubDbService: SandHubDbService, + private readonly systemSettingsService: SystemSettingsService, ) {} /** - * When false (default), no outbound HTTP to SandHub/Tejarat gateways — all inquiries return mocks. - * Set `SANDHUB_USE_REAL_API=true` to restore live calls. + * When false, no outbound HTTP to SandHub/Tejarat — inquiries use permissive mocks. + * Controlled by `system_settings.externalApis.sandHubUseLiveApi` (PATCH /system-settings). */ - private useLiveSandHubApis(): boolean { - return process.env.SANDHUB_USE_REAL_API === "true"; + private async useLiveSandHubApis(): Promise { + return this.systemSettingsService.isSandHubLiveEnabled(); } /** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */ @@ -117,9 +119,12 @@ export class SandHubService { /** * Bodies returned from `makeSandHubRequest` in mock mode (same shape callers expect from real API). */ - private buildMockSandHubResponseForUrl(url: string): any { + private buildMockSandHubResponseForUrl(url: string, payload?: unknown): any { const u = (url || "").toLowerCase(); if (u.includes("personal-inquiry")) { + const p = payload as { nationalCode?: string } | undefined; + const nin = + typeof p?.nationalCode === "string" ? p.nationalCode : "-"; return { message: "success", data: { @@ -127,7 +132,7 @@ export class SandHubService { lastName: "خانوادگی", fatherName: "-", birthCertificateNumber: "-", - nin: "-", + nin, }, }; } @@ -165,7 +170,7 @@ export class SandHubService { } private async getAccessToken(): Promise { - if (!this.useLiveSandHubApis()) { + if (!(await this.useLiveSandHubApis())) { return "mock-sandhub-access-token"; } if (this.loginToken && this.tokenExpiry && this.tokenExpiry > new Date()) { @@ -205,7 +210,7 @@ export class SandHubService { } private async getTejaratAccessToken(): Promise { - if (!this.useLiveSandHubApis()) { + if (!(await this.useLiveSandHubApis())) { return "mock-tejarat-access-token"; } if ( @@ -263,7 +268,7 @@ export class SandHubService { } private async makeTejaratRequest(url: string, payload: any, maxRetries = 2) { - if (!this.useLiveSandHubApis()) { + if (!(await this.useLiveSandHubApis())) { this.logger.log(`[MOCK] Tejarat POST skipped: ${url}`); return this.getDefaultMockPlateInquiryRaw(); } @@ -328,10 +333,11 @@ export class SandHubService { }; const requestUrl = `${baseUrl}/block-inquiry-tejarat`; - const raw = this.useLiveSandHubApis() + const live = await this.useLiveSandHubApis(); + const raw = live ? await this.makeTejaratRequest(requestUrl, requestPayload) : this.getDefaultMockPlateInquiryRaw(); - if (!this.useLiveSandHubApis()) { + if (!live) { this.logger.debug( `[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`, ); @@ -345,9 +351,9 @@ export class SandHubService { } private async makeSandHubRequest(url: string, payload: any, maxRetries = 3) { - if (!this.useLiveSandHubApis()) { + if (!(await this.useLiveSandHubApis())) { this.logger.log(`[MOCK] SandHub POST skipped: ${url}`); - return this.buildMockSandHubResponseForUrl(url); + return this.buildMockSandHubResponseForUrl(url, payload); } const token = await this.getAccessToken(); const INITIAL_DELAY = 1000; @@ -453,7 +459,7 @@ export class SandHubService { const requestUrl = `${base}/block-inquiry-tejarat`; let response: any; - if (this.useLiveSandHubApis()) { + if (await this.useLiveSandHubApis()) { response = await this.makeSandHubRequest(requestUrl, requestPayload); } else { this.logger.debug( diff --git a/src/system-settings/dto/system-settings.dto.ts b/src/system-settings/dto/system-settings.dto.ts new file mode 100644 index 0000000..474a695 --- /dev/null +++ b/src/system-settings/dto/system-settings.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional, ValidateNested } from "class-validator"; +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.", + example: false, + }) + @IsOptional() + @IsBoolean() + sandHubUseLiveApi?: boolean; +} + +export class UpdateSystemSettingsDto { + @ApiPropertyOptional({ type: ExternalApisSettingsDto }) + @IsOptional() + @ValidateNested() + @Type(() => ExternalApisSettingsDto) + externalApis?: ExternalApisSettingsDto; +} + +export class ExternalApisSettingsViewDto { + @ApiProperty({ + description: + "Stored in `system_settings.externalApis.sandHubUseLiveApi` — controls SandHubService", + example: false, + }) + sandHubUseLiveApi: boolean; +} + +export class SystemSettingsResponseDto { + @ApiProperty({ example: "global" }) + key: string; + + @ApiProperty({ type: ExternalApisSettingsViewDto }) + externalApis: ExternalApisSettingsViewDto; + + @ApiProperty({ + description: "Human-readable mode for operators", + example: "mock", + enum: ["live", "mock"], + }) + sandHubMode: "live" | "mock"; +} diff --git a/src/system-settings/entities/db-service/system-settings.db.service.ts b/src/system-settings/entities/db-service/system-settings.db.service.ts new file mode 100644 index 0000000..d8d89a4 --- /dev/null +++ b/src/system-settings/entities/db-service/system-settings.db.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { Model, UpdateQuery } from "mongoose"; +import { + SYSTEM_SETTINGS_GLOBAL_KEY, + SystemSettingsModel, +} from "../schema/system-settings.schema"; + +@Injectable() +export class SystemSettingsDbService { + constructor( + @InjectModel(SystemSettingsModel.name) + private readonly model: Model, + ) {} + + findGlobal() { + return this.model + .findOne({ key: SYSTEM_SETTINGS_GLOBAL_KEY }) + .lean() + .exec(); + } + + async upsertGlobal(update: UpdateQuery) { + return this.model + .findOneAndUpdate( + { key: SYSTEM_SETTINGS_GLOBAL_KEY }, + { + $setOnInsert: { key: SYSTEM_SETTINGS_GLOBAL_KEY }, + ...update, + }, + { new: true, upsert: true, lean: true }, + ) + .exec(); + } +} diff --git a/src/system-settings/entities/schema/system-settings.schema.ts b/src/system-settings/entities/schema/system-settings.schema.ts new file mode 100644 index 0000000..03298ea --- /dev/null +++ b/src/system-settings/entities/schema/system-settings.schema.ts @@ -0,0 +1,30 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; + +/** Singleton document key — only `global` is used today. */ +export const SYSTEM_SETTINGS_GLOBAL_KEY = "global"; + +/** + * Toggle outbound integrations (SandHub / Tejarat inquiry gateways, etc.). + * When `sandHubUseLiveApi` is false, {@link SandHubService} skips HTTP and + * returns permissive mocks (including default plate/insurance inquiry data). + */ +export class ExternalApisSettings { + /** + * `true` → live SandHub/Tejarat HTTP calls. + * `false` → no external requests; mocks pass validation checks. + */ + @Prop({ type: Boolean, required: false, default: false }) + sandHubUseLiveApi?: boolean; +} + +@Schema({ collection: "system_settings", versionKey: false }) +export class SystemSettingsModel { + @Prop({ required: true, unique: true, default: SYSTEM_SETTINGS_GLOBAL_KEY }) + key: string; + + @Prop({ type: ExternalApisSettings, required: false, default: {} }) + externalApis?: ExternalApisSettings; +} + +export const SystemSettingsSchema = + SchemaFactory.createForClass(SystemSettingsModel); diff --git a/src/system-settings/system-settings.controller.ts b/src/system-settings/system-settings.controller.ts new file mode 100644 index 0000000..ff346be --- /dev/null +++ b/src/system-settings/system-settings.controller.ts @@ -0,0 +1,51 @@ +import { Body, Controller, Get, Patch, UseGuards } from "@nestjs/common"; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { SettingsJwtGuard } from "src/auth/guards/settings-jwt.guard"; +import { RolesGuard } from "src/auth/guards/role.guard"; +import { Roles } from "src/decorators/roles.decorator"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { + SystemSettingsResponseDto, + UpdateSystemSettingsDto, +} from "./dto/system-settings.dto"; +import { SystemSettingsService } from "./system-settings.service"; + +@ApiTags("system-settings") +@ApiBearerAuth() +@Controller("system-settings") +export class SystemSettingsController { + constructor(private readonly systemSettingsService: SystemSettingsService) {} + + @Get() + @UseGuards(SettingsJwtGuard, RolesGuard) + @Roles(RoleEnum.ADMIN, RoleEnum.COMPANY) + @ApiOperation({ + summary: "Get global system settings (external API toggles)", + description: + "Shows whether SandHub/Tejarat live HTTP is enabled. When disabled, the API uses mocks so flows continue without external connectivity.", + }) + @ApiResponse({ status: 200, type: SystemSettingsResponseDto }) + getSettings(): Promise { + return this.systemSettingsService.getSettingsView(); + } + + @Patch() + @UseGuards(SettingsJwtGuard, RolesGuard) + @Roles(RoleEnum.ADMIN) + @ApiOperation({ + summary: "Update global system settings", + description: + "Set `externalApis.sandHubUseLiveApi` to true for live inquiries, false for mock/offline mode.", + }) + @ApiResponse({ status: 200, type: SystemSettingsResponseDto }) + updateSettings( + @Body() body: UpdateSystemSettingsDto, + ): Promise { + return this.systemSettingsService.updateSettings(body); + } +} diff --git a/src/system-settings/system-settings.module.ts b/src/system-settings/system-settings.module.ts new file mode 100644 index 0000000..79cf9cb --- /dev/null +++ b/src/system-settings/system-settings.module.ts @@ -0,0 +1,21 @@ +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; +import { SystemSettingsDbService } from "./entities/db-service/system-settings.db.service"; +import { + SystemSettingsModel, + SystemSettingsSchema, +} from "./entities/schema/system-settings.schema"; +import { SystemSettingsController } from "./system-settings.controller"; +import { SystemSettingsService } from "./system-settings.service"; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: SystemSettingsModel.name, schema: SystemSettingsSchema }, + ]), + ], + controllers: [SystemSettingsController], + providers: [SystemSettingsService, SystemSettingsDbService], + exports: [SystemSettingsService], +}) +export class SystemSettingsModule {} diff --git a/src/system-settings/system-settings.service.spec.ts b/src/system-settings/system-settings.service.spec.ts new file mode 100644 index 0000000..108c5b1 --- /dev/null +++ b/src/system-settings/system-settings.service.spec.ts @@ -0,0 +1,31 @@ +import { SystemSettingsService } from "./system-settings.service"; + +describe("SystemSettingsService", () => { + const db = { + findGlobal: jest.fn(), + upsertGlobal: jest.fn(), + }; + + let service: SystemSettingsService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new SystemSettingsService(db as any); + }); + + it("defaults to mock when DB field is false", async () => { + db.findGlobal.mockResolvedValue({ + key: "global", + externalApis: { sandHubUseLiveApi: false }, + }); + await expect(service.isSandHubLiveEnabled()).resolves.toBe(false); + }); + + it("uses live mode when DB field is true", async () => { + db.findGlobal.mockResolvedValue({ + key: "global", + externalApis: { sandHubUseLiveApi: true }, + }); + await expect(service.isSandHubLiveEnabled()).resolves.toBe(true); + }); +}); diff --git a/src/system-settings/system-settings.service.ts b/src/system-settings/system-settings.service.ts new file mode 100644 index 0000000..36cdcc7 --- /dev/null +++ b/src/system-settings/system-settings.service.ts @@ -0,0 +1,79 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + SystemSettingsResponseDto, + UpdateSystemSettingsDto, +} from "./dto/system-settings.dto"; +import { SystemSettingsDbService } from "./entities/db-service/system-settings.db.service"; + +@Injectable() +export class SystemSettingsService { + private readonly logger = new Logger(SystemSettingsService.name); + private cache: Record | null = null; + private cacheAt = 0; + private readonly cacheTtlMs = 15_000; + + constructor(private readonly db: SystemSettingsDbService) {} + + private async loadGlobal(): Promise> { + const now = Date.now(); + if (this.cache && now - this.cacheAt < this.cacheTtlMs) { + return this.cache; + } + let doc = await this.db.findGlobal(); + if (!doc) { + doc = await this.db.upsertGlobal({ + $set: { externalApis: { sandHubUseLiveApi: false } }, + }); + this.logger.log( + "Created default system_settings document (sandHubUseLiveApi=false)", + ); + } + this.cache = doc; + this.cacheAt = now; + return doc; + } + + private invalidateCache(): void { + this.cache = null; + this.cacheAt = 0; + } + + /** + * Whether SandHub/Tejarat live HTTP should run (`system_settings` collection). + */ + async isSandHubLiveEnabled(): Promise { + const doc = await this.loadGlobal(); + const externalApis = doc.externalApis as + | { sandHubUseLiveApi?: boolean } + | undefined; + return externalApis?.sandHubUseLiveApi === true; + } + + async getSettingsView(): Promise { + const doc = await this.loadGlobal(); + const externalApis = doc.externalApis as + | { sandHubUseLiveApi?: boolean } + | undefined; + const sandHubUseLiveApi = externalApis?.sandHubUseLiveApi === true; + return { + key: String(doc.key ?? "global"), + externalApis: { sandHubUseLiveApi }, + sandHubMode: sandHubUseLiveApi ? "live" : "mock", + }; + } + + async updateSettings( + body: UpdateSystemSettingsDto, + ): Promise { + const $set: Record = {}; + if (body.externalApis?.sandHubUseLiveApi !== undefined) { + $set["externalApis.sandHubUseLiveApi"] = body.externalApis.sandHubUseLiveApi; + } + if (Object.keys($set).length > 0) { + await this.db.upsertGlobal({ $set }); + this.invalidateCache(); + this.logger.log(`System settings updated: ${JSON.stringify($set)}`); + } + return this.getSettingsView(); + } +}