Compare commits

...

6 Commits

19 changed files with 965 additions and 26 deletions

View File

@@ -4,7 +4,12 @@ describe("FanavaranAuthService", () => {
const createAuthTokenModel = () => { const createAuthTokenModel = () => {
const store = new Map< const store = new Map<
string, string,
{ clientKey: string; authenticationToken: string; expiresAt: Date } {
clientKey: string;
authenticationToken: string;
expiresAt: Date;
authFingerprint?: string;
}
>(); >();
return { return {
findOne: jest.fn((query: { clientKey: string }) => ({ findOne: jest.fn((query: { clientKey: string }) => ({
@@ -15,13 +20,20 @@ describe("FanavaranAuthService", () => {
findOneAndUpdate: jest.fn( findOneAndUpdate: jest.fn(
( (
query: { clientKey: string }, query: { clientKey: string },
update: { $set: { authenticationToken: string; expiresAt: Date } }, update: {
$set: {
authenticationToken: string;
expiresAt: Date;
authFingerprint?: string;
};
},
) => ({ ) => ({
exec: async () => { exec: async () => {
const next = { const next = {
clientKey: query.clientKey, clientKey: query.clientKey,
authenticationToken: update.$set.authenticationToken, authenticationToken: update.$set.authenticationToken,
expiresAt: update.$set.expiresAt, expiresAt: update.$set.expiresAt,
authFingerprint: update.$set.authFingerprint,
}; };
store.set(query.clientKey, next); store.set(query.clientKey, next);
return next; return next;

View File

@@ -30,6 +30,8 @@ interface CachedFanavaranAuth {
authenticationToken: string; authenticationToken: string;
/** Epoch ms when the cached token should be refreshed. */ /** Epoch ms when the cached token should be refreshed. */
expiresAt: number; expiresAt: number;
/** Hash of auth fields used when this token was obtained. */
authFingerprint: string;
} }
interface TenantBackoffState { 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 { assertNotInBackoff(clientKey: FanavaranClientKey): void {
const remaining = this.getBackoffRemainingMs(clientKey); const remaining = this.getBackoffRemainingMs(clientKey);
if (remaining <= 0) return; if (remaining <= 0) return;
@@ -195,13 +213,16 @@ export class FanavaranAuthService {
}, },
): Promise<string> { ): Promise<string> {
this.assertNotInBackoff(clientKey); this.assertNotInBackoff(clientKey);
const fingerprint = FanavaranAuthService.authFingerprint(
getFanavaranClientProfile(clientKey).auth,
);
if (!options?.forceRefresh) { if (!options?.forceRefresh) {
const memoryHit = this.readMemoryCache(clientKey); const memoryHit = this.readMemoryCache(clientKey, fingerprint);
if (memoryHit) { if (memoryHit) {
return memoryHit; return memoryHit;
} }
const persisted = await this.readPersistedCache(clientKey); const persisted = await this.readPersistedCache(clientKey, fingerprint);
if (persisted) { if (persisted) {
return persisted; return persisted;
} }
@@ -253,6 +274,7 @@ export class FanavaranAuthService {
auditSession?: FanavaranAuditSession, auditSession?: FanavaranAuditSession,
): Promise<string> { ): Promise<string> {
const profile = getFanavaranClientProfile(clientKey); const profile = getFanavaranClientProfile(clientKey);
const fingerprint = FanavaranAuthService.authFingerprint(profile.auth);
const appToken = await this.fetchAppToken(profile.auth, auditSession); const appToken = await this.fetchAppToken(profile.auth, auditSession);
const authenticationToken = await this.fetchLoginToken( const authenticationToken = await this.fetchLoginToken(
appToken, appToken,
@@ -261,7 +283,12 @@ export class FanavaranAuthService {
); );
const expiresAt = FanavaranAuthService.getNextMidnightExpiryMs(); const expiresAt = FanavaranAuthService.getNextMidnightExpiryMs();
await this.persistToken(clientKey, authenticationToken, expiresAt); await this.persistToken(
clientKey,
authenticationToken,
expiresAt,
fingerprint,
);
this.clearBackoff(clientKey); this.clearBackoff(clientKey);
this.logger.log( this.logger.log(
`[${clientKey}] Cached Fanavaran authenticationToken until ${new Date( `[${clientKey}] Cached Fanavaran authenticationToken until ${new Date(
@@ -271,25 +298,44 @@ export class FanavaranAuthService {
return authenticationToken; return authenticationToken;
} }
private readMemoryCache(clientKey: FanavaranClientKey): string | null { private readMemoryCache(
clientKey: FanavaranClientKey,
fingerprint: string,
): string | null {
const cached = this.tokenCache.get(clientKey); 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; return cached.authenticationToken;
} }
if (cached) {
this.tokenCache.delete(clientKey); this.tokenCache.delete(clientKey);
}
return null; return null;
} }
private async readPersistedCache( private async readPersistedCache(
clientKey: FanavaranClientKey, clientKey: FanavaranClientKey,
fingerprint: string,
): Promise<string | null> { ): Promise<string | null> {
try { try {
const doc = await this.authTokenModel.findOne({ clientKey }).lean().exec(); const doc = await this.authTokenModel.findOne({ clientKey }).lean().exec();
if (!doc?.authenticationToken || !doc.expiresAt) { if (!doc?.authenticationToken || !doc.expiresAt) {
return null; 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(); const expiresAt = new Date(doc.expiresAt).getTime();
if (!(expiresAt > Date.now())) { if (!(expiresAt > Date.now())) {
await this.authTokenModel.deleteOne({ clientKey }).exec(); await this.authTokenModel.deleteOne({ clientKey }).exec();
@@ -298,6 +344,7 @@ export class FanavaranAuthService {
this.tokenCache.set(clientKey, { this.tokenCache.set(clientKey, {
authenticationToken: doc.authenticationToken, authenticationToken: doc.authenticationToken,
expiresAt, expiresAt,
authFingerprint: doc.authFingerprint,
}); });
this.logger.log( this.logger.log(
`[${clientKey}] Reused persisted Fanavaran authenticationToken until ${new Date( `[${clientKey}] Reused persisted Fanavaran authenticationToken until ${new Date(
@@ -318,8 +365,13 @@ export class FanavaranAuthService {
clientKey: FanavaranClientKey, clientKey: FanavaranClientKey,
authenticationToken: string, authenticationToken: string,
expiresAt: number, expiresAt: number,
authFingerprint: string,
): Promise<void> { ): Promise<void> {
this.tokenCache.set(clientKey, { authenticationToken, expiresAt }); this.tokenCache.set(clientKey, {
authenticationToken,
expiresAt,
authFingerprint,
});
try { try {
await this.authTokenModel await this.authTokenModel
.findOneAndUpdate( .findOneAndUpdate(
@@ -328,6 +380,7 @@ export class FanavaranAuthService {
$set: { $set: {
authenticationToken, authenticationToken,
expiresAt: new Date(expiresAt), expiresAt: new Date(expiresAt),
authFingerprint,
}, },
}, },
{ upsert: true, new: true }, { upsert: true, new: true },

View File

@@ -4,10 +4,12 @@ import { Model } from "mongoose";
import { import {
FANAVARAN_CLIENT_KEYS, FANAVARAN_CLIENT_KEYS,
SEED_FANAVARAN_CLIENT_PROFILES, SEED_FANAVARAN_CLIENT_PROFILES,
getFanavaranClientProfile,
setFanavaranClientProfilesCache, setFanavaranClientProfilesCache,
type FanavaranClientKey, type FanavaranClientKey,
type FanavaranClientProfile, type FanavaranClientProfile,
} from "src/core/config/fanavaran-client.config"; } from "src/core/config/fanavaran-client.config";
import { FanavaranAuthService } from "./fanavaran-auth.service";
import { import {
FanavaranClientConfig, FanavaranClientConfig,
FanavaranClientConfigDocument, FanavaranClientConfigDocument,
@@ -27,6 +29,7 @@ export class FanavaranClientConfigService implements OnModuleInit {
constructor( constructor(
@InjectModel(FanavaranClientConfig.name) @InjectModel(FanavaranClientConfig.name)
private readonly configModel: Model<FanavaranClientConfigDocument>, private readonly configModel: Model<FanavaranClientConfigDocument>,
private readonly fanavaranAuthService: FanavaranAuthService,
) {} ) {}
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
@@ -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<void> { async reloadCache(): Promise<void> {
const previousFingerprints = new Map<FanavaranClientKey, string>();
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 docs = await this.configModel.find().lean().exec();
const cache: Partial<Record<FanavaranClientKey, FanavaranClientProfile>> = const cache: Partial<Record<FanavaranClientKey, FanavaranClientProfile>> =
{}; {};
@@ -83,6 +105,18 @@ export class FanavaranClientConfigService implements OnModuleInit {
} }
setFanavaranClientProfilesCache(cache); 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( this.logger.log(
`Loaded ${docs.length} Fanavaran client config(s) from Mongo into runtime cache`, `Loaded ${docs.length} Fanavaran client config(s) from Mongo into runtime cache`,
); );

View File

@@ -14,6 +14,13 @@ export class FanavaranAuthToken {
@Prop({ type: String, required: true }) @Prop({ type: String, required: true })
authenticationToken: string; 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). */ /** When this token should be refreshed (Asia/Tehran midnight). */
@Prop({ type: Date, required: true, index: true }) @Prop({ type: Date, required: true, index: true })
expiresAt: Date; expiresAt: Date;

View File

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

View File

@@ -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<string, unknown>;
insurance?: Record<string, unknown>;
inquiry: {
source: string;
raw: Record<string, unknown>;
mapped: Record<string, unknown>;
};
}> = [
{
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: "سيدنويد صالحي",
},
},
},
];

View File

@@ -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<string, unknown>;
mapped: Record<string, unknown>;
source: string;
fanavaranDriverId?: number;
insurance?: Record<string, unknown>;
person?: Record<string, unknown>;
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<OfflineInquiryDocument>,
private readonly systemSettingsService: SystemSettingsService,
) {}
async onModuleInit(): Promise<void> {
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<void> {
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<OfflinePlateInquiryHit | null> {
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<string, unknown>,
mapped: doc.inquiry.mapped as Record<string, unknown>,
source: doc.inquiry.source || "OFFLINE_SEED_INQUIRY",
fanavaranDriverId: doc.fanavaranDriverId,
insurance: doc.insurance,
person: doc.person,
plateId: doc.plateId,
label: doc.label,
};
}
}

View File

@@ -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<string, unknown>;
mapped: Record<string, unknown>;
};
@Prop({ type: Object, required: false })
insurance?: Record<string, unknown>;
/** Cached Fanavaran DriverId from parties inquiry-by-unique-identifier. */
@Prop({ type: Number, required: false })
fanavaranDriverId?: number;
@Prop({ type: Object, required: false })
person?: Record<string, unknown>;
@Prop({ type: Boolean, default: true })
enabled: boolean;
}
export type OfflineInquiryDocument = HydratedDocument<OfflineInquiry>;
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 },
);

View File

@@ -242,7 +242,7 @@ export class InquiryRefreshService {
if (inquiry.mapped?.Error) { if (inquiry.mapped?.Error) {
this.recordPartyInquiry(inquiries, "thirdParty", role, false, { this.recordPartyInquiry(inquiries, "thirdParty", role, false, {
source: "TEJARAT_BLOCK_INQUIRY", source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY",
raw: inquiry.raw, raw: inquiry.raw,
mapped: inquiry.mapped, mapped: inquiry.mapped,
}); });
@@ -252,10 +252,18 @@ export class InquiryRefreshService {
message: inquiry.mapped.Error.Message || "third-party inquiry error", message: inquiry.mapped.Error.Message || "third-party inquiry error",
}; };
} else { } 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; partyChanged = true;
this.recordPartyInquiry(inquiries, "thirdParty", role, true, { this.recordPartyInquiry(inquiries, "thirdParty", role, true, {
source: "TEJARAT_BLOCK_INQUIRY", source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY",
raw: inquiry.raw, raw: inquiry.raw,
mapped: inquiry.mapped, mapped: inquiry.mapped,
refreshedAt: new Date().toISOString(), refreshedAt: new Date().toISOString(),
@@ -343,21 +351,30 @@ export class InquiryRefreshService {
party: Record<string, any>, party: Record<string, any>,
raw: Record<string, any>, raw: Record<string, any>,
mapped: Record<string, any>, mapped: Record<string, any>,
extras?: {
source?: string;
fanavaranDriverId?: number;
},
): Record<string, any> { ): Record<string, any> {
const next = { ...party }; const next = { ...party };
next.vehicle = { ...(next.vehicle || {}) }; next.vehicle = { ...(next.vehicle || {}) };
next.insurance = { ...(next.insurance || {}) }; next.insurance = { ...(next.insurance || {}) };
next.person = { ...(next.person || {}) };
const existingCarBody = next.vehicle.inquiry?.carBody; const existingCarBody = next.vehicle.inquiry?.carBody;
next.vehicle.inquiry = { next.vehicle.inquiry = {
source: "TEJARAT_BLOCK_INQUIRY", source: extras?.source ?? "TEJARAT_BLOCK_INQUIRY",
raw, raw,
mapped, mapped,
refreshedAt: new Date().toISOString(), refreshedAt: new Date().toISOString(),
...(existingCarBody ? { carBody: existingCarBody } : {}), ...(existingCarBody ? { carBody: existingCarBody } : {}),
}; };
if (extras?.fanavaranDriverId != null) {
next.person.fanavaranDriverId = extras.fanavaranDriverId;
}
const vehicleName = this.resolveVehicleName(mapped, raw); const vehicleName = this.resolveVehicleName(mapped, raw);
if (vehicleName) { if (vehicleName) {
next.vehicle.name = vehicleName; next.vehicle.name = vehicleName;

View File

@@ -1223,6 +1223,7 @@ export class RequestManagementService {
// ---- External inquiry 1: Tejarat block inquiry ---- // ---- External inquiry 1: Tejarat block inquiry ----
let inquiryRaw: any; let inquiryRaw: any;
let inquiryMapped: any; let inquiryMapped: any;
let inquirySource = "TEJARAT_BLOCK_INQUIRY";
const inquiryClientId = party.person?.clientId const inquiryClientId = party.person?.clientId
? String(party.person.clientId) ? String(party.person.clientId)
: undefined; : undefined;
@@ -1239,6 +1240,9 @@ export class RequestManagementService {
); );
inquiryRaw = inquiry.raw; inquiryRaw = inquiry.raw;
inquiryMapped = inquiry.mapped; inquiryMapped = inquiry.mapped;
if (inquiry.offline?.source) {
inquirySource = inquiry.offline.source;
}
this.logger.log( this.logger.log(
`[TEJARAT] block inquiry raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`, `[TEJARAT] block inquiry raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`,
); );
@@ -1246,10 +1250,14 @@ export class RequestManagementService {
`[TEJARAT] block inquiry mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`, `[TEJARAT] block inquiry mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`,
); );
this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, { this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, {
source: "TEJARAT_BLOCK_INQUIRY", source: inquirySource,
raw: inquiryRaw, raw: inquiryRaw,
mapped: inquiryMapped, mapped: inquiryMapped,
}); });
if (inquiry.offline?.fanavaranDriverId != null) {
if (!party.person) party.person = {} as any;
party.person.fanavaranDriverId = inquiry.offline.fanavaranDriverId;
}
} catch (err: any) { } catch (err: any) {
this.logger.error( this.logger.error(
`[TEJARAT] block inquiry failed for request=${req._id}: ${err?.message || err}`, `[TEJARAT] block inquiry failed for request=${req._id}: ${err?.message || err}`,
@@ -1421,7 +1429,7 @@ export class RequestManagementService {
party.vehicle.name = inquiryMapped?.MapTypNam; party.vehicle.name = inquiryMapped?.MapTypNam;
party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`; party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`;
party.vehicle.inquiry = { party.vehicle.inquiry = {
source: "TEJARAT_BLOCK_INQUIRY", source: inquirySource,
raw: inquiryRaw, raw: inquiryRaw,
mapped: inquiryMapped, mapped: inquiryMapped,
}; };
@@ -8602,6 +8610,7 @@ export class RequestManagementService {
let inquiryRaw: any; let inquiryRaw: any;
let inquiryMapped: any; let inquiryMapped: any;
let inquirySource = "TEJARAT_BLOCK_INQUIRY";
try { try {
const inquiry = await this.sandHubService.getTejaratBlockInquiry( const inquiry = await this.sandHubService.getTejaratBlockInquiry(
{ {
@@ -8612,11 +8621,18 @@ export class RequestManagementService {
); );
inquiryRaw = inquiry.raw; inquiryRaw = inquiry.raw;
inquiryMapped = inquiry.mapped; inquiryMapped = inquiry.mapped;
if (inquiry.offline?.source) {
inquirySource = inquiry.offline.source;
}
this.recordPartyCaseInquiryStatus(req, "thirdParty", partyRole, true, { this.recordPartyCaseInquiryStatus(req, "thirdParty", partyRole, true, {
source: "TEJARAT_BLOCK_INQUIRY", source: inquirySource,
raw: inquiryRaw, raw: inquiryRaw,
mapped: inquiryMapped, mapped: inquiryMapped,
}); });
if (inquiry.offline?.fanavaranDriverId != null) {
if (!party.person) party.person = {} as any;
party.person.fanavaranDriverId = inquiry.offline.fanavaranDriverId;
}
} catch (err: any) { } catch (err: any) {
this.logger.error( this.logger.error(
`[V3] plate inquiry failed for ${roleLabel} party (request=${req._id}): ${err?.message || err}`, `[V3] plate inquiry failed for ${roleLabel} party (request=${req._id}): ${err?.message || err}`,
@@ -8679,7 +8695,7 @@ export class RequestManagementService {
party.vehicle.name = inquiryMapped?.MapTypNam; party.vehicle.name = inquiryMapped?.MapTypNam;
party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`; party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`;
party.vehicle.inquiry = { party.vehicle.inquiry = {
source: "TEJARAT_BLOCK_INQUIRY", source: inquirySource,
raw: inquiryRaw, raw: inquiryRaw,
mapped: inquiryMapped, mapped: inquiryMapped,
}; };

View File

@@ -6,6 +6,7 @@ import { MongooseModule } from "@nestjs/mongoose";
import { ClientModule } from "src/client/client.module"; import { ClientModule } from "src/client/client.module";
import { SystemSettingsModule } from "src/system-settings/system-settings.module"; import { SystemSettingsModule } from "src/system-settings/system-settings.module";
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.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 { 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";
@@ -20,6 +21,7 @@ import { SandHubService } from "./sand-hub.service";
SystemSettingsModule, SystemSettingsModule,
PlateNormalizerModule, PlateNormalizerModule,
ClientModule, ClientModule,
OfflineInquiryModule,
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: SandHubModel.name, schema: SandHubSchema }, { name: SandHubModel.name, schema: SandHubSchema },
]), ]),

View File

@@ -34,6 +34,9 @@ describe("SandHubService inquiry mocks", () => {
sandHubDbService as any, sandHubDbService as any,
externalInquirySettings as unknown as ExternalInquirySettingsService, externalInquirySettings as unknown as ExternalInquirySettingsService,
plateNormalizer as any, plateNormalizer as any,
{
findPlateInquiry: jest.fn().mockResolvedValue(null),
} as any,
); );
externalInquirySettings.isInquiryLive.mockResolvedValue(false); externalInquirySettings.isInquiryLive.mockResolvedValue(false);
externalInquirySettings.getMockCompanyContext.mockResolvedValue({ externalInquirySettings.getMockCompanyContext.mockResolvedValue({

View File

@@ -15,6 +15,8 @@ import { ExternalInquirySettingsService } from "src/client/external-inquiry-sett
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service"; import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
import type { ExternalInquiryType } from "src/common/types/external-inquiry.types"; import type { ExternalInquiryType } from "src/common/types/external-inquiry.types";
import type { MockInquiryCompanyContext } 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 { SandHubDetailDto, SandHubInquiryOptions } from "./dto/sand-hub.dto";
import { jalaliToGregorianDate } from "src/helpers/date-jalali"; import { jalaliToGregorianDate } from "src/helpers/date-jalali";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
@@ -41,6 +43,7 @@ export class SandHubService {
private readonly sandHubDbService: SandHubDbService, private readonly sandHubDbService: SandHubDbService,
private readonly externalInquirySettings: ExternalInquirySettingsService, private readonly externalInquirySettings: ExternalInquirySettingsService,
private readonly plateNormalizer: PlateNormalizerService, private readonly plateNormalizer: PlateNormalizerService,
private readonly offlineInquiryService: OfflineInquiryService,
) {} ) {}
private clientRefFrom(options?: SandHubInquiryOptions): string | undefined { private clientRefFrom(options?: SandHubInquiryOptions): string | undefined {
@@ -731,6 +734,7 @@ export class SandHubService {
/** /**
* Tejarat block inquiry (replaces SandHub call for V2 flows). * Tejarat block inquiry (replaces SandHub call for V2 flows).
* Returns both raw + mapped (old-format) response. * Returns both raw + mapped (old-format) response.
* Offline seeded hits (per Fanavaran clientKey) are checked first.
*/ */
async getTejaratBlockInquiry( async getTejaratBlockInquiry(
userDetail: SandHubDetailDto, userDetail: SandHubDetailDto,
@@ -738,7 +742,34 @@ export class SandHubService {
): Promise<{ ): Promise<{
raw: any; raw: any;
mapped: any; mapped: any;
offline?: {
source: string;
fanavaranDriverId?: number;
insurance?: Record<string, unknown>;
person?: Record<string, unknown>;
};
}> { }> {
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); const ctx = await this.mockCompanyContext(options);
if (this.shouldUseEsgInquiryProvider()) { if (this.shouldUseEsgInquiryProvider()) {
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085"; const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";

View File

@@ -13,12 +13,38 @@ export class ExternalApisSettingsDto {
sandHubUseLiveApi?: boolean; 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 { export class UpdateSystemSettingsDto {
@ApiPropertyOptional({ type: ExternalApisSettingsDto }) @ApiPropertyOptional({ type: ExternalApisSettingsDto })
@IsOptional() @IsOptional()
@ValidateNested() @ValidateNested()
@Type(() => ExternalApisSettingsDto) @Type(() => ExternalApisSettingsDto)
externalApis?: 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 { export class ExternalApisSettingsViewDto {
@@ -30,6 +56,15 @@ export class ExternalApisSettingsViewDto {
sandHubUseLiveApi: boolean; 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 { export class SystemSettingsResponseDto {
@ApiProperty({ example: "global" }) @ApiProperty({ example: "global" })
key: string; key: string;
@@ -37,6 +72,9 @@ export class SystemSettingsResponseDto {
@ApiProperty({ type: ExternalApisSettingsViewDto }) @ApiProperty({ type: ExternalApisSettingsViewDto })
externalApis: ExternalApisSettingsViewDto; externalApis: ExternalApisSettingsViewDto;
@ApiProperty({ type: OfflineInquirySettingsViewDto })
offlineInquiry: OfflineInquirySettingsViewDto;
@ApiProperty({ @ApiProperty({
description: "Human-readable mode for operators", description: "Human-readable mode for operators",
example: "mock", example: "mock",

View File

@@ -17,6 +17,16 @@ export class ExternalApisSettings {
sandHubUseLiveApi?: boolean; 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 }) @Schema({ collection: "system_settings", versionKey: false })
export class SystemSettingsModel { export class SystemSettingsModel {
@Prop({ required: true, unique: true, default: SYSTEM_SETTINGS_GLOBAL_KEY }) @Prop({ required: true, unique: true, default: SYSTEM_SETTINGS_GLOBAL_KEY })
@@ -24,6 +34,9 @@ export class SystemSettingsModel {
@Prop({ type: ExternalApisSettings, required: false, default: {} }) @Prop({ type: ExternalApisSettings, required: false, default: {} })
externalApis?: ExternalApisSettings; externalApis?: ExternalApisSettings;
@Prop({ type: OfflineInquirySettings, required: false, default: {} })
offlineInquiry?: OfflineInquirySettings;
} }
export const SystemSettingsSchema = export const SystemSettingsSchema =

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Patch, UseGuards } from "@nestjs/common"; import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common";
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiOperation, ApiOperation,
@@ -10,11 +10,23 @@ import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { import {
OfflineInquirySettingsViewDto,
SystemSettingsResponseDto, SystemSettingsResponseDto,
UpdateOfflineInquirySettingsDto,
UpdateSystemSettingsDto, UpdateSystemSettingsDto,
} from "./dto/system-settings.dto"; } from "./dto/system-settings.dto";
import { SystemSettingsService } from "./system-settings.service"; 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") @ApiTags("system-settings")
@ApiBearerAuth() @ApiBearerAuth()
@Controller("system-settings") @Controller("system-settings")
@@ -27,7 +39,7 @@ export class SystemSettingsController {
@ApiOperation({ @ApiOperation({
summary: "Get global system settings (external API toggles)", summary: "Get global system settings (external API toggles)",
description: 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 }) @ApiResponse({ status: 200, type: SystemSettingsResponseDto })
getSettings(): Promise<SystemSettingsResponseDto> { getSettings(): Promise<SystemSettingsResponseDto> {
@@ -40,7 +52,7 @@ export class SystemSettingsController {
@ApiOperation({ @ApiOperation({
summary: "Update global system settings", summary: "Update global system settings",
description: 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 }) @ApiResponse({ status: 200, type: SystemSettingsResponseDto })
updateSettings( updateSettings(
@@ -48,4 +60,55 @@ export class SystemSettingsController {
): Promise<SystemSettingsResponseDto> { ): Promise<SystemSettingsResponseDto> {
return this.systemSettingsService.updateSettings(body); 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<OfflineInquirySettingsViewDto> {
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<OfflineInquirySettingsViewDto> {
return this.systemSettingsService.updateOfflineInquirySettings(body);
}
} }

View File

@@ -1,5 +1,6 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose"; import { MongooseModule } from "@nestjs/mongoose";
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
import { SystemSettingsDbService } from "./entities/db-service/system-settings.db.service"; import { SystemSettingsDbService } from "./entities/db-service/system-settings.db.service";
import { import {
SystemSettingsModel, SystemSettingsModel,
@@ -13,6 +14,7 @@ import { SystemSettingsService } from "./system-settings.service";
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: SystemSettingsModel.name, schema: SystemSettingsSchema }, { name: SystemSettingsModel.name, schema: SystemSettingsSchema },
]), ]),
FanavaranLookupModule,
], ],
controllers: [SystemSettingsController], controllers: [SystemSettingsController],
providers: [SystemSettingsService, SystemSettingsDbService], providers: [SystemSettingsService, SystemSettingsDbService],

View File

@@ -5,12 +5,18 @@ describe("SystemSettingsService", () => {
findGlobal: jest.fn(), findGlobal: jest.fn(),
upsertGlobal: jest.fn(), upsertGlobal: jest.fn(),
}; };
const fanavaranClientConfigService = {
reloadCache: jest.fn().mockResolvedValue(undefined),
};
let service: SystemSettingsService; let service: SystemSettingsService;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); 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 () => { it("defaults to mock when DB field is false", async () => {
@@ -28,4 +34,41 @@ describe("SystemSettingsService", () => {
}); });
await expect(service.isSandHubLiveEnabled()).resolves.toBe(true); 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();
});
}); });

View File

@@ -1,6 +1,9 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { FanavaranClientConfigService } from "src/fanavaran/fanavaran-client-config.service";
import { import {
OfflineInquirySettingsViewDto,
SystemSettingsResponseDto, SystemSettingsResponseDto,
UpdateOfflineInquirySettingsDto,
UpdateSystemSettingsDto, UpdateSystemSettingsDto,
} from "./dto/system-settings.dto"; } from "./dto/system-settings.dto";
import { SystemSettingsDbService } from "./entities/db-service/system-settings.db.service"; import { SystemSettingsDbService } from "./entities/db-service/system-settings.db.service";
@@ -12,7 +15,10 @@ export class SystemSettingsService {
private cacheAt = 0; private cacheAt = 0;
private readonly cacheTtlMs = 15_000; private readonly cacheTtlMs = 15_000;
constructor(private readonly db: SystemSettingsDbService) {} constructor(
private readonly db: SystemSettingsDbService,
private readonly fanavaranClientConfigService: FanavaranClientConfigService,
) {}
private async loadGlobal(): Promise<Record<string, unknown>> { private async loadGlobal(): Promise<Record<string, unknown>> {
const now = Date.now(); const now = Date.now();
@@ -22,10 +28,13 @@ export class SystemSettingsService {
let doc = await this.db.findGlobal(); let doc = await this.db.findGlobal();
if (!doc) { if (!doc) {
doc = await this.db.upsertGlobal({ doc = await this.db.upsertGlobal({
$set: { externalApis: { sandHubUseLiveApi: false } }, $set: {
externalApis: { sandHubUseLiveApi: false },
offlineInquiry: { enabled: true },
},
}); });
this.logger.log( this.logger.log(
"Created default system_settings document (sandHubUseLiveApi=false)", "Created default system_settings document (sandHubUseLiveApi=false, offlineInquiry.enabled=true)",
); );
} }
this.cache = doc; this.cache = doc;
@@ -38,6 +47,14 @@ export class SystemSettingsService {
this.cacheAt = 0; this.cacheAt = 0;
} }
private readOfflineInquiryEnabled(doc: Record<string, unknown>): 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). * Whether SandHub/Tejarat live HTTP should run (`system_settings` collection).
*/ */
@@ -49,6 +66,18 @@ export class SystemSettingsService {
return externalApis?.sandHubUseLiveApi === true; return externalApis?.sandHubUseLiveApi === true;
} }
/**
* Whether seeded offline plate inquiries may short-circuit live/mock inquiry.
*/
async isOfflineInquiryEnabled(): Promise<boolean> {
const doc = await this.loadGlobal();
return this.readOfflineInquiryEnabled(doc);
}
async getOfflineInquirySettings(): Promise<OfflineInquirySettingsViewDto> {
return { enabled: await this.isOfflineInquiryEnabled() };
}
async getSettingsView(): Promise<SystemSettingsResponseDto> { async getSettingsView(): Promise<SystemSettingsResponseDto> {
const doc = await this.loadGlobal(); const doc = await this.loadGlobal();
const externalApis = doc.externalApis as const externalApis = doc.externalApis as
@@ -58,6 +87,7 @@ export class SystemSettingsService {
return { return {
key: String(doc.key ?? "global"), key: String(doc.key ?? "global"),
externalApis: { sandHubUseLiveApi }, externalApis: { sandHubUseLiveApi },
offlineInquiry: { enabled: this.readOfflineInquiryEnabled(doc) },
sandHubMode: sandHubUseLiveApi ? "live" : "mock", sandHubMode: sandHubUseLiveApi ? "live" : "mock",
}; };
} }
@@ -69,6 +99,9 @@ export class SystemSettingsService {
if (body.externalApis?.sandHubUseLiveApi !== undefined) { if (body.externalApis?.sandHubUseLiveApi !== undefined) {
$set["externalApis.sandHubUseLiveApi"] = body.externalApis.sandHubUseLiveApi; $set["externalApis.sandHubUseLiveApi"] = body.externalApis.sandHubUseLiveApi;
} }
if (body.offlineInquiry?.enabled !== undefined) {
$set["offlineInquiry.enabled"] = body.offlineInquiry.enabled;
}
if (Object.keys($set).length > 0) { if (Object.keys($set).length > 0) {
await this.db.upsertGlobal({ $set }); await this.db.upsertGlobal({ $set });
this.invalidateCache(); this.invalidateCache();
@@ -76,4 +109,30 @@ export class SystemSettingsService {
} }
return this.getSettingsView(); return this.getSettingsView();
} }
async updateOfflineInquirySettings(
body: UpdateOfflineInquirySettingsDto,
): Promise<OfflineInquirySettingsViewDto> {
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",
};
}
} }