diff --git a/src/fanavaran/fanavaran-auth.service.spec.ts b/src/fanavaran/fanavaran-auth.service.spec.ts index 2191a69..f7e44e7 100644 --- a/src/fanavaran/fanavaran-auth.service.spec.ts +++ b/src/fanavaran/fanavaran-auth.service.spec.ts @@ -4,7 +4,12 @@ describe("FanavaranAuthService", () => { const createAuthTokenModel = () => { const store = new Map< string, - { clientKey: string; authenticationToken: string; expiresAt: Date } + { + clientKey: string; + authenticationToken: string; + expiresAt: Date; + authFingerprint?: string; + } >(); return { findOne: jest.fn((query: { clientKey: string }) => ({ @@ -15,13 +20,20 @@ describe("FanavaranAuthService", () => { findOneAndUpdate: jest.fn( ( query: { clientKey: string }, - update: { $set: { authenticationToken: string; expiresAt: Date } }, + update: { + $set: { + authenticationToken: string; + expiresAt: Date; + authFingerprint?: string; + }; + }, ) => ({ exec: async () => { const next = { clientKey: query.clientKey, authenticationToken: update.$set.authenticationToken, expiresAt: update.$set.expiresAt, + authFingerprint: update.$set.authFingerprint, }; store.set(query.clientKey, next); return next; diff --git a/src/fanavaran/fanavaran-auth.service.ts b/src/fanavaran/fanavaran-auth.service.ts index cac9a15..6f63eab 100644 --- a/src/fanavaran/fanavaran-auth.service.ts +++ b/src/fanavaran/fanavaran-auth.service.ts @@ -30,6 +30,8 @@ interface CachedFanavaranAuth { authenticationToken: string; /** Epoch ms when the cached token should be refreshed. */ expiresAt: number; + /** Hash of auth fields used when this token was obtained. */ + authFingerprint: string; } interface TenantBackoffState { @@ -178,6 +180,22 @@ export class FanavaranAuthService { }); } + /** + * Stable fingerprint of tenant auth. Any change (location, credentials, …) + * forces a fresh GetAppToken+Login even before Tehran midnight. + */ + static authFingerprint(auth: FanavaranAuthConfig): string { + return [ + auth.appName, + auth.secret, + auth.username, + auth.password, + auth.corpId, + auth.contractId, + auth.location, + ].join("|"); + } + assertNotInBackoff(clientKey: FanavaranClientKey): void { const remaining = this.getBackoffRemainingMs(clientKey); if (remaining <= 0) return; @@ -195,13 +213,16 @@ export class FanavaranAuthService { }, ): Promise { this.assertNotInBackoff(clientKey); + const fingerprint = FanavaranAuthService.authFingerprint( + getFanavaranClientProfile(clientKey).auth, + ); if (!options?.forceRefresh) { - const memoryHit = this.readMemoryCache(clientKey); + const memoryHit = this.readMemoryCache(clientKey, fingerprint); if (memoryHit) { return memoryHit; } - const persisted = await this.readPersistedCache(clientKey); + const persisted = await this.readPersistedCache(clientKey, fingerprint); if (persisted) { return persisted; } @@ -253,6 +274,7 @@ export class FanavaranAuthService { auditSession?: FanavaranAuditSession, ): Promise { const profile = getFanavaranClientProfile(clientKey); + const fingerprint = FanavaranAuthService.authFingerprint(profile.auth); const appToken = await this.fetchAppToken(profile.auth, auditSession); const authenticationToken = await this.fetchLoginToken( appToken, @@ -261,7 +283,12 @@ export class FanavaranAuthService { ); const expiresAt = FanavaranAuthService.getNextMidnightExpiryMs(); - await this.persistToken(clientKey, authenticationToken, expiresAt); + await this.persistToken( + clientKey, + authenticationToken, + expiresAt, + fingerprint, + ); this.clearBackoff(clientKey); this.logger.log( `[${clientKey}] Cached Fanavaran authenticationToken until ${new Date( @@ -271,25 +298,44 @@ export class FanavaranAuthService { return authenticationToken; } - private readMemoryCache(clientKey: FanavaranClientKey): string | null { + private readMemoryCache( + clientKey: FanavaranClientKey, + fingerprint: string, + ): string | null { const cached = this.tokenCache.get(clientKey); - if (cached && cached.expiresAt > Date.now()) { + if (!cached) { + return null; + } + if (cached.authFingerprint !== fingerprint) { + this.logger.log( + `[${clientKey}] Auth config changed — discarding in-memory Fanavaran token`, + ); + this.tokenCache.delete(clientKey); + return null; + } + if (cached.expiresAt > Date.now()) { return cached.authenticationToken; } - if (cached) { - this.tokenCache.delete(clientKey); - } + this.tokenCache.delete(clientKey); return null; } private async readPersistedCache( clientKey: FanavaranClientKey, + fingerprint: string, ): Promise { try { const doc = await this.authTokenModel.findOne({ clientKey }).lean().exec(); if (!doc?.authenticationToken || !doc.expiresAt) { return null; } + if (!doc.authFingerprint || doc.authFingerprint !== fingerprint) { + this.logger.log( + `[${clientKey}] Auth config changed (or legacy token without fingerprint) — discarding persisted Fanavaran token`, + ); + await this.authTokenModel.deleteOne({ clientKey }).exec(); + return null; + } const expiresAt = new Date(doc.expiresAt).getTime(); if (!(expiresAt > Date.now())) { await this.authTokenModel.deleteOne({ clientKey }).exec(); @@ -298,6 +344,7 @@ export class FanavaranAuthService { this.tokenCache.set(clientKey, { authenticationToken: doc.authenticationToken, expiresAt, + authFingerprint: doc.authFingerprint, }); this.logger.log( `[${clientKey}] Reused persisted Fanavaran authenticationToken until ${new Date( @@ -318,8 +365,13 @@ export class FanavaranAuthService { clientKey: FanavaranClientKey, authenticationToken: string, expiresAt: number, + authFingerprint: string, ): Promise { - this.tokenCache.set(clientKey, { authenticationToken, expiresAt }); + this.tokenCache.set(clientKey, { + authenticationToken, + expiresAt, + authFingerprint, + }); try { await this.authTokenModel .findOneAndUpdate( @@ -328,6 +380,7 @@ export class FanavaranAuthService { $set: { authenticationToken, expiresAt: new Date(expiresAt), + authFingerprint, }, }, { upsert: true, new: true }, diff --git a/src/fanavaran/fanavaran-client-config.service.ts b/src/fanavaran/fanavaran-client-config.service.ts index 66e59b1..9f43fec 100644 --- a/src/fanavaran/fanavaran-client-config.service.ts +++ b/src/fanavaran/fanavaran-client-config.service.ts @@ -4,10 +4,12 @@ import { Model } from "mongoose"; import { FANAVARAN_CLIENT_KEYS, SEED_FANAVARAN_CLIENT_PROFILES, + getFanavaranClientProfile, setFanavaranClientProfilesCache, type FanavaranClientKey, type FanavaranClientProfile, } from "src/core/config/fanavaran-client.config"; +import { FanavaranAuthService } from "./fanavaran-auth.service"; import { FanavaranClientConfig, FanavaranClientConfigDocument, @@ -27,6 +29,7 @@ export class FanavaranClientConfigService implements OnModuleInit { constructor( @InjectModel(FanavaranClientConfig.name) private readonly configModel: Model, + private readonly fanavaranAuthService: FanavaranAuthService, ) {} async onModuleInit(): Promise { @@ -56,7 +59,26 @@ export class FanavaranClientConfigService implements OnModuleInit { } } + /** + * Re-read Mongo into the runtime profile cache. If any tenant's auth block + * changed (location, credentials, …), drop cached Fanavaran tokens so the + * next call re-logins instead of waiting for Tehran midnight. + */ async reloadCache(): Promise { + const previousFingerprints = new Map(); + for (const key of FANAVARAN_CLIENT_KEYS) { + try { + previousFingerprints.set( + key, + FanavaranAuthService.authFingerprint( + getFanavaranClientProfile(key).auth, + ), + ); + } catch { + // First boot / empty cache — nothing to compare. + } + } + const docs = await this.configModel.find().lean().exec(); const cache: Partial> = {}; @@ -83,6 +105,18 @@ export class FanavaranClientConfigService implements OnModuleInit { } setFanavaranClientProfilesCache(cache); + + for (const key of FANAVARAN_CLIENT_KEYS) { + const next = FanavaranAuthService.authFingerprint(cache[key]!.auth); + const prev = previousFingerprints.get(key); + if (prev && prev !== next) { + this.logger.warn( + `[${key}] Fanavaran auth config changed — invalidating cached authenticationToken`, + ); + this.fanavaranAuthService.invalidateToken(key); + } + } + this.logger.log( `Loaded ${docs.length} Fanavaran client config(s) from Mongo into runtime cache`, ); diff --git a/src/fanavaran/schema/fanavaran-auth-token.schema.ts b/src/fanavaran/schema/fanavaran-auth-token.schema.ts index 0ef93ba..f0ef90b 100644 --- a/src/fanavaran/schema/fanavaran-auth-token.schema.ts +++ b/src/fanavaran/schema/fanavaran-auth-token.schema.ts @@ -14,6 +14,13 @@ export class FanavaranAuthToken { @Prop({ type: String, required: true }) authenticationToken: string; + /** + * Fingerprint of auth config used when this token was minted + * (appName/secret/user/pass/corp/contract/location). Mismatch → re-login. + */ + @Prop({ type: String, required: false }) + authFingerprint?: string; + /** When this token should be refreshed (Asia/Tehran midnight). */ @Prop({ type: Date, required: true, index: true }) expiresAt: Date; diff --git a/src/offline-inquiry/offline-inquiry.module.ts b/src/offline-inquiry/offline-inquiry.module.ts new file mode 100644 index 0000000..e1b8c33 --- /dev/null +++ b/src/offline-inquiry/offline-inquiry.module.ts @@ -0,0 +1,20 @@ +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; +import { SystemSettingsModule } from "src/system-settings/system-settings.module"; +import { OfflineInquiryService } from "./offline-inquiry.service"; +import { + OfflineInquiry, + OfflineInquirySchema, +} from "./schema/offline-inquiry.schema"; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: OfflineInquiry.name, schema: OfflineInquirySchema }, + ]), + SystemSettingsModule, + ], + providers: [OfflineInquiryService], + exports: [OfflineInquiryService], +}) +export class OfflineInquiryModule {} diff --git a/src/offline-inquiry/offline-inquiry.seeds.ts b/src/offline-inquiry/offline-inquiry.seeds.ts new file mode 100644 index 0000000..e0c7fce --- /dev/null +++ b/src/offline-inquiry/offline-inquiry.seeds.ts @@ -0,0 +1,287 @@ +import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config"; + +/** + * Seed payloads for offline plate/policy inquiry. + * Inserted into `offlineInquiries` on boot when the unique key is missing. + */ +export const OFFLINE_INQUIRY_SEEDS: Array<{ + clientKey: FanavaranClientKey; + inquiryType: string; + nationalCode: string; + plate: { + leftDigits: string; + centerAlphabet: string; + centerDigits: string; + ir: string; + }; + plateId: string; + label: string; + fanavaranDriverId?: number; + person?: Record; + insurance?: Record; + inquiry: { + source: string; + raw: Record; + mapped: Record; + }; +}> = [ + { + clientKey: "parsian", + inquiryType: "thirdPartyPlate", + nationalCode: "0651828562", + plate: { + leftDigits: "56", + centerAlphabet: "د", + centerDigits: "394", + ir: "66", + }, + plateId: "66-56-د-394", + label: "روح اله حيدري — Parsian FIRST party (offline seed)", + person: { + driverBirthday: "13571201", + insurerBirthday: 13571201, + driverIsInsurer: true, + nationalCodeOfDriver: "0651828562", + nationalCodeOfInsurer: "0651828562", + phoneNumber: "09120723528", + }, + insurance: { + coverages: [], + policyNumber: null, + company: "بيمه پارسيان", + financialCeiling: "0", + startDate: "1404/10/21", + endDate: "1405/10/23", + }, + inquiry: { + source: "OFFLINE_SEED_INQUIRY", + raw: { + success: true, + provider: "PARSIAN", + trackingCode: "OFFLINE-PARSIAN-0651828562", + message: "offline seeded policy by plate", + duration: 0, + data: { + nationalCode: "0651828562", + plk1: "56", + plk2: "د", + plk3: "394", + plksrl: "66", + CarGrpCod: "2", + CmpCod: "8", + CmpNam: "بيمه پارسيان", + DisFnYrPrcnt: "35", + DisLfYrPrcnt: "35", + DisPrsnYrPrcnt: "70", + FndCst: "0.00", + HBgnDte: "1404/10/23", + HEndDte: "1405/10/23", + HIsuDte: "1404/10/21", + InsNam: "روح اله حيدري", + LastCmpCod: "8", + LastCmpDocNo: "1110/111130/403/002650", + MtrNum: "13389030167", + NtnlId: "0651828562", + PlcyUnqCod: "16018466143", + Plk: "394د56 66", + PrdDte: "1389", + PrntCmpDocNo: "1110/111130/404/003412", + ShsNum: "NAAP41FD5BJ301065", + TypPlcy: "بيمه ثالث و حوادث", + UsgCod: "8", + VIN: "IRFC891V7D2301065", + VehSysCod: "2", + }, + error: null, + }, + mapped: { + nationalCode: "0651828562", + plk1: "56", + plk2: "د", + plk3: "394", + plksrl: "66", + CarGrpCod: "2", + CmpCod: "8", + CmpNam: "بيمه پارسيان", + DisFnYrPrcnt: "35", + DisLfYrPrcnt: "35", + DisPrsnYrPrcnt: "70", + FndCst: "0.00", + HBgnDte: "1404/10/23", + HEndDte: "1405/10/23", + HIsuDte: "1404/10/21", + InsNam: "روح اله حيدري", + LastCmpCod: "8", + LastCmpDocNo: "1110/111130/403/002650", + MtrNum: "13389030167", + NtnlId: "0651828562", + PlcyUnqCod: "16018466143", + Plk: "394د56 66", + PrdDte: "1389", + PrntCmpDocNo: "1110/111130/404/003412", + ShsNum: "NAAP41FD5BJ301065", + TypPlcy: "بيمه ثالث و حوادث", + UsgCod: "8", + VIN: "IRFC891V7D2301065", + VehSysCod: "2", + companyId: "8", + companyPersianName: "بيمه پارسيان", + carGrpCod: "2", + usgCod: "8", + vehSysCod: "2", + mtrnum: "13389030167", + shsNam: "NAAP41FD5BJ301065", + vin: "IRFC891V7D2301065", + ntnlId: "0651828562", + Name: "روح اله حيدري", + ThirdPolicyCode: "16018466143", + LastCompanyDocumentNumber: "1110/111130/403/002650", + IssueDate: "1404/10/21", + StartDate: "1404/10/23", + EndDate: "1405/10/23", + CompanyCode: "8", + CompanyName: "بيمه پارسيان", + UsageField: "شخصی", + FinancialCvrCptl: "0", + PrntPlcyCmpDocNo: "1110/111130/404/003412", + VinNumberField: "IRFC891V7D2301065", + ChassisNumberField: "NAAP41FD5BJ301065", + EngineNumberField: "13389030167", + UsageCode: "8", + VehicleSystemCode: "2", + CarGroupCode: "2", + InsuranceFullName: "روح اله حيدري", + }, + }, + }, + { + clientKey: "parsian", + inquiryType: "thirdPartyPlate", + nationalCode: "0021557985", + plate: { + leftDigits: "77", + centerAlphabet: "ه", + centerDigits: "339", + ir: "20", + }, + plateId: "20-77-ه-339", + label: "سيدنويد صالحي — Parsian SECOND party (offline seed)", + fanavaranDriverId: 2426953, + person: { + driverBirthday: "13770819", + insurerBirthday: 13770819, + driverIsInsurer: true, + nationalCodeOfDriver: "0021557985", + nationalCodeOfInsurer: "0021557985", + phoneNumber: "09912536917", + }, + insurance: { + coverages: [], + policyNumber: null, + company: "بيمه پارسيان", + financialCeiling: "0", + startDate: "1405/02/29", + endDate: "1406/02/29", + }, + inquiry: { + source: "OFFLINE_SEED_INQUIRY", + raw: { + success: true, + provider: "PARSIAN", + trackingCode: "OFFLINE-PARSIAN-0021557985", + message: "offline seeded policy by plate", + duration: 0, + data: { + nationalCode: "0021557985", + plk1: "77", + plk2: "ه", + plk3: "339", + plksrl: "20", + CarGrpCod: "2", + CmpCod: "8", + CmpNam: "بيمه پارسيان", + DisFnYrPrcnt: "15", + DisLfYrPrcnt: "15", + DisPrsnYrPrcnt: "15", + FndCst: "716122.00", + HBgnDte: "1405/02/29", + HEndDte: "1406/02/29", + HIsuDte: "1405/02/29", + InsNam: "سيدنويد صالحي", + LastCmpCod: "8", + LastCmpDocNo: "1110/111130/404/000575", + MtrNum: "M159895611", + NtnlId: "0021557985", + PlcyUnqCod: "16035590431", + Plk: "339ه77 20", + PrdDte: "1402", + PrntCmpDocNo: "1110/111130/405/001098", + ShsNum: "NAPX212AAP1173773", + TypPlcy: "بيمه ثالث و حوادث", + UsgCod: "8", + VIN: "NAPX212AAP1173773", + VehSysCod: "315", + }, + error: null, + }, + mapped: { + nationalCode: "0021557985", + plk1: "77", + plk2: "ه", + plk3: "339", + plksrl: "20", + CarGrpCod: "2", + CmpCod: "8", + CmpNam: "بيمه پارسيان", + DisFnYrPrcnt: "15", + DisLfYrPrcnt: "15", + DisPrsnYrPrcnt: "15", + FndCst: "716122.00", + HBgnDte: "1405/02/29", + HEndDte: "1406/02/29", + HIsuDte: "1405/02/29", + InsNam: "سيدنويد صالحي", + LastCmpCod: "8", + LastCmpDocNo: "1110/111130/404/000575", + MtrNum: "M159895611", + NtnlId: "0021557985", + PlcyUnqCod: "16035590431", + Plk: "339ه77 20", + PrdDte: "1402", + PrntCmpDocNo: "1110/111130/405/001098", + ShsNum: "NAPX212AAP1173773", + TypPlcy: "بيمه ثالث و حوادث", + UsgCod: "8", + VIN: "NAPX212AAP1173773", + VehSysCod: "315", + companyId: "8", + companyPersianName: "بيمه پارسيان", + carGrpCod: "2", + usgCod: "8", + vehSysCod: "315", + mtrnum: "M159895611", + shsNam: "NAPX212AAP1173773", + vin: "NAPX212AAP1173773", + ntnlId: "0021557985", + Name: "سيدنويد صالحي", + ThirdPolicyCode: "16035590431", + LastCompanyDocumentNumber: "1110/111130/404/000575", + IssueDate: "1405/02/29", + StartDate: "1405/02/29", + EndDate: "1406/02/29", + CompanyCode: "8", + CompanyName: "بيمه پارسيان", + UsageField: "شخصی", + FinancialCvrCptl: "0", + PrntPlcyCmpDocNo: "1110/111130/405/001098", + VinNumberField: "NAPX212AAP1173773", + ChassisNumberField: "NAPX212AAP1173773", + EngineNumberField: "M159895611", + UsageCode: "8", + VehicleSystemCode: "315", + CarGroupCode: "2", + InsuranceFullName: "سيدنويد صالحي", + }, + }, + }, +]; diff --git a/src/offline-inquiry/offline-inquiry.service.ts b/src/offline-inquiry/offline-inquiry.service.ts new file mode 100644 index 0000000..7739687 --- /dev/null +++ b/src/offline-inquiry/offline-inquiry.service.ts @@ -0,0 +1,154 @@ +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { Model } from "mongoose"; +import { + resolveFanavaranClientKey, + type FanavaranClientKey, +} from "src/core/config/fanavaran-client.config"; +import { SystemSettingsService } from "src/system-settings/system-settings.service"; +import { OFFLINE_INQUIRY_SEEDS } from "./offline-inquiry.seeds"; +import { + OfflineInquiry, + OfflineInquiryDocument, +} from "./schema/offline-inquiry.schema"; + +export interface OfflinePlateLookupInput { + nationalCode: string; + leftDigits: string; + centerAlphabet: string; + centerDigits: string; + ir: string; + clientKey?: FanavaranClientKey; +} + +export interface OfflinePlateInquiryHit { + raw: Record; + mapped: Record; + source: string; + fanavaranDriverId?: number; + insurance?: Record; + person?: Record; + plateId?: string; + label?: string; +} + +@Injectable() +export class OfflineInquiryService implements OnModuleInit { + private readonly logger = new Logger(OfflineInquiryService.name); + + constructor( + @InjectModel(OfflineInquiry.name) + private readonly offlineInquiryModel: Model, + private readonly systemSettingsService: SystemSettingsService, + ) {} + + async onModuleInit(): Promise { + await this.seedMissing(); + } + + /** Normalize Persian plate letter (strip ZWNJ / Arabic variants). */ + static normalizePlateAlphabet(value: string): string { + return String(value ?? "") + .replace(/[\u200c\u200d\uFEFF]/g, "") + .replace(/ي/g, "ی") + .replace(/ك/g, "ک") + .trim(); + } + + static normalizeDigits(value: string | number): string { + return String(value ?? "").trim(); + } + + async seedMissing(): Promise { + for (const seed of OFFLINE_INQUIRY_SEEDS) { + const plate = { + leftDigits: OfflineInquiryService.normalizeDigits(seed.plate.leftDigits), + centerAlphabet: OfflineInquiryService.normalizePlateAlphabet( + seed.plate.centerAlphabet, + ), + centerDigits: OfflineInquiryService.normalizeDigits( + seed.plate.centerDigits, + ), + ir: OfflineInquiryService.normalizeDigits(seed.plate.ir), + }; + const existing = await this.offlineInquiryModel + .findOne({ + clientKey: seed.clientKey, + inquiryType: seed.inquiryType, + nationalCode: seed.nationalCode, + "plate.leftDigits": plate.leftDigits, + "plate.centerAlphabet": plate.centerAlphabet, + "plate.centerDigits": plate.centerDigits, + "plate.ir": plate.ir, + }) + .select({ _id: 1 }) + .lean() + .exec(); + if (existing) { + continue; + } + await this.offlineInquiryModel.create({ + ...seed, + plate, + enabled: true, + }); + this.logger.log( + `Seeded offlineInquiry ${seed.clientKey} ${seed.nationalCode} plate=${seed.plateId}`, + ); + } + } + + async findPlateInquiry( + input: OfflinePlateLookupInput, + ): Promise { + if (!(await this.systemSettingsService.isOfflineInquiryEnabled())) { + return null; + } + + const clientKey = input.clientKey ?? resolveFanavaranClientKey(); + const nationalCode = OfflineInquiryService.normalizeDigits( + input.nationalCode, + ); + const leftDigits = OfflineInquiryService.normalizeDigits(input.leftDigits); + const centerAlphabet = OfflineInquiryService.normalizePlateAlphabet( + input.centerAlphabet, + ); + const centerDigits = OfflineInquiryService.normalizeDigits( + input.centerDigits, + ); + const ir = OfflineInquiryService.normalizeDigits(input.ir); + + const doc = await this.offlineInquiryModel + .findOne({ + clientKey, + inquiryType: "thirdPartyPlate", + nationalCode, + enabled: true, + "plate.leftDigits": leftDigits, + "plate.centerAlphabet": centerAlphabet, + "plate.centerDigits": centerDigits, + "plate.ir": ir, + }) + .lean() + .exec(); + + if (!doc?.inquiry?.raw || !doc?.inquiry?.mapped) { + return null; + } + + this.logger.log( + `[OFFLINE] Hit plate inquiry client=${clientKey} nationalCode=${nationalCode} plate=${doc.plateId ?? `${ir}-${leftDigits}-${centerAlphabet}-${centerDigits}`}`, + ); + + return { + raw: doc.inquiry.raw as Record, + mapped: doc.inquiry.mapped as Record, + source: doc.inquiry.source || "OFFLINE_SEED_INQUIRY", + fanavaranDriverId: doc.fanavaranDriverId, + insurance: doc.insurance, + person: doc.person, + plateId: doc.plateId, + label: doc.label, + }; + } +} diff --git a/src/offline-inquiry/schema/offline-inquiry.schema.ts b/src/offline-inquiry/schema/offline-inquiry.schema.ts new file mode 100644 index 0000000..3ad9061 --- /dev/null +++ b/src/offline-inquiry/schema/offline-inquiry.schema.ts @@ -0,0 +1,85 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { HydratedDocument } from "mongoose"; +import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config"; + +@Schema({ _id: false }) +export class OfflineInquiryPlateParts { + @Prop({ type: String, required: true }) + leftDigits: string; + + @Prop({ type: String, required: true }) + centerAlphabet: string; + + @Prop({ type: String, required: true }) + centerDigits: string; + + @Prop({ type: String, required: true }) + ir: string; +} +export const OfflineInquiryPlatePartsSchema = SchemaFactory.createForClass( + OfflineInquiryPlateParts, +); + +/** + * Captured plate/policy inquiry (+ optional Fanavaran driver id) for offline use + * when live ESG/Tejarat inquiry is unavailable (e.g. Parsian off their LAN). + */ +@Schema({ collection: "offlineInquiries", timestamps: true }) +export class OfflineInquiry { + @Prop({ type: String, required: true, index: true }) + clientKey: FanavaranClientKey; + + /** thirdPartyPlate | personalIdentity (extensible). */ + @Prop({ type: String, required: true, index: true, default: "thirdPartyPlate" }) + inquiryType: string; + + @Prop({ type: String, required: true, index: true }) + nationalCode: string; + + @Prop({ type: OfflineInquiryPlatePartsSchema, required: false }) + plate?: OfflineInquiryPlateParts; + + /** Human plate id e.g. 66-56-د-394 */ + @Prop({ type: String, required: false }) + plateId?: string; + + @Prop({ type: String, required: false }) + label?: string; + + @Prop({ type: Object, required: true }) + inquiry: { + source: string; + raw: Record; + mapped: Record; + }; + + @Prop({ type: Object, required: false }) + insurance?: Record; + + /** Cached Fanavaran DriverId from parties inquiry-by-unique-identifier. */ + @Prop({ type: Number, required: false }) + fanavaranDriverId?: number; + + @Prop({ type: Object, required: false }) + person?: Record; + + @Prop({ type: Boolean, default: true }) + enabled: boolean; +} + +export type OfflineInquiryDocument = HydratedDocument; +export const OfflineInquirySchema = + SchemaFactory.createForClass(OfflineInquiry); + +OfflineInquirySchema.index( + { + clientKey: 1, + inquiryType: 1, + nationalCode: 1, + "plate.leftDigits": 1, + "plate.centerAlphabet": 1, + "plate.centerDigits": 1, + "plate.ir": 1, + }, + { unique: true, sparse: true }, +); diff --git a/src/request-management/inquiry-refresh.service.ts b/src/request-management/inquiry-refresh.service.ts index 84f0697..e2c1df2 100644 --- a/src/request-management/inquiry-refresh.service.ts +++ b/src/request-management/inquiry-refresh.service.ts @@ -242,7 +242,7 @@ export class InquiryRefreshService { if (inquiry.mapped?.Error) { this.recordPartyInquiry(inquiries, "thirdParty", role, false, { - source: "TEJARAT_BLOCK_INQUIRY", + source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY", raw: inquiry.raw, mapped: inquiry.mapped, }); @@ -252,10 +252,18 @@ export class InquiryRefreshService { message: inquiry.mapped.Error.Message || "third-party inquiry error", }; } else { - nextParty = this.applyThirdPartyToParty(nextParty, inquiry.raw, inquiry.mapped); + nextParty = this.applyThirdPartyToParty( + nextParty, + inquiry.raw, + inquiry.mapped, + { + source: inquiry.offline?.source, + fanavaranDriverId: inquiry.offline?.fanavaranDriverId, + }, + ); partyChanged = true; this.recordPartyInquiry(inquiries, "thirdParty", role, true, { - source: "TEJARAT_BLOCK_INQUIRY", + source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY", raw: inquiry.raw, mapped: inquiry.mapped, refreshedAt: new Date().toISOString(), @@ -343,21 +351,30 @@ export class InquiryRefreshService { party: Record, raw: Record, mapped: Record, + extras?: { + source?: string; + fanavaranDriverId?: number; + }, ): Record { const next = { ...party }; next.vehicle = { ...(next.vehicle || {}) }; next.insurance = { ...(next.insurance || {}) }; + next.person = { ...(next.person || {}) }; const existingCarBody = next.vehicle.inquiry?.carBody; next.vehicle.inquiry = { - source: "TEJARAT_BLOCK_INQUIRY", + source: extras?.source ?? "TEJARAT_BLOCK_INQUIRY", raw, mapped, refreshedAt: new Date().toISOString(), ...(existingCarBody ? { carBody: existingCarBody } : {}), }; + if (extras?.fanavaranDriverId != null) { + next.person.fanavaranDriverId = extras.fanavaranDriverId; + } + const vehicleName = this.resolveVehicleName(mapped, raw); if (vehicleName) { next.vehicle.name = vehicleName; diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index c139497..dfe21d3 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -1180,6 +1180,7 @@ export class RequestManagementService { // ---- External inquiry 1: Tejarat block inquiry ---- let inquiryRaw: any; let inquiryMapped: any; + let inquirySource = "TEJARAT_BLOCK_INQUIRY"; const inquiryClientId = party.person?.clientId ? String(party.person.clientId) : undefined; @@ -1196,6 +1197,9 @@ export class RequestManagementService { ); inquiryRaw = inquiry.raw; inquiryMapped = inquiry.mapped; + if (inquiry.offline?.source) { + inquirySource = inquiry.offline.source; + } this.logger.log( `[TEJARAT] block inquiry raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`, ); @@ -1203,10 +1207,14 @@ export class RequestManagementService { `[TEJARAT] block inquiry mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`, ); this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, { - source: "TEJARAT_BLOCK_INQUIRY", + source: inquirySource, raw: inquiryRaw, mapped: inquiryMapped, }); + if (inquiry.offline?.fanavaranDriverId != null) { + if (!party.person) party.person = {} as any; + party.person.fanavaranDriverId = inquiry.offline.fanavaranDriverId; + } } catch (err: any) { this.logger.error( `[TEJARAT] block inquiry failed for request=${req._id}: ${err?.message || err}`, @@ -1378,7 +1386,7 @@ export class RequestManagementService { party.vehicle.name = inquiryMapped?.MapTypNam; party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`; party.vehicle.inquiry = { - source: "TEJARAT_BLOCK_INQUIRY", + source: inquirySource, raw: inquiryRaw, mapped: inquiryMapped, }; @@ -8546,6 +8554,7 @@ export class RequestManagementService { let inquiryRaw: any; let inquiryMapped: any; + let inquirySource = "TEJARAT_BLOCK_INQUIRY"; try { const inquiry = await this.sandHubService.getTejaratBlockInquiry( { @@ -8556,11 +8565,18 @@ export class RequestManagementService { ); inquiryRaw = inquiry.raw; inquiryMapped = inquiry.mapped; + if (inquiry.offline?.source) { + inquirySource = inquiry.offline.source; + } this.recordPartyCaseInquiryStatus(req, "thirdParty", partyRole, true, { - source: "TEJARAT_BLOCK_INQUIRY", + source: inquirySource, raw: inquiryRaw, mapped: inquiryMapped, }); + if (inquiry.offline?.fanavaranDriverId != null) { + if (!party.person) party.person = {} as any; + party.person.fanavaranDriverId = inquiry.offline.fanavaranDriverId; + } } catch (err: any) { this.logger.error( `[V3] plate inquiry failed for ${roleLabel} party (request=${req._id}): ${err?.message || err}`, @@ -8623,7 +8639,7 @@ export class RequestManagementService { party.vehicle.name = inquiryMapped?.MapTypNam; party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`; party.vehicle.inquiry = { - source: "TEJARAT_BLOCK_INQUIRY", + source: inquirySource, raw: inquiryRaw, mapped: inquiryMapped, }; diff --git a/src/sand-hub/sand-hub.module.ts b/src/sand-hub/sand-hub.module.ts index 3b2dc12..2385780 100644 --- a/src/sand-hub/sand-hub.module.ts +++ b/src/sand-hub/sand-hub.module.ts @@ -6,6 +6,7 @@ import { MongooseModule } from "@nestjs/mongoose"; import { ClientModule } from "src/client/client.module"; import { SystemSettingsModule } from "src/system-settings/system-settings.module"; import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module"; +import { OfflineInquiryModule } from "src/offline-inquiry/offline-inquiry.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"; @@ -20,6 +21,7 @@ import { SandHubService } from "./sand-hub.service"; SystemSettingsModule, PlateNormalizerModule, ClientModule, + OfflineInquiryModule, MongooseModule.forFeature([ { name: SandHubModel.name, schema: SandHubSchema }, ]), diff --git a/src/sand-hub/sand-hub.service.spec.ts b/src/sand-hub/sand-hub.service.spec.ts index 977299b..284b02e 100644 --- a/src/sand-hub/sand-hub.service.spec.ts +++ b/src/sand-hub/sand-hub.service.spec.ts @@ -34,6 +34,9 @@ describe("SandHubService inquiry mocks", () => { sandHubDbService as any, externalInquirySettings as unknown as ExternalInquirySettingsService, plateNormalizer as any, + { + findPlateInquiry: jest.fn().mockResolvedValue(null), + } as any, ); externalInquirySettings.isInquiryLive.mockResolvedValue(false); externalInquirySettings.getMockCompanyContext.mockResolvedValue({ diff --git a/src/sand-hub/sand-hub.service.ts b/src/sand-hub/sand-hub.service.ts index 653c9ee..677d030 100644 --- a/src/sand-hub/sand-hub.service.ts +++ b/src/sand-hub/sand-hub.service.ts @@ -15,6 +15,8 @@ import { ExternalInquirySettingsService } from "src/client/external-inquiry-sett import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service"; import type { ExternalInquiryType } from "src/common/types/external-inquiry.types"; import type { MockInquiryCompanyContext } from "src/common/types/external-inquiry.types"; +import { OfflineInquiryService } from "src/offline-inquiry/offline-inquiry.service"; +import { resolveFanavaranClientKey } from "src/core/config/fanavaran-client.config"; import { SandHubDetailDto, SandHubInquiryOptions } from "./dto/sand-hub.dto"; import { jalaliToGregorianDate } from "src/helpers/date-jalali"; import { firstValueFrom } from "rxjs"; @@ -41,6 +43,7 @@ export class SandHubService { private readonly sandHubDbService: SandHubDbService, private readonly externalInquirySettings: ExternalInquirySettingsService, private readonly plateNormalizer: PlateNormalizerService, + private readonly offlineInquiryService: OfflineInquiryService, ) {} private clientRefFrom(options?: SandHubInquiryOptions): string | undefined { @@ -731,6 +734,7 @@ export class SandHubService { /** * Tejarat block inquiry (replaces SandHub call for V2 flows). * Returns both raw + mapped (old-format) response. + * Offline seeded hits (per Fanavaran clientKey) are checked first. */ async getTejaratBlockInquiry( userDetail: SandHubDetailDto, @@ -738,7 +742,34 @@ export class SandHubService { ): Promise<{ raw: any; mapped: any; + offline?: { + source: string; + fanavaranDriverId?: number; + insurance?: Record; + person?: Record; + }; }> { + const offlineHit = await this.offlineInquiryService.findPlateInquiry({ + clientKey: resolveFanavaranClientKey(), + nationalCode: String(userDetail.nationalCodeOfInsurer), + leftDigits: String(userDetail.plate.leftDigits), + centerAlphabet: String(userDetail.plate.centerAlphabet), + centerDigits: String(userDetail.plate.centerDigits), + ir: String(userDetail.plate.ir), + }); + if (offlineHit) { + return { + raw: offlineHit.raw, + mapped: offlineHit.mapped, + offline: { + source: offlineHit.source, + fanavaranDriverId: offlineHit.fanavaranDriverId, + insurance: offlineHit.insurance, + person: offlineHit.person, + }, + }; + } + const ctx = await this.mockCompanyContext(options); if (this.shouldUseEsgInquiryProvider()) { const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085"; diff --git a/src/system-settings/dto/system-settings.dto.ts b/src/system-settings/dto/system-settings.dto.ts index 8e69a20..91255b2 100644 --- a/src/system-settings/dto/system-settings.dto.ts +++ b/src/system-settings/dto/system-settings.dto.ts @@ -13,12 +13,38 @@ export class ExternalApisSettingsDto { sandHubUseLiveApi?: boolean; } +export class OfflineInquirySettingsDto { + @ApiPropertyOptional({ + description: + "Master switch for seeded offline plate inquiries. When false, `offlineInquiries` hits are skipped.", + example: true, + }) + @IsOptional() + @IsBoolean() + enabled?: boolean; +} + export class UpdateSystemSettingsDto { @ApiPropertyOptional({ type: ExternalApisSettingsDto }) @IsOptional() @ValidateNested() @Type(() => ExternalApisSettingsDto) externalApis?: ExternalApisSettingsDto; + + @ApiPropertyOptional({ type: OfflineInquirySettingsDto }) + @IsOptional() + @ValidateNested() + @Type(() => OfflineInquirySettingsDto) + offlineInquiry?: OfflineInquirySettingsDto; +} + +export class UpdateOfflineInquirySettingsDto { + @ApiProperty({ + description: "Enable or disable offline seeded plate inquiries globally", + example: true, + }) + @IsBoolean() + enabled: boolean; } export class ExternalApisSettingsViewDto { @@ -30,6 +56,15 @@ export class ExternalApisSettingsViewDto { sandHubUseLiveApi: boolean; } +export class OfflineInquirySettingsViewDto { + @ApiProperty({ + description: + "Stored in `system_settings.offlineInquiry.enabled` — when false, offline seeds are ignored", + example: true, + }) + enabled: boolean; +} + export class SystemSettingsResponseDto { @ApiProperty({ example: "global" }) key: string; @@ -37,6 +72,9 @@ export class SystemSettingsResponseDto { @ApiProperty({ type: ExternalApisSettingsViewDto }) externalApis: ExternalApisSettingsViewDto; + @ApiProperty({ type: OfflineInquirySettingsViewDto }) + offlineInquiry: OfflineInquirySettingsViewDto; + @ApiProperty({ description: "Human-readable mode for operators", example: "mock", diff --git a/src/system-settings/entities/schema/system-settings.schema.ts b/src/system-settings/entities/schema/system-settings.schema.ts index 03298ea..89b16c8 100644 --- a/src/system-settings/entities/schema/system-settings.schema.ts +++ b/src/system-settings/entities/schema/system-settings.schema.ts @@ -17,6 +17,16 @@ export class ExternalApisSettings { sandHubUseLiveApi?: boolean; } +/** + * Master switch for seeded offline plate inquiries (`offlineInquiries` collection). + * When false, lookup is skipped and live/mock inquiry paths run as usual. + * Default true when unset (seeds remain usable without an explicit migrate). + */ +export class OfflineInquirySettings { + @Prop({ type: Boolean, required: false, default: true }) + enabled?: boolean; +} + @Schema({ collection: "system_settings", versionKey: false }) export class SystemSettingsModel { @Prop({ required: true, unique: true, default: SYSTEM_SETTINGS_GLOBAL_KEY }) @@ -24,6 +34,9 @@ export class SystemSettingsModel { @Prop({ type: ExternalApisSettings, required: false, default: {} }) externalApis?: ExternalApisSettings; + + @Prop({ type: OfflineInquirySettings, required: false, default: {} }) + offlineInquiry?: OfflineInquirySettings; } export const SystemSettingsSchema = diff --git a/src/system-settings/system-settings.controller.ts b/src/system-settings/system-settings.controller.ts index 5915851..20b54f2 100644 --- a/src/system-settings/system-settings.controller.ts +++ b/src/system-settings/system-settings.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Patch, UseGuards } from "@nestjs/common"; +import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, @@ -10,11 +10,23 @@ import { RolesGuard } from "src/auth/guards/role.guard"; import { Roles } from "src/decorators/roles.decorator"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { + OfflineInquirySettingsViewDto, SystemSettingsResponseDto, + UpdateOfflineInquirySettingsDto, UpdateSystemSettingsDto, } from "./dto/system-settings.dto"; import { SystemSettingsService } from "./system-settings.service"; +const EXPERT_AND_ADMIN_ROLES = [ + RoleEnum.ADMIN, + RoleEnum.SUPER_ADMIN, + RoleEnum.EXPERT, + RoleEnum.DAMAGE_EXPERT, + RoleEnum.FIELD_EXPERT, + RoleEnum.FILE_MAKER, + RoleEnum.FILE_REVIEWER, +] as const; + @ApiTags("system-settings") @ApiBearerAuth() @Controller("system-settings") @@ -27,7 +39,7 @@ export class SystemSettingsController { @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.", + "Shows whether SandHub/Tejarat live HTTP is enabled, and whether offline seeded inquiries are enabled.", }) @ApiResponse({ status: 200, type: SystemSettingsResponseDto }) getSettings(): Promise { @@ -40,7 +52,7 @@ export class SystemSettingsController { @ApiOperation({ summary: "Update global system settings", description: - "Set `externalApis.sandHubUseLiveApi` to true for live inquiries, false for mock/offline mode.", + "Set `externalApis.sandHubUseLiveApi` and/or `offlineInquiry.enabled`.", }) @ApiResponse({ status: 200, type: SystemSettingsResponseDto }) updateSettings( @@ -48,4 +60,55 @@ export class SystemSettingsController { ): Promise { return this.systemSettingsService.updateSettings(body); } + + @Post("fanavaran-config/reload") + @UseGuards(SettingsJwtGuard, RolesGuard) + @Roles(...EXPERT_AND_ADMIN_ROLES) + @ApiOperation({ + summary: "Reload Fanavaran client config cache from Mongo", + description: + "Re-reads `fanavaranClientConfigs` into the runtime profile cache. If auth fields changed, cached Fanavaran tokens are invalidated so the next call re-logins. Allowed for admin and expert roles.", + }) + @ApiResponse({ + status: 200, + description: "Cache reloaded", + schema: { + type: "object", + properties: { + ok: { type: "boolean", example: true }, + message: { type: "string" }, + }, + }, + }) + reloadFanavaranConfigCache(): Promise<{ ok: true; message: string }> { + return this.systemSettingsService.reloadFanavaranConfigCache(); + } + + @Get("offline-inquiry") + @UseGuards(SettingsJwtGuard, RolesGuard) + @Roles(...EXPERT_AND_ADMIN_ROLES) + @ApiOperation({ + summary: "Get offline inquiry master switch", + description: + "When enabled, matching `offlineInquiries` seeds short-circuit live plate inquiry. When disabled, live/mock paths run as usual.", + }) + @ApiResponse({ status: 200, type: OfflineInquirySettingsViewDto }) + getOfflineInquirySettings(): Promise { + return this.systemSettingsService.getOfflineInquirySettings(); + } + + @Patch("offline-inquiry") + @UseGuards(SettingsJwtGuard, RolesGuard) + @Roles(...EXPERT_AND_ADMIN_ROLES) + @ApiOperation({ + summary: "Enable or disable offline seeded plate inquiries", + description: + "Global master switch stored in `system_settings.offlineInquiry.enabled`. Per-seed `enabled` flags still apply when this is on.", + }) + @ApiResponse({ status: 200, type: OfflineInquirySettingsViewDto }) + updateOfflineInquirySettings( + @Body() body: UpdateOfflineInquirySettingsDto, + ): Promise { + return this.systemSettingsService.updateOfflineInquirySettings(body); + } } diff --git a/src/system-settings/system-settings.module.ts b/src/system-settings/system-settings.module.ts index 79cf9cb..eab5824 100644 --- a/src/system-settings/system-settings.module.ts +++ b/src/system-settings/system-settings.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { MongooseModule } from "@nestjs/mongoose"; +import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module"; import { SystemSettingsDbService } from "./entities/db-service/system-settings.db.service"; import { SystemSettingsModel, @@ -13,6 +14,7 @@ import { SystemSettingsService } from "./system-settings.service"; MongooseModule.forFeature([ { name: SystemSettingsModel.name, schema: SystemSettingsSchema }, ]), + FanavaranLookupModule, ], controllers: [SystemSettingsController], providers: [SystemSettingsService, SystemSettingsDbService], diff --git a/src/system-settings/system-settings.service.spec.ts b/src/system-settings/system-settings.service.spec.ts index 108c5b1..0b60c97 100644 --- a/src/system-settings/system-settings.service.spec.ts +++ b/src/system-settings/system-settings.service.spec.ts @@ -5,12 +5,18 @@ describe("SystemSettingsService", () => { findGlobal: jest.fn(), upsertGlobal: jest.fn(), }; + const fanavaranClientConfigService = { + reloadCache: jest.fn().mockResolvedValue(undefined), + }; let service: SystemSettingsService; beforeEach(() => { jest.clearAllMocks(); - service = new SystemSettingsService(db as any); + service = new SystemSettingsService( + db as any, + fanavaranClientConfigService as any, + ); }); it("defaults to mock when DB field is false", async () => { @@ -28,4 +34,41 @@ describe("SystemSettingsService", () => { }); await expect(service.isSandHubLiveEnabled()).resolves.toBe(true); }); + + it("treats unset offlineInquiry as enabled", async () => { + db.findGlobal.mockResolvedValue({ + key: "global", + externalApis: { sandHubUseLiveApi: false }, + }); + await expect(service.isOfflineInquiryEnabled()).resolves.toBe(true); + }); + + it("disables offline inquiry when explicitly false", async () => { + db.findGlobal.mockResolvedValue({ + key: "global", + offlineInquiry: { enabled: false }, + }); + await expect(service.isOfflineInquiryEnabled()).resolves.toBe(false); + }); + + it("updates offline inquiry master switch", async () => { + db.upsertGlobal.mockResolvedValue({ + key: "global", + offlineInquiry: { enabled: false }, + }); + await expect( + service.updateOfflineInquirySettings({ enabled: false }), + ).resolves.toEqual({ enabled: false }); + expect(db.upsertGlobal).toHaveBeenCalledWith({ + $set: { "offlineInquiry.enabled": false }, + }); + }); + + it("reloads Fanavaran client config cache", async () => { + await expect(service.reloadFanavaranConfigCache()).resolves.toEqual({ + ok: true, + message: "Fanavaran client config cache reloaded", + }); + expect(fanavaranClientConfigService.reloadCache).toHaveBeenCalled(); + }); }); diff --git a/src/system-settings/system-settings.service.ts b/src/system-settings/system-settings.service.ts index 36cdcc7..ef9eedc 100644 --- a/src/system-settings/system-settings.service.ts +++ b/src/system-settings/system-settings.service.ts @@ -1,6 +1,9 @@ import { Injectable, Logger } from "@nestjs/common"; +import { FanavaranClientConfigService } from "src/fanavaran/fanavaran-client-config.service"; import { + OfflineInquirySettingsViewDto, SystemSettingsResponseDto, + UpdateOfflineInquirySettingsDto, UpdateSystemSettingsDto, } from "./dto/system-settings.dto"; import { SystemSettingsDbService } from "./entities/db-service/system-settings.db.service"; @@ -12,7 +15,10 @@ export class SystemSettingsService { private cacheAt = 0; private readonly cacheTtlMs = 15_000; - constructor(private readonly db: SystemSettingsDbService) {} + constructor( + private readonly db: SystemSettingsDbService, + private readonly fanavaranClientConfigService: FanavaranClientConfigService, + ) {} private async loadGlobal(): Promise> { const now = Date.now(); @@ -22,10 +28,13 @@ export class SystemSettingsService { let doc = await this.db.findGlobal(); if (!doc) { doc = await this.db.upsertGlobal({ - $set: { externalApis: { sandHubUseLiveApi: false } }, + $set: { + externalApis: { sandHubUseLiveApi: false }, + offlineInquiry: { enabled: true }, + }, }); this.logger.log( - "Created default system_settings document (sandHubUseLiveApi=false)", + "Created default system_settings document (sandHubUseLiveApi=false, offlineInquiry.enabled=true)", ); } this.cache = doc; @@ -38,6 +47,14 @@ export class SystemSettingsService { this.cacheAt = 0; } + private readOfflineInquiryEnabled(doc: Record): boolean { + const offlineInquiry = doc.offlineInquiry as + | { enabled?: boolean } + | undefined; + // Unset → enabled (preserve prior always-on seed behavior). + return offlineInquiry?.enabled !== false; + } + /** * Whether SandHub/Tejarat live HTTP should run (`system_settings` collection). */ @@ -49,6 +66,18 @@ export class SystemSettingsService { return externalApis?.sandHubUseLiveApi === true; } + /** + * Whether seeded offline plate inquiries may short-circuit live/mock inquiry. + */ + async isOfflineInquiryEnabled(): Promise { + const doc = await this.loadGlobal(); + return this.readOfflineInquiryEnabled(doc); + } + + async getOfflineInquirySettings(): Promise { + return { enabled: await this.isOfflineInquiryEnabled() }; + } + async getSettingsView(): Promise { const doc = await this.loadGlobal(); const externalApis = doc.externalApis as @@ -58,6 +87,7 @@ export class SystemSettingsService { return { key: String(doc.key ?? "global"), externalApis: { sandHubUseLiveApi }, + offlineInquiry: { enabled: this.readOfflineInquiryEnabled(doc) }, sandHubMode: sandHubUseLiveApi ? "live" : "mock", }; } @@ -69,6 +99,9 @@ export class SystemSettingsService { if (body.externalApis?.sandHubUseLiveApi !== undefined) { $set["externalApis.sandHubUseLiveApi"] = body.externalApis.sandHubUseLiveApi; } + if (body.offlineInquiry?.enabled !== undefined) { + $set["offlineInquiry.enabled"] = body.offlineInquiry.enabled; + } if (Object.keys($set).length > 0) { await this.db.upsertGlobal({ $set }); this.invalidateCache(); @@ -76,4 +109,30 @@ export class SystemSettingsService { } return this.getSettingsView(); } + + async updateOfflineInquirySettings( + body: UpdateOfflineInquirySettingsDto, + ): Promise { + await this.db.upsertGlobal({ + $set: { "offlineInquiry.enabled": body.enabled }, + }); + this.invalidateCache(); + this.logger.log( + `Offline inquiry master switch set to enabled=${body.enabled}`, + ); + return { enabled: body.enabled }; + } + + /** + * Re-read `fanavaranClientConfigs` into the in-memory profile cache. + * Auth fingerprint changes invalidate cached Fanavaran tokens. + */ + async reloadFanavaranConfigCache(): Promise<{ ok: true; message: string }> { + await this.fanavaranClientConfigService.reloadCache(); + this.logger.log("Fanavaran client config cache reloaded via system-settings API"); + return { + ok: true, + message: "Fanavaran client config cache reloaded", + }; + } }