1
0
forked from Yara724/api

Merge pull request 'Toggle External API' (#71) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#71
This commit is contained in:
2026-05-16 16:23:44 +03:30
11 changed files with 358 additions and 14 deletions

View File

@@ -16,6 +16,7 @@ import { LookupsModule } from "./lookups/lookups.module";
import { PlatesModule } from "./plates/plates.module"; import { PlatesModule } from "./plates/plates.module";
import { ProfileModule } from "./profile/profile.module"; import { ProfileModule } from "./profile/profile.module";
import { SandHubModule } from "./sand-hub/sand-hub.module"; import { SandHubModule } from "./sand-hub/sand-hub.module";
import { SystemSettingsModule } from "./system-settings/system-settings.module";
import { ReportsModule } from "./reports/reports.module"; import { ReportsModule } from "./reports/reports.module";
import { RequestManagementModule } from "./request-management/request-management.module"; import { RequestManagementModule } from "./request-management/request-management.module";
import { UsersModule } from "./users/users.module"; import { UsersModule } from "./users/users.module";
@@ -58,6 +59,7 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
PlatesModule, PlatesModule,
RequestManagementModule, RequestManagementModule,
SandHubModule, SandHubModule,
SystemSettingsModule,
ExpertBlameModule, ExpertBlameModule,
ClaimRequestManagementModule, ClaimRequestManagementModule,
ExpertClaimModule, ExpertClaimModule,

View File

@@ -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<boolean> {
const request = context.switchToHttp().getRequest<Request>();
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;
}
}

View File

@@ -1,6 +1,7 @@
import { HttpModule } from "@nestjs/axios"; import { HttpModule } from "@nestjs/axios";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose"; import { MongooseModule } from "@nestjs/mongoose";
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service"; import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
import { SandHubModel, SandHubSchema } from "./entity/schema/sand-hub.schema"; import { SandHubModel, SandHubSchema } from "./entity/schema/sand-hub.schema";
import { SandHubService } from "./sand-hub.service"; import { SandHubService } from "./sand-hub.service";
@@ -8,6 +9,7 @@ import { SandHubService } from "./sand-hub.service";
@Module({ @Module({
imports: [ imports: [
HttpModule, HttpModule,
SystemSettingsModule,
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: SandHubModel.name, schema: SandHubSchema }, { name: SandHubModel.name, schema: SandHubSchema },
]), ]),

View File

@@ -12,6 +12,7 @@ import {
import axios from "axios"; import axios from "axios";
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service"; import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
import { SandHubModel } from "src/sand-hub/entity/schema/sand-hub.schema"; import { SandHubModel } from "src/sand-hub/entity/schema/sand-hub.schema";
import { SystemSettingsService } from "src/system-settings/system-settings.service";
import { SandHubDetailDto } from "./dto/sand-hub.dto"; import { SandHubDetailDto } from "./dto/sand-hub.dto";
import { jalaliToGregorianDate } from "src/helpers/date-jalali"; import { jalaliToGregorianDate } from "src/helpers/date-jalali";
@@ -28,14 +29,15 @@ export class SandHubService {
constructor( constructor(
private readonly httpService: HttpService, private readonly httpService: HttpService,
private readonly sandHubDbService: SandHubDbService, private readonly sandHubDbService: SandHubDbService,
private readonly systemSettingsService: SystemSettingsService,
) {} ) {}
/** /**
* When false (default), no outbound HTTP to SandHub/Tejarat gateways — all inquiries return mocks. * When false, no outbound HTTP to SandHub/Tejarat — inquiries use permissive mocks.
* Set `SANDHUB_USE_REAL_API=true` to restore live calls. * Controlled by `system_settings.externalApis.sandHubUseLiveApi` (PATCH /system-settings).
*/ */
private useLiveSandHubApis(): boolean { private async useLiveSandHubApis(): Promise<boolean> {
return process.env.SANDHUB_USE_REAL_API === "true"; return this.systemSettingsService.isSandHubLiveEnabled();
} }
/** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */ /** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */
@@ -117,9 +119,12 @@ export class SandHubService {
/** /**
* Bodies returned from `makeSandHubRequest` in mock mode (same shape callers expect from real API). * Bodies returned from `makeSandHubRequest` in mock mode (same shape callers expect from real API).
*/ */
private buildMockSandHubResponseForUrl(url: string): any { private buildMockSandHubResponseForUrl(url: string, payload?: unknown): any {
const u = (url || "").toLowerCase(); const u = (url || "").toLowerCase();
if (u.includes("personal-inquiry")) { if (u.includes("personal-inquiry")) {
const p = payload as { nationalCode?: string } | undefined;
const nin =
typeof p?.nationalCode === "string" ? p.nationalCode : "-";
return { return {
message: "success", message: "success",
data: { data: {
@@ -127,7 +132,7 @@ export class SandHubService {
lastName: "خانوادگی", lastName: "خانوادگی",
fatherName: "-", fatherName: "-",
birthCertificateNumber: "-", birthCertificateNumber: "-",
nin: "-", nin,
}, },
}; };
} }
@@ -165,7 +170,7 @@ export class SandHubService {
} }
private async getAccessToken(): Promise<string> { private async getAccessToken(): Promise<string> {
if (!this.useLiveSandHubApis()) { if (!(await this.useLiveSandHubApis())) {
return "mock-sandhub-access-token"; return "mock-sandhub-access-token";
} }
if (this.loginToken && this.tokenExpiry && this.tokenExpiry > new Date()) { if (this.loginToken && this.tokenExpiry && this.tokenExpiry > new Date()) {
@@ -205,7 +210,7 @@ export class SandHubService {
} }
private async getTejaratAccessToken(): Promise<string> { private async getTejaratAccessToken(): Promise<string> {
if (!this.useLiveSandHubApis()) { if (!(await this.useLiveSandHubApis())) {
return "mock-tejarat-access-token"; return "mock-tejarat-access-token";
} }
if ( if (
@@ -263,7 +268,7 @@ export class SandHubService {
} }
private async makeTejaratRequest(url: string, payload: any, maxRetries = 2) { private async makeTejaratRequest(url: string, payload: any, maxRetries = 2) {
if (!this.useLiveSandHubApis()) { if (!(await this.useLiveSandHubApis())) {
this.logger.log(`[MOCK] Tejarat POST skipped: ${url}`); this.logger.log(`[MOCK] Tejarat POST skipped: ${url}`);
return this.getDefaultMockPlateInquiryRaw(); return this.getDefaultMockPlateInquiryRaw();
} }
@@ -328,10 +333,11 @@ export class SandHubService {
}; };
const requestUrl = `${baseUrl}/block-inquiry-tejarat`; const requestUrl = `${baseUrl}/block-inquiry-tejarat`;
const raw = this.useLiveSandHubApis() const live = await this.useLiveSandHubApis();
const raw = live
? await this.makeTejaratRequest(requestUrl, requestPayload) ? await this.makeTejaratRequest(requestUrl, requestPayload)
: this.getDefaultMockPlateInquiryRaw(); : this.getDefaultMockPlateInquiryRaw();
if (!this.useLiveSandHubApis()) { if (!live) {
this.logger.debug( this.logger.debug(
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`, `[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
); );
@@ -345,9 +351,9 @@ export class SandHubService {
} }
private async makeSandHubRequest(url: string, payload: any, maxRetries = 3) { private async makeSandHubRequest(url: string, payload: any, maxRetries = 3) {
if (!this.useLiveSandHubApis()) { if (!(await this.useLiveSandHubApis())) {
this.logger.log(`[MOCK] SandHub POST skipped: ${url}`); this.logger.log(`[MOCK] SandHub POST skipped: ${url}`);
return this.buildMockSandHubResponseForUrl(url); return this.buildMockSandHubResponseForUrl(url, payload);
} }
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const INITIAL_DELAY = 1000; const INITIAL_DELAY = 1000;
@@ -453,7 +459,7 @@ export class SandHubService {
const requestUrl = `${base}/block-inquiry-tejarat`; const requestUrl = `${base}/block-inquiry-tejarat`;
let response: any; let response: any;
if (this.useLiveSandHubApis()) { if (await this.useLiveSandHubApis()) {
response = await this.makeSandHubRequest(requestUrl, requestPayload); response = await this.makeSandHubRequest(requestUrl, requestPayload);
} else { } else {
this.logger.debug( this.logger.debug(

View File

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

View File

@@ -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<SystemSettingsModel>,
) {}
findGlobal() {
return this.model
.findOne({ key: SYSTEM_SETTINGS_GLOBAL_KEY })
.lean()
.exec();
}
async upsertGlobal(update: UpdateQuery<SystemSettingsModel>) {
return this.model
.findOneAndUpdate(
{ key: SYSTEM_SETTINGS_GLOBAL_KEY },
{
$setOnInsert: { key: SYSTEM_SETTINGS_GLOBAL_KEY },
...update,
},
{ new: true, upsert: true, lean: true },
)
.exec();
}
}

View File

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

View File

@@ -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<SystemSettingsResponseDto> {
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<SystemSettingsResponseDto> {
return this.systemSettingsService.updateSettings(body);
}
}

View File

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

View File

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

View File

@@ -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<string, unknown> | null = null;
private cacheAt = 0;
private readonly cacheTtlMs = 15_000;
constructor(private readonly db: SystemSettingsDbService) {}
private async loadGlobal(): Promise<Record<string, unknown>> {
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<boolean> {
const doc = await this.loadGlobal();
const externalApis = doc.externalApis as
| { sandHubUseLiveApi?: boolean }
| undefined;
return externalApis?.sandHubUseLiveApi === true;
}
async getSettingsView(): Promise<SystemSettingsResponseDto> {
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<SystemSettingsResponseDto> {
const $set: Record<string, unknown> = {};
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();
}
}