forked from Yara724/api
Compare commits
11 Commits
ea3db8f025
...
fdfca80eb6
| Author | SHA1 | Date | |
|---|---|---|---|
| fdfca80eb6 | |||
| f7b7cd13e8 | |||
| ca5c1900ff | |||
| fc7488a204 | |||
| 298761233a | |||
| 05531260da | |||
| b345818d43 | |||
| c2f5c576fa | |||
| 8f66502c49 | |||
| 6a26eaa6da | |||
| 86f8b829fd |
@@ -58,6 +58,7 @@ import { MediaPolicyModule } from "src/media-policy/media-policy.module";
|
|||||||
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
|
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
|
||||||
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
||||||
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
||||||
|
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -76,6 +77,7 @@ import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normaliz
|
|||||||
SandHubModule,
|
SandHubModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
MediaPolicyModule,
|
MediaPolicyModule,
|
||||||
|
SmsOrchestrationModule,
|
||||||
JwtModule.register({}),
|
JwtModule.register({}),
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -81,6 +81,36 @@ export class FanavaranSyncStage {
|
|||||||
@Prop({ type: Number })
|
@Prop({ type: Number })
|
||||||
expertiseId?: number;
|
expertiseId?: number;
|
||||||
|
|
||||||
|
/** Cached Fanavaran PolicyId from inquiry-my-policies (guilty party). */
|
||||||
|
@Prop({ type: Number })
|
||||||
|
policyId?: number;
|
||||||
|
|
||||||
|
/** Cached Fanavaran DriverId (parties inquiry-by-unique-identifier). */
|
||||||
|
@Prop({ type: Number })
|
||||||
|
driverId?: number;
|
||||||
|
|
||||||
|
/** Cached Fanavaran VehicleKindId. */
|
||||||
|
@Prop({ type: Number })
|
||||||
|
vehicleKindId?: number;
|
||||||
|
|
||||||
|
/** Cached Fanavaran InsuranceCorpId. */
|
||||||
|
@Prop({ type: Number })
|
||||||
|
insuranceCorpId?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last successfully built payload for this stage (preview/submit).
|
||||||
|
* Lets later preview/submit reuse Fanavaran-sourced fields without re-calling.
|
||||||
|
*/
|
||||||
|
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||||
|
lastPayload?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@Prop({ type: Date })
|
||||||
|
lastPayloadBuiltAt?: Date;
|
||||||
|
|
||||||
|
/** When we SMS'd the claim owner about Fanavaran claimId/ClaimNo (base claim). */
|
||||||
|
@Prop({ type: Date })
|
||||||
|
smsNotifiedAt?: Date;
|
||||||
|
|
||||||
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||||
files?: unknown[];
|
files?: unknown[];
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
export type FanavaranClientKey = "parsian" | "tejaratno";
|
export type FanavaranClientKey = "parsian" | "tejaratno" | "moallem";
|
||||||
|
|
||||||
export const FANAVARAN_CLIENT_KEYS: readonly FanavaranClientKey[] = [
|
export const FANAVARAN_CLIENT_KEYS: readonly FanavaranClientKey[] = [
|
||||||
"parsian",
|
"parsian",
|
||||||
"tejaratno",
|
"tejaratno",
|
||||||
|
"moallem",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** Swagger `@ApiParam({ enum })` value — keep in sync with {@link FANAVARAN_CLIENT_KEYS}. */
|
||||||
|
export const FANAVARAN_CLIENT_SWAGGER_ENUM: FanavaranClientKey[] = [
|
||||||
|
...FANAVARAN_CLIENT_KEYS,
|
||||||
|
];
|
||||||
|
|
||||||
export function isFanavaranClientKey(
|
export function isFanavaranClientKey(
|
||||||
value: string,
|
value: string,
|
||||||
): value is FanavaranClientKey {
|
): value is FanavaranClientKey {
|
||||||
const normalized = value?.trim().toLowerCase();
|
const normalized = value?.trim().toLowerCase();
|
||||||
return normalized === "parsian" || normalized === "tejaratno";
|
return (FANAVARAN_CLIENT_KEYS as readonly string[]).includes(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeFanavaranClientKey(value: string): FanavaranClientKey {
|
export function normalizeFanavaranClientKey(value: string): FanavaranClientKey {
|
||||||
const normalized = value?.trim().toLowerCase();
|
const normalized = value?.trim().toLowerCase();
|
||||||
if (normalized === "parsian" || normalized === "tejaratno") {
|
if (isFanavaranClientKey(normalized)) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -36,7 +42,15 @@ export interface FanavaranPayloadDefaults {
|
|||||||
AccidentCityId: number;
|
AccidentCityId: number;
|
||||||
AccidentReportTypeId: number;
|
AccidentReportTypeId: number;
|
||||||
AccidentVehicleUsedId: number;
|
AccidentVehicleUsedId: number;
|
||||||
|
/**
|
||||||
|
* GEN.03 base claim — کارشناس مسئول پرونده مالی
|
||||||
|
* (must NOT be the assessor role used on GEN.08).
|
||||||
|
*/
|
||||||
ClaimExpertId: number;
|
ClaimExpertId: number;
|
||||||
|
/**
|
||||||
|
* GEN.08 expertise — کارشناس ارزیاب خسارت ثالث مالی / وکیل معتمد / کارشناس تحقیق
|
||||||
|
* (Parsian: 29; Tejaratno proven: 2709).
|
||||||
|
*/
|
||||||
ExpertiseClaimExpertId: number;
|
ExpertiseClaimExpertId: number;
|
||||||
CompensationReferenceId: number;
|
CompensationReferenceId: number;
|
||||||
CulpritLicenceTypeId: number;
|
CulpritLicenceTypeId: number;
|
||||||
@@ -56,7 +70,34 @@ export interface FanavaranClientProfile {
|
|||||||
defaults: FanavaranPayloadDefaults;
|
defaults: FanavaranPayloadDefaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FANAVARAN_CLIENT_PROFILES: Record<
|
/**
|
||||||
|
* Shared codebook-ish defaults. Each tenant must override ClaimExpertId /
|
||||||
|
* ExpertiseClaimExpertId (they are different Fanavaran roles).
|
||||||
|
*/
|
||||||
|
const SHARED_FANAVARAN_DEFAULTS: FanavaranPayloadDefaults = {
|
||||||
|
AccidentCityId: 701,
|
||||||
|
AccidentReportTypeId: 155,
|
||||||
|
AccidentVehicleUsedId: 1,
|
||||||
|
ClaimExpertId: 4543092,
|
||||||
|
ExpertiseClaimExpertId: 2709,
|
||||||
|
CompensationReferenceId: 167,
|
||||||
|
CulpritLicenceTypeId: 2,
|
||||||
|
CulpritTypeId: 337,
|
||||||
|
DmgCaseTypeId: 175,
|
||||||
|
DmgHistoryStatus: 5214,
|
||||||
|
PlaqueKindId: 8,
|
||||||
|
PlaqueSampleId: 10,
|
||||||
|
DriverIsOwner: 0,
|
||||||
|
FaultPercent: 100,
|
||||||
|
ClaimFileTypeId: 23,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed / code fallback profiles. On app boot these are inserted into
|
||||||
|
* `fanavaranClientConfigs` only when a key is missing (never overwrite DB edits).
|
||||||
|
* Runtime reads prefer the in-memory cache loaded from Mongo.
|
||||||
|
*/
|
||||||
|
export const SEED_FANAVARAN_CLIENT_PROFILES: Record<
|
||||||
FanavaranClientKey,
|
FanavaranClientKey,
|
||||||
FanavaranClientProfile
|
FanavaranClientProfile
|
||||||
> = {
|
> = {
|
||||||
@@ -72,20 +113,11 @@ const FANAVARAN_CLIENT_PROFILES: Record<
|
|||||||
location: "100",
|
location: "100",
|
||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
AccidentCityId: 701,
|
...SHARED_FANAVARAN_DEFAULTS,
|
||||||
AccidentReportTypeId: 155,
|
// GEN.03 — کارشناس مسئول پرونده مالی
|
||||||
AccidentVehicleUsedId: 1,
|
|
||||||
ClaimExpertId: 4543092,
|
ClaimExpertId: 4543092,
|
||||||
ExpertiseClaimExpertId: 4543092,
|
// GEN.08 — ارزیاب (proven successful submit 2026-07-18 ClaimExpertId: 2709)
|
||||||
CompensationReferenceId: 167,
|
ExpertiseClaimExpertId: 2709,
|
||||||
CulpritLicenceTypeId: 2,
|
|
||||||
CulpritTypeId: 337,
|
|
||||||
DmgCaseTypeId: 175,
|
|
||||||
DmgHistoryStatus: 5214,
|
|
||||||
PlaqueKindId: 8,
|
|
||||||
PlaqueSampleId: 10,
|
|
||||||
DriverIsOwner: 0,
|
|
||||||
FaultPercent: 100,
|
|
||||||
ClaimFileTypeId: 23,
|
ClaimFileTypeId: 23,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -101,29 +133,57 @@ const FANAVARAN_CLIENT_PROFILES: Record<
|
|||||||
location: "210050",
|
location: "210050",
|
||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
AccidentCityId: 701,
|
...SHARED_FANAVARAN_DEFAULTS,
|
||||||
AccidentReportTypeId: 155,
|
// GEN.03 — مسئول پرونده مالی (Fanavaran example used 154; west branch 4662)
|
||||||
AccidentVehicleUsedId: 1,
|
|
||||||
ClaimExpertId: 154,
|
ClaimExpertId: 154,
|
||||||
|
// GEN.08 — ارزیاب خسارت ثالث مالی (رسول کرکی / شعبه غرب → 29)
|
||||||
ExpertiseClaimExpertId: 29,
|
ExpertiseClaimExpertId: 29,
|
||||||
CompensationReferenceId: 167,
|
|
||||||
CulpritLicenceTypeId: 2,
|
|
||||||
CulpritTypeId: 337,
|
|
||||||
DmgCaseTypeId: 175,
|
|
||||||
DmgHistoryStatus: 5214,
|
|
||||||
PlaqueKindId: 8,
|
|
||||||
PlaqueSampleId: 10,
|
|
||||||
DriverIsOwner: 0,
|
|
||||||
FaultPercent: 100,
|
|
||||||
ClaimFileTypeId: 70,
|
ClaimFileTypeId: 70,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
moallem: {
|
||||||
|
key: "moallem",
|
||||||
|
auth: {
|
||||||
|
appName: "ItTalie",
|
||||||
|
secret: "itT@l!3@api",
|
||||||
|
username: "itTalieUser",
|
||||||
|
password: "itT@l!3@user",
|
||||||
|
corpId: "5650",
|
||||||
|
contractId: "304",
|
||||||
|
location: "30900",
|
||||||
|
},
|
||||||
|
// Expert role ids not confirmed for Moallem yet — start from shared shape.
|
||||||
|
defaults: {
|
||||||
|
...SHARED_FANAVARAN_DEFAULTS,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @deprecated Use SEED_FANAVARAN_CLIENT_PROFILES — kept alias for older imports. */
|
||||||
|
export const FANAVARAN_CLIENT_PROFILES = SEED_FANAVARAN_CLIENT_PROFILES;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime cache filled by FanavaranClientConfigService on boot from Mongo.
|
||||||
|
* Until then (and in unit tests), callers fall back to seed profiles.
|
||||||
|
*/
|
||||||
|
let runtimeProfiles: Partial<
|
||||||
|
Record<FanavaranClientKey, FanavaranClientProfile>
|
||||||
|
> | null = null;
|
||||||
|
|
||||||
|
export function setFanavaranClientProfilesCache(
|
||||||
|
profiles: Partial<Record<FanavaranClientKey, FanavaranClientProfile>>,
|
||||||
|
): void {
|
||||||
|
runtimeProfiles = profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearFanavaranClientProfilesCache(): void {
|
||||||
|
runtimeProfiles = null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve active Fanavaran tenant from env (`FANAVARAN_CLIENT`) with optional CLIENT_ID fallback. */
|
/** Resolve active Fanavaran tenant from env (`FANAVARAN_CLIENT`) with optional CLIENT_ID fallback. */
|
||||||
export function resolveFanavaranClientKey(): FanavaranClientKey {
|
export function resolveFanavaranClientKey(): FanavaranClientKey {
|
||||||
const explicit = process.env.FANAVARAN_CLIENT?.trim().toLowerCase();
|
const explicit = process.env.FANAVARAN_CLIENT?.trim().toLowerCase();
|
||||||
if (explicit === "parsian" || explicit === "tejaratno") {
|
if (explicit && isFanavaranClientKey(explicit)) {
|
||||||
return explicit;
|
return explicit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,17 +196,19 @@ export function resolveFanavaranClientKey(): FanavaranClientKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveFanavaranClientProfile(): FanavaranClientProfile {
|
export function resolveFanavaranClientProfile(): FanavaranClientProfile {
|
||||||
return FANAVARAN_CLIENT_PROFILES[resolveFanavaranClientKey()];
|
return getFanavaranClientProfile(resolveFanavaranClientKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFanavaranClientProfile(
|
export function getFanavaranClientProfile(
|
||||||
clientKey: FanavaranClientKey,
|
clientKey: FanavaranClientKey,
|
||||||
): FanavaranClientProfile {
|
): FanavaranClientProfile {
|
||||||
return FANAVARAN_CLIENT_PROFILES[clientKey];
|
return (
|
||||||
|
runtimeProfiles?.[clientKey] ?? SEED_FANAVARAN_CLIENT_PROFILES[clientKey]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listFanavaranClientProfiles(): FanavaranClientProfile[] {
|
export function listFanavaranClientProfiles(): FanavaranClientProfile[] {
|
||||||
return FANAVARAN_CLIENT_KEYS.map((key) => FANAVARAN_CLIENT_PROFILES[key]);
|
return FANAVARAN_CLIENT_KEYS.map((key) => getFanavaranClientProfile(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fanavaranPreviewPath(
|
export function fanavaranPreviewPath(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { InjectModel } from "@nestjs/mongoose";
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError, type AxiosResponse } from "axios";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { Model, Types } from "mongoose";
|
import { Model, Types } from "mongoose";
|
||||||
import {
|
import {
|
||||||
@@ -16,18 +16,54 @@ export interface RecordFanavaranAuditStepInput {
|
|||||||
step: FanavaranAuditStep;
|
step: FanavaranAuditStep;
|
||||||
status: FanavaranAuditStatus;
|
status: FanavaranAuditStatus;
|
||||||
requestUrl?: string;
|
requestUrl?: string;
|
||||||
|
requestMethod?: string;
|
||||||
httpStatus?: number;
|
httpStatus?: number;
|
||||||
|
requestHeaders?: Record<string, unknown>;
|
||||||
|
requestBody?: unknown;
|
||||||
requestMeta?: Record<string, unknown>;
|
requestMeta?: Record<string, unknown>;
|
||||||
|
responseHeaders?: Record<string, unknown>;
|
||||||
|
responseBody?: unknown;
|
||||||
responseMeta?: Record<string, unknown>;
|
responseMeta?: Record<string, unknown>;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
errorDetails?: Record<string, unknown>;
|
errorDetails?: Record<string, unknown>;
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FanavaranHttpExchange {
|
||||||
|
httpStatus?: number;
|
||||||
|
requestHeaders?: Record<string, unknown>;
|
||||||
|
responseHeaders?: Record<string, unknown>;
|
||||||
|
responseBody?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FanavaranAuditService {
|
export class FanavaranAuditService {
|
||||||
private readonly logger = new Logger(FanavaranAuditService.name);
|
private readonly logger = new Logger(FanavaranAuditService.name);
|
||||||
|
|
||||||
|
/** Max serialized chars kept for request/response bodies in audit docs. */
|
||||||
|
static readonly BODY_MAX_CHARS = 80_000;
|
||||||
|
|
||||||
|
private static readonly SENSITIVE_HEADER_KEYS = new Set([
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
|
"authorization",
|
||||||
|
"authenticationtoken",
|
||||||
|
"apptoken",
|
||||||
|
"app-token",
|
||||||
|
"x-api-key",
|
||||||
|
"cookie",
|
||||||
|
"set-cookie",
|
||||||
|
]);
|
||||||
|
|
||||||
|
private static readonly SENSITIVE_BODY_KEYS = new Set([
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
|
"authenticationtoken",
|
||||||
|
"apptoken",
|
||||||
|
"token",
|
||||||
|
"files",
|
||||||
|
]);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectModel(FanavaranAuditLog.name)
|
@InjectModel(FanavaranAuditLog.name)
|
||||||
private readonly auditModel: Model<FanavaranAuditLogDocument>,
|
private readonly auditModel: Model<FanavaranAuditLogDocument>,
|
||||||
@@ -47,6 +83,138 @@ export class FanavaranAuditService {
|
|||||||
return `${trimmed.slice(0, 3)}****${trimmed.slice(-2)}`;
|
return `${trimmed.slice(0, 3)}****${trimmed.slice(-2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maskToken(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length <= 8) {
|
||||||
|
return "****";
|
||||||
|
}
|
||||||
|
return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)} (len=${trimmed.length})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizeHeaders(
|
||||||
|
headers?: Record<string, unknown> | null,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
if (!headers || typeof headers !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||||
|
if (rawValue === undefined) continue;
|
||||||
|
const key = String(rawKey);
|
||||||
|
const lower = key.toLowerCase();
|
||||||
|
if (FanavaranAuditService.SENSITIVE_HEADER_KEYS.has(lower)) {
|
||||||
|
out[key] =
|
||||||
|
typeof rawValue === "string"
|
||||||
|
? this.maskToken(rawValue)
|
||||||
|
: "***";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof rawValue === "string" ||
|
||||||
|
typeof rawValue === "number" ||
|
||||||
|
typeof rawValue === "boolean" ||
|
||||||
|
rawValue === null
|
||||||
|
) {
|
||||||
|
out[key] = rawValue;
|
||||||
|
} else {
|
||||||
|
out[key] = String(rawValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizeBody(body: unknown): unknown {
|
||||||
|
if (body === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const masked = this.maskSensitiveDeep(body);
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(masked);
|
||||||
|
if (serialized.length <= FanavaranAuditService.BODY_MAX_CHARS) {
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
_truncated: true,
|
||||||
|
maxChars: FanavaranAuditService.BODY_MAX_CHARS,
|
||||||
|
preview: serialized.slice(0, FanavaranAuditService.BODY_MAX_CHARS),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
const asString = String(masked);
|
||||||
|
return asString.length <= FanavaranAuditService.BODY_MAX_CHARS
|
||||||
|
? asString
|
||||||
|
: {
|
||||||
|
_truncated: true,
|
||||||
|
preview: asString.slice(0, FanavaranAuditService.BODY_MAX_CHARS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract request/response headers + body from an Axios response or Axios
|
||||||
|
* error (uses `config.headers` when present). Values are returned raw;
|
||||||
|
* `recordStep` applies sanitisation before persistence.
|
||||||
|
*/
|
||||||
|
captureAxiosExchange(
|
||||||
|
source: unknown,
|
||||||
|
overrideRequestHeaders?: Record<string, unknown>,
|
||||||
|
): FanavaranHttpExchange {
|
||||||
|
const toPlainHeaders = (
|
||||||
|
headers?: Record<string, unknown> | null,
|
||||||
|
): Record<string, unknown> | undefined => {
|
||||||
|
if (!headers || typeof headers !== "object") return undefined;
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
out[key] =
|
||||||
|
typeof value === "string" ||
|
||||||
|
typeof value === "number" ||
|
||||||
|
typeof value === "boolean" ||
|
||||||
|
value === null
|
||||||
|
? value
|
||||||
|
: String(value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isAxiosError(source)) {
|
||||||
|
return {
|
||||||
|
httpStatus: source.response?.status,
|
||||||
|
requestHeaders: toPlainHeaders(
|
||||||
|
overrideRequestHeaders ??
|
||||||
|
(source.config?.headers as Record<string, unknown> | undefined),
|
||||||
|
),
|
||||||
|
responseHeaders: toPlainHeaders(
|
||||||
|
source.response?.headers as Record<string, unknown> | undefined,
|
||||||
|
),
|
||||||
|
responseBody: source.response?.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = source as AxiosResponse | null;
|
||||||
|
if (
|
||||||
|
response &&
|
||||||
|
typeof response === "object" &&
|
||||||
|
"status" in response &&
|
||||||
|
"headers" in response
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
httpStatus: response.status,
|
||||||
|
requestHeaders: toPlainHeaders(
|
||||||
|
overrideRequestHeaders ??
|
||||||
|
(response.config?.headers as Record<string, unknown> | undefined),
|
||||||
|
),
|
||||||
|
responseHeaders: toPlainHeaders(
|
||||||
|
response.headers as Record<string, unknown> | undefined,
|
||||||
|
),
|
||||||
|
responseBody: response.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestHeaders: toPlainHeaders(overrideRequestHeaders),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
sanitizeErrorDetails(error: unknown): Record<string, unknown> {
|
sanitizeErrorDetails(error: unknown): Record<string, unknown> {
|
||||||
if (isAxiosError(error)) {
|
if (isAxiosError(error)) {
|
||||||
const data = error.response?.data;
|
const data = error.response?.data;
|
||||||
@@ -54,12 +222,13 @@ export class FanavaranAuditService {
|
|||||||
type: "axios",
|
type: "axios",
|
||||||
status: error.response?.status,
|
status: error.response?.status,
|
||||||
statusText: error.response?.statusText,
|
statusText: error.response?.statusText,
|
||||||
data:
|
data: this.sanitizeBody(
|
||||||
typeof data === "object" && data !== null
|
typeof data === "object" && data !== null
|
||||||
? data
|
? data
|
||||||
: typeof data === "string"
|
: typeof data === "string"
|
||||||
? data.slice(0, 2000)
|
? data.slice(0, 2000)
|
||||||
: data,
|
: data,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
@@ -112,8 +281,13 @@ export class FanavaranAuditService {
|
|||||||
? { claimRequestId: new Types.ObjectId(input.session.claimRequestId) }
|
? { claimRequestId: new Types.ObjectId(input.session.claimRequestId) }
|
||||||
: {}),
|
: {}),
|
||||||
requestUrl: input.requestUrl,
|
requestUrl: input.requestUrl,
|
||||||
|
requestMethod: input.requestMethod,
|
||||||
httpStatus: input.httpStatus,
|
httpStatus: input.httpStatus,
|
||||||
|
requestHeaders: this.sanitizeHeaders(input.requestHeaders),
|
||||||
|
requestBody: this.sanitizeBody(input.requestBody),
|
||||||
requestMeta: input.requestMeta,
|
requestMeta: input.requestMeta,
|
||||||
|
responseHeaders: this.sanitizeHeaders(input.responseHeaders),
|
||||||
|
responseBody: this.sanitizeBody(input.responseBody),
|
||||||
responseMeta: input.responseMeta,
|
responseMeta: input.responseMeta,
|
||||||
errorMessage: input.errorMessage,
|
errorMessage: input.errorMessage,
|
||||||
errorDetails: input.errorDetails,
|
errorDetails: input.errorDetails,
|
||||||
@@ -134,4 +308,31 @@ export class FanavaranAuditService {
|
|||||||
.lean()
|
.lean()
|
||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private maskSensitiveDeep(value: unknown, depth = 0): unknown {
|
||||||
|
if (depth > 8) {
|
||||||
|
return "[max-depth]";
|
||||||
|
}
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => this.maskSensitiveDeep(item, depth + 1));
|
||||||
|
}
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, child] of Object.entries(
|
||||||
|
value as Record<string, unknown>,
|
||||||
|
)) {
|
||||||
|
if (FanavaranAuditService.SENSITIVE_BODY_KEYS.has(key.toLowerCase())) {
|
||||||
|
out[key] =
|
||||||
|
typeof child === "string" ? this.maskToken(child) : "[redacted]";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out[key] = this.maskSensitiveDeep(child, depth + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
180
src/fanavaran/fanavaran-auth.service.spec.ts
Normal file
180
src/fanavaran/fanavaran-auth.service.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { FanavaranAuthService } from "./fanavaran-auth.service";
|
||||||
|
|
||||||
|
describe("FanavaranAuthService", () => {
|
||||||
|
const createAuthTokenModel = () => {
|
||||||
|
const store = new Map<
|
||||||
|
string,
|
||||||
|
{ clientKey: string; authenticationToken: string; expiresAt: Date }
|
||||||
|
>();
|
||||||
|
return {
|
||||||
|
findOne: jest.fn((query: { clientKey: string }) => ({
|
||||||
|
lean: () => ({
|
||||||
|
exec: async () => store.get(query.clientKey) ?? null,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
findOneAndUpdate: jest.fn(
|
||||||
|
(
|
||||||
|
query: { clientKey: string },
|
||||||
|
update: { $set: { authenticationToken: string; expiresAt: Date } },
|
||||||
|
) => ({
|
||||||
|
exec: async () => {
|
||||||
|
const next = {
|
||||||
|
clientKey: query.clientKey,
|
||||||
|
authenticationToken: update.$set.authenticationToken,
|
||||||
|
expiresAt: update.$set.expiresAt,
|
||||||
|
};
|
||||||
|
store.set(query.clientKey, next);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
deleteOne: jest.fn((query: { clientKey: string }) => ({
|
||||||
|
exec: async () => {
|
||||||
|
store.delete(query.clientKey);
|
||||||
|
return { deletedCount: 1 };
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
_store: store,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it("detects Fanavaran transient try-later messages", () => {
|
||||||
|
expect(
|
||||||
|
FanavaranAuthService.isTransientTryLaterError(
|
||||||
|
"کد پیگیری خطا: 10573755\r\n1405/05/1016:56:16:276\r\n.لطفا پس از چند لحظه مجدد تلاش فرمایید.",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
FanavaranAuthService.isTransientTryLaterError(
|
||||||
|
"فیلد شماره بيمه نامه ضروری میباشد",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches token and single-flights concurrent logins", async () => {
|
||||||
|
const http = {
|
||||||
|
post: jest.fn(),
|
||||||
|
};
|
||||||
|
const audit = {
|
||||||
|
recordStep: jest.fn().mockResolvedValue(undefined),
|
||||||
|
extractErrorMessage: (e: unknown) =>
|
||||||
|
e instanceof Error ? e.message : String(e),
|
||||||
|
sanitizeErrorDetails: () => ({}),
|
||||||
|
formatErrorWithTrackingCode: (m: string) => m,
|
||||||
|
captureAxiosExchange: () => ({}),
|
||||||
|
};
|
||||||
|
const authTokenModel = createAuthTokenModel();
|
||||||
|
|
||||||
|
let loginCalls = 0;
|
||||||
|
const { of, delay } = await import("rxjs");
|
||||||
|
http.post.mockImplementation((url: string) => {
|
||||||
|
if (url.includes("GetAppToken")) {
|
||||||
|
return of({
|
||||||
|
status: 200,
|
||||||
|
headers: { apptoken: "app-1" },
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loginCalls += 1;
|
||||||
|
return of({
|
||||||
|
status: 200,
|
||||||
|
headers: { authenticationtoken: "auth-1" },
|
||||||
|
data: {},
|
||||||
|
}).pipe(delay(20));
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new FanavaranAuthService(
|
||||||
|
http as any,
|
||||||
|
audit as any,
|
||||||
|
authTokenModel as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [a, b, c] = await Promise.all([
|
||||||
|
service.getAuthenticationToken("parsian"),
|
||||||
|
service.getAuthenticationToken("parsian"),
|
||||||
|
service.getAuthenticationToken("parsian"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(a).toBe("auth-1");
|
||||||
|
expect(b).toBe("auth-1");
|
||||||
|
expect(c).toBe("auth-1");
|
||||||
|
expect(loginCalls).toBe(1);
|
||||||
|
|
||||||
|
// Cache hit — no extra login
|
||||||
|
await service.getAuthenticationToken("parsian");
|
||||||
|
expect(loginCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses persisted token across service instances", async () => {
|
||||||
|
const http = { post: jest.fn() };
|
||||||
|
const audit = {
|
||||||
|
recordStep: jest.fn().mockResolvedValue(undefined),
|
||||||
|
extractErrorMessage: (e: unknown) =>
|
||||||
|
e instanceof Error ? e.message : String(e),
|
||||||
|
sanitizeErrorDetails: () => ({}),
|
||||||
|
formatErrorWithTrackingCode: (m: string) => m,
|
||||||
|
captureAxiosExchange: () => ({}),
|
||||||
|
};
|
||||||
|
const authTokenModel = createAuthTokenModel();
|
||||||
|
const { of } = await import("rxjs");
|
||||||
|
let loginCalls = 0;
|
||||||
|
http.post.mockImplementation((url: string) => {
|
||||||
|
if (url.includes("GetAppToken")) {
|
||||||
|
return of({
|
||||||
|
status: 200,
|
||||||
|
headers: { apptoken: "app-1" },
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loginCalls += 1;
|
||||||
|
return of({
|
||||||
|
status: 200,
|
||||||
|
headers: { authenticationtoken: "auth-persisted" },
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = new FanavaranAuthService(
|
||||||
|
http as any,
|
||||||
|
audit as any,
|
||||||
|
authTokenModel as any,
|
||||||
|
);
|
||||||
|
await first.getAuthenticationToken("tejaratno");
|
||||||
|
expect(loginCalls).toBe(1);
|
||||||
|
|
||||||
|
const second = new FanavaranAuthService(
|
||||||
|
http as any,
|
||||||
|
audit as any,
|
||||||
|
authTokenModel as any,
|
||||||
|
);
|
||||||
|
const token = await second.getAuthenticationToken("tejaratno");
|
||||||
|
expect(token).toBe("auth-persisted");
|
||||||
|
expect(loginCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enters tenant backoff on try-later errors", () => {
|
||||||
|
const service = new FanavaranAuthService(
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
createAuthTokenModel() as any,
|
||||||
|
);
|
||||||
|
service.registerFailure(
|
||||||
|
"parsian",
|
||||||
|
"کد پیگیری خطا: 1\r\n.لطفا پس از چند لحظه مجدد تلاش فرمایید.",
|
||||||
|
);
|
||||||
|
expect(service.isInBackoff("parsian")).toBe(true);
|
||||||
|
expect(() => service.assertNotInBackoff("parsian")).toThrow(/backoff/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes next Asia/Tehran midnight expiry after now", () => {
|
||||||
|
const now = Date.parse("2026-08-02T10:00:00.000Z");
|
||||||
|
const expiry = FanavaranAuthService.getNextMidnightExpiryMs(now);
|
||||||
|
expect(expiry).toBeGreaterThan(now);
|
||||||
|
expect(expiry - now).toBeLessThanOrEqual(24 * 60 * 60 * 1000);
|
||||||
|
expect(expiry - now).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const justAfterMidnight = Date.parse("2026-08-01T20:30:01.000Z");
|
||||||
|
const next = FanavaranAuthService.getNextMidnightExpiryMs(justAfterMidnight);
|
||||||
|
expect(next - justAfterMidnight).toBeGreaterThan(23 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
567
src/fanavaran/fanavaran-auth.service.ts
Normal file
567
src/fanavaran/fanavaran-auth.service.ts
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
import { HttpService } from "@nestjs/axios";
|
||||||
|
import {
|
||||||
|
BadGatewayException,
|
||||||
|
HttpException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
|
import { isAxiosError } from "axios";
|
||||||
|
import { Model } from "mongoose";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
import {
|
||||||
|
getFanavaranClientProfile,
|
||||||
|
type FanavaranAuthConfig,
|
||||||
|
type FanavaranClientKey,
|
||||||
|
} from "src/core/config/fanavaran-client.config";
|
||||||
|
import { FanavaranAuditService } from "./fanavaran-audit.service";
|
||||||
|
import type { FanavaranAuditSession } from "./fanavaran-audit.types";
|
||||||
|
import {
|
||||||
|
FanavaranAuditStatus,
|
||||||
|
FanavaranAuditStep,
|
||||||
|
} from "./schema/fanavaran-audit-log.schema";
|
||||||
|
import {
|
||||||
|
FanavaranAuthToken,
|
||||||
|
FanavaranAuthTokenDocument,
|
||||||
|
} from "./schema/fanavaran-auth-token.schema";
|
||||||
|
|
||||||
|
interface CachedFanavaranAuth {
|
||||||
|
authenticationToken: string;
|
||||||
|
/** Epoch ms when the cached token should be refreshed. */
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenantBackoffState {
|
||||||
|
until: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared Fanavaran auth: one AppToken+Login per tenant, reused until TTL / backoff. */
|
||||||
|
@Injectable()
|
||||||
|
export class FanavaranAuthService {
|
||||||
|
private readonly logger = new Logger(FanavaranAuthService.name);
|
||||||
|
|
||||||
|
private readonly getAppTokenUrl =
|
||||||
|
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken";
|
||||||
|
private readonly loginUrl =
|
||||||
|
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login";
|
||||||
|
|
||||||
|
/** When Fanavaran says "try again later", pause all tenant calls. */
|
||||||
|
static readonly TRANSIENT_BACKOFF_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fanavaran authenticationToken is valid until local midnight (Asia/Tehran).
|
||||||
|
* First call after 00:00 gets a fresh token; daytime calls reuse the cache.
|
||||||
|
*/
|
||||||
|
static readonly TOKEN_TIME_ZONE = "Asia/Tehran";
|
||||||
|
|
||||||
|
private readonly tokenCache = new Map<FanavaranClientKey, CachedFanavaranAuth>();
|
||||||
|
private readonly inflightLogin = new Map<
|
||||||
|
FanavaranClientKey,
|
||||||
|
Promise<string>
|
||||||
|
>();
|
||||||
|
private readonly backoffByTenant = new Map<
|
||||||
|
FanavaranClientKey,
|
||||||
|
TenantBackoffState
|
||||||
|
>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
private readonly fanavaranAuditService: FanavaranAuditService,
|
||||||
|
@InjectModel(FanavaranAuthToken.name)
|
||||||
|
private readonly authTokenModel: Model<FanavaranAuthTokenDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** True when Fanavaran asked us to wait (Persian “try again later” / tracking-code 500). */
|
||||||
|
static isTransientTryLaterError(errorOrMessage: unknown): boolean {
|
||||||
|
const message = FanavaranAuthService.extractMessage(errorOrMessage);
|
||||||
|
if (!message) return false;
|
||||||
|
return (
|
||||||
|
message.includes("لطفا پس از چند لحظه مجدد تلاش") ||
|
||||||
|
message.includes("مجدد تلاش فرمایید") ||
|
||||||
|
/try again later/i.test(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static extractMessage(errorOrMessage: unknown): string {
|
||||||
|
if (typeof errorOrMessage === "string") return errorOrMessage;
|
||||||
|
if (isAxiosError(errorOrMessage)) {
|
||||||
|
const data = errorOrMessage.response?.data as
|
||||||
|
| { Message?: string; message?: string }
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
if (typeof data === "string") return data;
|
||||||
|
return (
|
||||||
|
data?.Message ||
|
||||||
|
data?.message ||
|
||||||
|
errorOrMessage.message ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (errorOrMessage instanceof Error) return errorOrMessage.message;
|
||||||
|
return errorOrMessage == null ? "" : String(errorOrMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Epoch ms of the next 00:00:00 in Asia/Tehran after `now`.
|
||||||
|
* If `now` is exactly midnight Tehran, returns the following midnight.
|
||||||
|
*/
|
||||||
|
static getNextMidnightExpiryMs(
|
||||||
|
nowMs: number = Date.now(),
|
||||||
|
timeZone: string = FanavaranAuthService.TOKEN_TIME_ZONE,
|
||||||
|
): number {
|
||||||
|
const parts = Object.fromEntries(
|
||||||
|
new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
})
|
||||||
|
.formatToParts(new Date(nowMs))
|
||||||
|
.filter((p) => p.type !== "literal")
|
||||||
|
.map((p) => [p.type, p.value]),
|
||||||
|
) as Record<string, string>;
|
||||||
|
|
||||||
|
const hour = Number(parts.hour);
|
||||||
|
const minute = Number(parts.minute);
|
||||||
|
const second = Number(parts.second);
|
||||||
|
const msIntoDay = ((hour * 60 + minute) * 60 + second) * 1000;
|
||||||
|
const msPerDay = 24 * 60 * 60 * 1000;
|
||||||
|
const remaining = msPerDay - msIntoDay;
|
||||||
|
// Exactly at midnight → treat as expired for current day; expire at next midnight.
|
||||||
|
return nowMs + (remaining === 0 ? msPerDay : remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBackoffRemainingMs(clientKey: FanavaranClientKey): number {
|
||||||
|
const state = this.backoffByTenant.get(clientKey);
|
||||||
|
if (!state) return 0;
|
||||||
|
return Math.max(0, state.until - Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
isInBackoff(clientKey: FanavaranClientKey): boolean {
|
||||||
|
return this.getBackoffRemainingMs(clientKey) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call after any Fanavaran HTTP failure. Sets a tenant-wide pause when the
|
||||||
|
* error is the familiar “try again later” overload response.
|
||||||
|
*/
|
||||||
|
registerFailure(clientKey: FanavaranClientKey, error: unknown): void {
|
||||||
|
if (!FanavaranAuthService.isTransientTryLaterError(error)) return;
|
||||||
|
|
||||||
|
const until = Date.now() + FanavaranAuthService.TRANSIENT_BACKOFF_MS;
|
||||||
|
const reason = FanavaranAuthService.extractMessage(error).slice(0, 500);
|
||||||
|
this.backoffByTenant.set(clientKey, { until, reason });
|
||||||
|
// Token may still be valid, but Fanavaran is rejecting work — keep token,
|
||||||
|
// just stop hammering Login/business APIs.
|
||||||
|
this.logger.warn(
|
||||||
|
`[${clientKey}] Fanavaran transient backoff until ${new Date(until).toISOString()}: ${reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearBackoff(clientKey: FanavaranClientKey): void {
|
||||||
|
this.backoffByTenant.delete(clientKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateToken(clientKey: FanavaranClientKey): void {
|
||||||
|
this.tokenCache.delete(clientKey);
|
||||||
|
void this.authTokenModel.deleteOne({ clientKey }).exec().catch((error) => {
|
||||||
|
this.logger.warn(
|
||||||
|
`[${clientKey}] Failed to clear persisted Fanavaran auth token`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNotInBackoff(clientKey: FanavaranClientKey): void {
|
||||||
|
const remaining = this.getBackoffRemainingMs(clientKey);
|
||||||
|
if (remaining <= 0) return;
|
||||||
|
const state = this.backoffByTenant.get(clientKey);
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Fanavaran tenant "${clientKey}" is in backoff for ${Math.ceil(remaining / 1000)}s after transient errors. ${state?.reason ?? ""}`.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuthenticationToken(
|
||||||
|
clientKey: FanavaranClientKey,
|
||||||
|
options?: {
|
||||||
|
auditSession?: FanavaranAuditSession;
|
||||||
|
forceRefresh?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<string> {
|
||||||
|
this.assertNotInBackoff(clientKey);
|
||||||
|
|
||||||
|
if (!options?.forceRefresh) {
|
||||||
|
const memoryHit = this.readMemoryCache(clientKey);
|
||||||
|
if (memoryHit) {
|
||||||
|
return memoryHit;
|
||||||
|
}
|
||||||
|
const persisted = await this.readPersistedCache(clientKey);
|
||||||
|
if (persisted) {
|
||||||
|
return persisted;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.invalidateToken(clientKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = this.inflightLogin.get(clientKey);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginPromise = this.loginFresh(clientKey, options?.auditSession);
|
||||||
|
this.inflightLogin.set(clientKey, loginPromise);
|
||||||
|
try {
|
||||||
|
return await loginPromise;
|
||||||
|
} finally {
|
||||||
|
this.inflightLogin.delete(clientKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRequestHeaders(
|
||||||
|
clientKey: FanavaranClientKey,
|
||||||
|
options?: {
|
||||||
|
auditSession?: FanavaranAuditSession;
|
||||||
|
forceRefresh?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
authenticationToken: string;
|
||||||
|
CorpId: string;
|
||||||
|
ContractId: string;
|
||||||
|
Location: string;
|
||||||
|
}> {
|
||||||
|
const profile = getFanavaranClientProfile(clientKey);
|
||||||
|
const authenticationToken = await this.getAuthenticationToken(
|
||||||
|
clientKey,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
authenticationToken,
|
||||||
|
CorpId: profile.auth.corpId,
|
||||||
|
ContractId: profile.auth.contractId,
|
||||||
|
Location: profile.auth.location,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loginFresh(
|
||||||
|
clientKey: FanavaranClientKey,
|
||||||
|
auditSession?: FanavaranAuditSession,
|
||||||
|
): Promise<string> {
|
||||||
|
const profile = getFanavaranClientProfile(clientKey);
|
||||||
|
const appToken = await this.fetchAppToken(profile.auth, auditSession);
|
||||||
|
const authenticationToken = await this.fetchLoginToken(
|
||||||
|
appToken,
|
||||||
|
profile.auth,
|
||||||
|
auditSession,
|
||||||
|
);
|
||||||
|
|
||||||
|
const expiresAt = FanavaranAuthService.getNextMidnightExpiryMs();
|
||||||
|
await this.persistToken(clientKey, authenticationToken, expiresAt);
|
||||||
|
this.clearBackoff(clientKey);
|
||||||
|
this.logger.log(
|
||||||
|
`[${clientKey}] Cached Fanavaran authenticationToken until ${new Date(
|
||||||
|
expiresAt,
|
||||||
|
).toISOString()} (${FanavaranAuthService.TOKEN_TIME_ZONE} midnight)`,
|
||||||
|
);
|
||||||
|
return authenticationToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readMemoryCache(clientKey: FanavaranClientKey): string | null {
|
||||||
|
const cached = this.tokenCache.get(clientKey);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
return cached.authenticationToken;
|
||||||
|
}
|
||||||
|
if (cached) {
|
||||||
|
this.tokenCache.delete(clientKey);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readPersistedCache(
|
||||||
|
clientKey: FanavaranClientKey,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const doc = await this.authTokenModel.findOne({ clientKey }).lean().exec();
|
||||||
|
if (!doc?.authenticationToken || !doc.expiresAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const expiresAt = new Date(doc.expiresAt).getTime();
|
||||||
|
if (!(expiresAt > Date.now())) {
|
||||||
|
await this.authTokenModel.deleteOne({ clientKey }).exec();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.tokenCache.set(clientKey, {
|
||||||
|
authenticationToken: doc.authenticationToken,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`[${clientKey}] Reused persisted Fanavaran authenticationToken until ${new Date(
|
||||||
|
expiresAt,
|
||||||
|
).toISOString()}`,
|
||||||
|
);
|
||||||
|
return doc.authenticationToken;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[${clientKey}] Failed to read persisted Fanavaran auth token`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistToken(
|
||||||
|
clientKey: FanavaranClientKey,
|
||||||
|
authenticationToken: string,
|
||||||
|
expiresAt: number,
|
||||||
|
): Promise<void> {
|
||||||
|
this.tokenCache.set(clientKey, { authenticationToken, expiresAt });
|
||||||
|
try {
|
||||||
|
await this.authTokenModel
|
||||||
|
.findOneAndUpdate(
|
||||||
|
{ clientKey },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
authenticationToken,
|
||||||
|
expiresAt: new Date(expiresAt),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true, new: true },
|
||||||
|
)
|
||||||
|
.exec();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[${clientKey}] Failed to persist Fanavaran auth token (memory cache still active)`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emptyBodyTransformRequest() {
|
||||||
|
return [
|
||||||
|
(_data: unknown, headers?: Record<string, unknown>) => {
|
||||||
|
if (headers) {
|
||||||
|
delete headers["Content-Type"];
|
||||||
|
delete headers["content-type"];
|
||||||
|
}
|
||||||
|
return _data;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchAppToken(
|
||||||
|
config: Pick<FanavaranAuthConfig, "appName" | "secret">,
|
||||||
|
auditSession?: FanavaranAuditSession,
|
||||||
|
): Promise<string> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const requestHeaders = {
|
||||||
|
appname: config.appName,
|
||||||
|
secret: config.secret,
|
||||||
|
"Content-Length": "0",
|
||||||
|
};
|
||||||
|
if (auditSession) {
|
||||||
|
await this.fanavaranAuditService.recordStep({
|
||||||
|
session: auditSession,
|
||||||
|
step: FanavaranAuditStep.GET_APP_TOKEN,
|
||||||
|
status: FanavaranAuditStatus.STARTED,
|
||||||
|
requestUrl: this.getAppTokenUrl,
|
||||||
|
requestMethod: "POST",
|
||||||
|
requestHeaders,
|
||||||
|
requestBody: "",
|
||||||
|
requestMeta: { appName: config.appName, cached: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.post(this.getAppTokenUrl, "", {
|
||||||
|
headers: requestHeaders,
|
||||||
|
transformRequest: this.emptyBodyTransformRequest(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const appToken =
|
||||||
|
response.headers.apptoken ||
|
||||||
|
response.headers.appToken ||
|
||||||
|
response.headers["apptoken"] ||
|
||||||
|
response.headers["appToken"];
|
||||||
|
|
||||||
|
if (!appToken) {
|
||||||
|
throw new BadGatewayException("Failed to get Fanavaran appToken");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auditSession) {
|
||||||
|
const exchange =
|
||||||
|
this.fanavaranAuditService.captureAxiosExchange(
|
||||||
|
response,
|
||||||
|
requestHeaders,
|
||||||
|
);
|
||||||
|
await this.fanavaranAuditService.recordStep({
|
||||||
|
session: auditSession,
|
||||||
|
step: FanavaranAuditStep.GET_APP_TOKEN,
|
||||||
|
status: FanavaranAuditStatus.SUCCESS,
|
||||||
|
requestUrl: this.getAppTokenUrl,
|
||||||
|
requestMethod: "POST",
|
||||||
|
httpStatus: response.status,
|
||||||
|
requestHeaders: exchange.requestHeaders,
|
||||||
|
requestBody: "",
|
||||||
|
responseHeaders: exchange.responseHeaders,
|
||||||
|
responseBody: exchange.responseBody,
|
||||||
|
responseMeta: { hasAppToken: true, cached: false },
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return appToken;
|
||||||
|
} catch (error) {
|
||||||
|
if (auditSession) {
|
||||||
|
const exchange = this.fanavaranAuditService.captureAxiosExchange(
|
||||||
|
error,
|
||||||
|
requestHeaders,
|
||||||
|
);
|
||||||
|
await this.fanavaranAuditService.recordStep({
|
||||||
|
session: auditSession,
|
||||||
|
step: FanavaranAuditStep.GET_APP_TOKEN,
|
||||||
|
status: FanavaranAuditStatus.FAILURE,
|
||||||
|
requestUrl: this.getAppTokenUrl,
|
||||||
|
requestMethod: "POST",
|
||||||
|
httpStatus: exchange.httpStatus,
|
||||||
|
requestHeaders: exchange.requestHeaders,
|
||||||
|
requestBody: "",
|
||||||
|
responseHeaders: exchange.responseHeaders,
|
||||||
|
responseBody: exchange.responseBody,
|
||||||
|
errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
|
||||||
|
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw this.toGatewayError(error, "Failed to get appToken from external API", auditSession);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchLoginToken(
|
||||||
|
appToken: string,
|
||||||
|
config: Pick<FanavaranAuthConfig, "username" | "password">,
|
||||||
|
auditSession?: FanavaranAuditSession,
|
||||||
|
): Promise<string> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const requestHeaders = {
|
||||||
|
appToken,
|
||||||
|
userName: config.username,
|
||||||
|
password: config.password,
|
||||||
|
"Content-Length": "0",
|
||||||
|
};
|
||||||
|
if (auditSession) {
|
||||||
|
await this.fanavaranAuditService.recordStep({
|
||||||
|
session: auditSession,
|
||||||
|
step: FanavaranAuditStep.LOGIN,
|
||||||
|
status: FanavaranAuditStatus.STARTED,
|
||||||
|
requestUrl: this.loginUrl,
|
||||||
|
requestMethod: "POST",
|
||||||
|
requestHeaders,
|
||||||
|
requestBody: "",
|
||||||
|
requestMeta: { userName: config.username, cached: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.post(this.loginUrl, "", {
|
||||||
|
headers: requestHeaders,
|
||||||
|
transformRequest: this.emptyBodyTransformRequest(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authenticationToken =
|
||||||
|
response.headers.authenticationtoken ||
|
||||||
|
response.headers.authenticationToken ||
|
||||||
|
response.headers["authenticationtoken"] ||
|
||||||
|
response.headers["authenticationToken"] ||
|
||||||
|
response.data?.authenticationtoken ||
|
||||||
|
response.data?.authenticationToken ||
|
||||||
|
response.data?.authentication_token;
|
||||||
|
|
||||||
|
if (!authenticationToken) {
|
||||||
|
throw new BadGatewayException(
|
||||||
|
"Failed to get Fanavaran authenticationToken",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auditSession) {
|
||||||
|
const exchange =
|
||||||
|
this.fanavaranAuditService.captureAxiosExchange(
|
||||||
|
response,
|
||||||
|
requestHeaders,
|
||||||
|
);
|
||||||
|
await this.fanavaranAuditService.recordStep({
|
||||||
|
session: auditSession,
|
||||||
|
step: FanavaranAuditStep.LOGIN,
|
||||||
|
status: FanavaranAuditStatus.SUCCESS,
|
||||||
|
requestUrl: this.loginUrl,
|
||||||
|
requestMethod: "POST",
|
||||||
|
httpStatus: response.status,
|
||||||
|
requestHeaders: exchange.requestHeaders,
|
||||||
|
requestBody: "",
|
||||||
|
responseHeaders: exchange.responseHeaders,
|
||||||
|
responseBody: exchange.responseBody,
|
||||||
|
responseMeta: {
|
||||||
|
hasAuthenticationToken: true,
|
||||||
|
cached: false,
|
||||||
|
},
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return authenticationToken;
|
||||||
|
} catch (error) {
|
||||||
|
if (auditSession) {
|
||||||
|
const exchange = this.fanavaranAuditService.captureAxiosExchange(
|
||||||
|
error,
|
||||||
|
requestHeaders,
|
||||||
|
);
|
||||||
|
await this.fanavaranAuditService.recordStep({
|
||||||
|
session: auditSession,
|
||||||
|
step: FanavaranAuditStep.LOGIN,
|
||||||
|
status: FanavaranAuditStatus.FAILURE,
|
||||||
|
requestUrl: this.loginUrl,
|
||||||
|
requestMethod: "POST",
|
||||||
|
httpStatus: exchange.httpStatus,
|
||||||
|
requestHeaders: exchange.requestHeaders,
|
||||||
|
requestBody: "",
|
||||||
|
responseHeaders: exchange.responseHeaders,
|
||||||
|
responseBody: exchange.responseBody,
|
||||||
|
errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
|
||||||
|
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw this.toGatewayError(error, "Failed to login to external API", auditSession);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toGatewayError(
|
||||||
|
error: unknown,
|
||||||
|
fallback: string,
|
||||||
|
auditSession?: FanavaranAuditSession,
|
||||||
|
): HttpException {
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
const message = isAxiosError(error)
|
||||||
|
? error.response?.data?.Message ||
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.message ||
|
||||||
|
fallback
|
||||||
|
: fallback;
|
||||||
|
return new BadGatewayException(
|
||||||
|
this.fanavaranAuditService.formatErrorWithTrackingCode(
|
||||||
|
String(message),
|
||||||
|
auditSession?.trackingCode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/fanavaran/fanavaran-client-config.service.ts
Normal file
98
src/fanavaran/fanavaran-client-config.service.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||||
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
|
import { Model } from "mongoose";
|
||||||
|
import {
|
||||||
|
FANAVARAN_CLIENT_KEYS,
|
||||||
|
SEED_FANAVARAN_CLIENT_PROFILES,
|
||||||
|
setFanavaranClientProfilesCache,
|
||||||
|
type FanavaranClientKey,
|
||||||
|
type FanavaranClientProfile,
|
||||||
|
} from "src/core/config/fanavaran-client.config";
|
||||||
|
import {
|
||||||
|
FanavaranClientConfig,
|
||||||
|
FanavaranClientConfigDocument,
|
||||||
|
} from "./schema/fanavaran-client-config.schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads / seeds per-tenant Fanavaran auth+defaults from Mongo and exposes them
|
||||||
|
* through the sync helpers in fanavaran-client.config.ts.
|
||||||
|
*
|
||||||
|
* Boot behaviour: for each known client key, if no document exists, insert the
|
||||||
|
* seed profile. Existing documents are never overwritten (ops can edit Mongo).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class FanavaranClientConfigService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(FanavaranClientConfigService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectModel(FanavaranClientConfig.name)
|
||||||
|
private readonly configModel: Model<FanavaranClientConfigDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.seedMissingProfiles();
|
||||||
|
await this.reloadCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
async seedMissingProfiles(): Promise<void> {
|
||||||
|
for (const key of FANAVARAN_CLIENT_KEYS) {
|
||||||
|
const seed = SEED_FANAVARAN_CLIENT_PROFILES[key];
|
||||||
|
const existing = await this.configModel
|
||||||
|
.findOne({ key })
|
||||||
|
.select({ _id: 1 })
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
if (existing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.configModel.create({
|
||||||
|
key: seed.key,
|
||||||
|
auth: seed.auth,
|
||||||
|
defaults: seed.defaults,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Seeded fanavaranClientConfigs for client "${key}" (ClaimExpertId=${seed.defaults.ClaimExpertId}, ExpertiseClaimExpertId=${seed.defaults.ExpertiseClaimExpertId})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadCache(): Promise<void> {
|
||||||
|
const docs = await this.configModel.find().lean().exec();
|
||||||
|
const cache: Partial<Record<FanavaranClientKey, FanavaranClientProfile>> =
|
||||||
|
{};
|
||||||
|
|
||||||
|
for (const key of FANAVARAN_CLIENT_KEYS) {
|
||||||
|
const doc = docs.find((row) => row.key === key);
|
||||||
|
if (doc?.auth && doc?.defaults) {
|
||||||
|
cache[key] = {
|
||||||
|
key,
|
||||||
|
auth: {
|
||||||
|
appName: doc.auth.appName,
|
||||||
|
secret: doc.auth.secret,
|
||||||
|
username: doc.auth.username,
|
||||||
|
password: doc.auth.password,
|
||||||
|
corpId: doc.auth.corpId,
|
||||||
|
contractId: doc.auth.contractId,
|
||||||
|
location: doc.auth.location,
|
||||||
|
},
|
||||||
|
defaults: { ...doc.defaults },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
cache[key] = SEED_FANAVARAN_CLIENT_PROFILES[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFanavaranClientProfilesCache(cache);
|
||||||
|
this.logger.log(
|
||||||
|
`Loaded ${docs.length} Fanavaran client config(s) from Mongo into runtime cache`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByKey(key: FanavaranClientKey) {
|
||||||
|
return this.configModel.findOne({ key }).lean().exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async list() {
|
||||||
|
return this.configModel.find().lean().exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,20 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
|
import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
|
||||||
|
import { FanavaranAuditModule } from "./fanavaran-audit.module";
|
||||||
|
import { FanavaranAuthService } from "./fanavaran-auth.service";
|
||||||
|
import { FanavaranClientConfigService } from "./fanavaran-client-config.service";
|
||||||
import { FanavaranLookupService } from "./fanavaran-lookup.service";
|
import { FanavaranLookupService } from "./fanavaran-lookup.service";
|
||||||
|
import {
|
||||||
|
FanavaranAuthToken,
|
||||||
|
FanavaranAuthTokenSchema,
|
||||||
|
} from "./schema/fanavaran-auth-token.schema";
|
||||||
|
import {
|
||||||
|
FanavaranClientConfig,
|
||||||
|
FanavaranClientConfigSchema,
|
||||||
|
} from "./schema/fanavaran-client-config.schema";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -11,8 +23,24 @@ import { FanavaranLookupService } from "./fanavaran-lookup.service";
|
|||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: createHttpModuleOptions,
|
useFactory: createHttpModuleOptions,
|
||||||
}),
|
}),
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: FanavaranAuthToken.name, schema: FanavaranAuthTokenSchema },
|
||||||
|
{
|
||||||
|
name: FanavaranClientConfig.name,
|
||||||
|
schema: FanavaranClientConfigSchema,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
FanavaranAuditModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
FanavaranClientConfigService,
|
||||||
|
FanavaranAuthService,
|
||||||
|
FanavaranLookupService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
FanavaranClientConfigService,
|
||||||
|
FanavaranAuthService,
|
||||||
|
FanavaranLookupService,
|
||||||
],
|
],
|
||||||
providers: [FanavaranLookupService],
|
|
||||||
exports: [FanavaranLookupService],
|
|
||||||
})
|
})
|
||||||
export class FanavaranLookupModule {}
|
export class FanavaranLookupModule {}
|
||||||
|
|||||||
@@ -9,26 +9,22 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
import {
|
import { type FanavaranClientKey } from "src/core/config/fanavaran-client.config";
|
||||||
getFanavaranClientProfile,
|
|
||||||
type FanavaranClientKey,
|
|
||||||
} from "src/core/config/fanavaran-client.config";
|
|
||||||
import {
|
import {
|
||||||
FANAVARAN_LOOKUP_BASE_URL,
|
FANAVARAN_LOOKUP_BASE_URL,
|
||||||
fanavaranLookupCacheDir,
|
fanavaranLookupCacheDir,
|
||||||
tejaratStaticAccidentFilePath,
|
tejaratStaticAccidentFilePath,
|
||||||
} from "./fanavaran-lookup.config";
|
} from "./fanavaran-lookup.config";
|
||||||
|
import { FanavaranAuthService } from "./fanavaran-auth.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FanavaranLookupService {
|
export class FanavaranLookupService {
|
||||||
private readonly logger = new Logger(FanavaranLookupService.name);
|
private readonly logger = new Logger(FanavaranLookupService.name);
|
||||||
|
|
||||||
private readonly getAppTokenUrl =
|
constructor(
|
||||||
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken";
|
private readonly httpService: HttpService,
|
||||||
private readonly loginUrl =
|
private readonly fanavaranAuthService: FanavaranAuthService,
|
||||||
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login";
|
) {}
|
||||||
|
|
||||||
constructor(private readonly httpService: HttpService) {}
|
|
||||||
|
|
||||||
private cacheFilePath(clientKey: FanavaranClientKey, fileName: string): string {
|
private cacheFilePath(clientKey: FanavaranClientKey, fileName: string): string {
|
||||||
return join(fanavaranLookupCacheDir(clientKey), fileName);
|
return join(fanavaranLookupCacheDir(clientKey), fileName);
|
||||||
@@ -71,91 +67,12 @@ export class FanavaranLookupService {
|
|||||||
return JSON.parse(cached) as T;
|
return JSON.parse(cached) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getAppToken(config: {
|
|
||||||
appName: string;
|
|
||||||
secret: string;
|
|
||||||
}): Promise<string> {
|
|
||||||
const response = await firstValueFrom(
|
|
||||||
this.httpService.post(this.getAppTokenUrl, "", {
|
|
||||||
headers: {
|
|
||||||
appname: config.appName,
|
|
||||||
secret: config.secret,
|
|
||||||
"Content-Length": "0",
|
|
||||||
},
|
|
||||||
transformRequest: [
|
|
||||||
(_data, headers) => {
|
|
||||||
if (headers) {
|
|
||||||
delete headers["Content-Type"];
|
|
||||||
delete headers["content-type"];
|
|
||||||
}
|
|
||||||
return _data;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const appToken =
|
|
||||||
response.headers.apptoken ||
|
|
||||||
response.headers.appToken ||
|
|
||||||
response.headers["apptoken"] ||
|
|
||||||
response.headers["appToken"];
|
|
||||||
|
|
||||||
if (!appToken) {
|
|
||||||
throw new BadGatewayException("Failed to get Fanavaran appToken");
|
|
||||||
}
|
|
||||||
|
|
||||||
return appToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async login(
|
|
||||||
appToken: string,
|
|
||||||
config: { username: string; password: string },
|
|
||||||
): Promise<string> {
|
|
||||||
const response = await firstValueFrom(
|
|
||||||
this.httpService.post(this.loginUrl, "", {
|
|
||||||
headers: {
|
|
||||||
appToken,
|
|
||||||
userName: config.username,
|
|
||||||
password: config.password,
|
|
||||||
"Content-Length": "0",
|
|
||||||
},
|
|
||||||
transformRequest: [
|
|
||||||
(_data, headers) => {
|
|
||||||
if (headers) {
|
|
||||||
delete headers["Content-Type"];
|
|
||||||
delete headers["content-type"];
|
|
||||||
}
|
|
||||||
return _data;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const authenticationToken =
|
|
||||||
response.headers.authenticationtoken ||
|
|
||||||
response.headers.authenticationToken ||
|
|
||||||
response.headers["authenticationtoken"] ||
|
|
||||||
response.headers["authenticationToken"] ||
|
|
||||||
response.data?.authenticationtoken ||
|
|
||||||
response.data?.authenticationToken ||
|
|
||||||
response.data?.authentication_token;
|
|
||||||
|
|
||||||
if (!authenticationToken) {
|
|
||||||
throw new BadGatewayException("Failed to get Fanavaran authenticationToken");
|
|
||||||
}
|
|
||||||
|
|
||||||
return authenticationToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchFromFanavaran(
|
async fetchFromFanavaran(
|
||||||
clientKey: FanavaranClientKey,
|
clientKey: FanavaranClientKey,
|
||||||
url: string,
|
url: string,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const profile = getFanavaranClientProfile(clientKey);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const appToken = await this.getAppToken(profile.auth);
|
const headers = await this.fanavaranAuthService.getRequestHeaders(clientKey);
|
||||||
const authenticationToken = await this.login(appToken, profile.auth);
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[${clientKey}] Calling Fanavaran lookup API: ${url}`,
|
`[${clientKey}] Calling Fanavaran lookup API: ${url}`,
|
||||||
@@ -164,10 +81,7 @@ export class FanavaranLookupService {
|
|||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.httpService.get(url, {
|
this.httpService.get(url, {
|
||||||
headers: {
|
headers: {
|
||||||
authenticationToken,
|
...headers,
|
||||||
CorpId: profile.auth.corpId,
|
|
||||||
ContractId: profile.auth.contractId,
|
|
||||||
Location: profile.auth.location,
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
@@ -184,8 +98,10 @@ export class FanavaranLookupService {
|
|||||||
`[${clientKey}] Fanavaran lookup response status=${response.status} dataCount=${dataCount}`,
|
`[${clientKey}] Fanavaran lookup response status=${response.status} dataCount=${dataCount}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.fanavaranAuthService.clearBackoff(clientKey);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
this.fanavaranAuthService.registerFailure(clientKey, error);
|
||||||
const message = isAxiosError(error)
|
const message = isAxiosError(error)
|
||||||
? error.response?.data?.Message ||
|
? error.response?.data?.Message ||
|
||||||
error.response?.data?.message ||
|
error.response?.data?.message ||
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||||
import {
|
import {
|
||||||
|
FANAVARAN_CLIENT_KEYS,
|
||||||
|
FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||||
isFanavaranClientKey,
|
isFanavaranClientKey,
|
||||||
listFanavaranClientProfiles,
|
listFanavaranClientProfiles,
|
||||||
normalizeFanavaranClientKey,
|
normalizeFanavaranClientKey,
|
||||||
@@ -37,7 +39,7 @@ export class FanavaranController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List supported Fanavaran insurance clients",
|
summary: "List supported Fanavaran insurance clients",
|
||||||
description:
|
description:
|
||||||
"Returns configured Fanavaran tenants (parsian, tejaratno) and which client is active for this deployment.",
|
"Returns configured Fanavaran tenants (parsian, tejaratno, moallem) and which client is active for this deployment.",
|
||||||
})
|
})
|
||||||
listClients() {
|
listClients() {
|
||||||
const activeClient = resolveFanavaranClientKey();
|
const activeClient = resolveFanavaranClientKey();
|
||||||
@@ -55,12 +57,12 @@ export class FanavaranController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Preview Fanavaran base claim create payload",
|
summary: "Preview Fanavaran base claim create payload",
|
||||||
description:
|
description:
|
||||||
"Builds the GEN.03 third-party-car-financial-claims payload from local claimCases + blameCases without creating a Fanavaran claim.",
|
"Builds the GEN.03 payload from local claim/blame data. Fanavaran-sourced PolicyId is resolve-once: first call may inquire and caches on the claim; later calls reuse the cache. Auth token is shared until Asia/Tehran midnight. Pass forceRefreshPolicy=true to re-inquire.",
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "client",
|
name: "client",
|
||||||
description: "Fanavaran tenant key",
|
description: "Fanavaran tenant key",
|
||||||
enum: ["parsian", "tejaratno"],
|
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "claimCaseId",
|
name: "claimCaseId",
|
||||||
@@ -71,16 +73,39 @@ export class FanavaranController {
|
|||||||
required: false,
|
required: false,
|
||||||
description: "When true, returns payload plus mapping debug steps",
|
description: "When true, returns payload plus mapping debug steps",
|
||||||
})
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: "forceRefreshPolicy",
|
||||||
|
required: false,
|
||||||
|
description:
|
||||||
|
"When true, ignores cached PolicyId and performs a live Fanavaran policy inquiry again. Do not pass this from normal UI loads.",
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: "resolvePolicy",
|
||||||
|
required: false,
|
||||||
|
deprecated: true,
|
||||||
|
description:
|
||||||
|
"Deprecated. Ignored for cache-busting. PolicyId is resolve-once from fanavaranSync.baseClaim.policyId; use forceRefreshPolicy=true only to re-inquire.",
|
||||||
|
})
|
||||||
async preview(
|
async preview(
|
||||||
@Param("client") client: string,
|
@Param("client") client: string,
|
||||||
@Param("claimCaseId") claimCaseId: string,
|
@Param("claimCaseId") claimCaseId: string,
|
||||||
@Query("debug") debug?: string,
|
@Query("debug") debug?: string,
|
||||||
|
@Query("forceRefreshPolicy") forceRefreshPolicy?: string,
|
||||||
|
@Query("resolvePolicy") _resolvePolicy?: string,
|
||||||
) {
|
) {
|
||||||
const clientKey = this.parseClientParam(client);
|
const clientKey = this.parseClientParam(client);
|
||||||
|
// IMPORTANT: resolvePolicy must NOT force a live inquiry. Older UI clients
|
||||||
|
// send resolvePolicy=true on every preview load; that used to defeat the
|
||||||
|
// PolicyId cache and re-Login Fanavaran on every click.
|
||||||
return await this.claimRequestManagementService.previewFanavaranSubmitV2(
|
return await this.claimRequestManagementService.previewFanavaranSubmitV2(
|
||||||
claimCaseId,
|
claimCaseId,
|
||||||
clientKey,
|
clientKey,
|
||||||
{ debug: debug === "1" || debug === "true" },
|
{
|
||||||
|
debug: debug === "1" || debug === "true",
|
||||||
|
forceRefreshPolicy:
|
||||||
|
forceRefreshPolicy === "1" || forceRefreshPolicy === "true",
|
||||||
|
requirePolicyId: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +118,7 @@ export class FanavaranController {
|
|||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "client",
|
name: "client",
|
||||||
description: "Fanavaran tenant key",
|
description: "Fanavaran tenant key",
|
||||||
enum: ["parsian", "tejaratno"],
|
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "claimCaseId",
|
name: "claimCaseId",
|
||||||
@@ -121,7 +146,7 @@ export class FanavaranController {
|
|||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "client",
|
name: "client",
|
||||||
description: "Fanavaran tenant key",
|
description: "Fanavaran tenant key",
|
||||||
enum: ["parsian", "tejaratno"],
|
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "claimCaseId",
|
name: "claimCaseId",
|
||||||
@@ -142,12 +167,12 @@ export class FanavaranController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Submit Fanavaran damage-case request",
|
summary: "Submit Fanavaran damage-case request",
|
||||||
description:
|
description:
|
||||||
"Submits the GEN.12 dmg-cases request for the already-created Fanavaran claim and stores returned Id as local dmgCaseId.",
|
"Submits the GEN.12 dmg-cases request. If base claim (claimId) is missing, soft-ensures GEN.03 base claim first, then submits damage. Skips when dmgCaseId already exists.",
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "client",
|
name: "client",
|
||||||
description: "Fanavaran tenant key",
|
description: "Fanavaran tenant key",
|
||||||
enum: ["parsian", "tejaratno"],
|
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "claimCaseId",
|
name: "claimCaseId",
|
||||||
@@ -175,7 +200,7 @@ export class FanavaranController {
|
|||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "client",
|
name: "client",
|
||||||
description: "Fanavaran tenant key",
|
description: "Fanavaran tenant key",
|
||||||
enum: ["parsian", "tejaratno"],
|
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "claimCaseId",
|
name: "claimCaseId",
|
||||||
@@ -201,7 +226,7 @@ export class FanavaranController {
|
|||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "client",
|
name: "client",
|
||||||
description: "Fanavaran tenant key",
|
description: "Fanavaran tenant key",
|
||||||
enum: ["parsian", "tejaratno"],
|
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "claimCaseId",
|
name: "claimCaseId",
|
||||||
@@ -227,7 +252,7 @@ export class FanavaranController {
|
|||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "client",
|
name: "client",
|
||||||
description: "Fanavaran tenant key",
|
description: "Fanavaran tenant key",
|
||||||
enum: ["parsian", "tejaratno"],
|
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "claimCaseId",
|
name: "claimCaseId",
|
||||||
@@ -253,7 +278,7 @@ export class FanavaranController {
|
|||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "client",
|
name: "client",
|
||||||
description: "Fanavaran tenant key",
|
description: "Fanavaran tenant key",
|
||||||
enum: ["parsian", "tejaratno"],
|
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||||
})
|
})
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "claimCaseId",
|
name: "claimCaseId",
|
||||||
@@ -275,7 +300,7 @@ export class FanavaranController {
|
|||||||
private parseClientParam(client: string) {
|
private parseClientParam(client: string) {
|
||||||
if (!isFanavaranClientKey(client)) {
|
if (!isFanavaranClientKey(client)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Invalid Fanavaran client "${client}". Expected one of: parsian, tejaratno`,
|
`Invalid Fanavaran client "${client}". Expected one of: ${FANAVARAN_CLIENT_KEYS.join(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return normalizeFanavaranClientKey(client);
|
return normalizeFanavaranClientKey(client);
|
||||||
|
|||||||
@@ -62,12 +62,33 @@ export class FanavaranAuditLog {
|
|||||||
@Prop({ type: String, required: false })
|
@Prop({ type: String, required: false })
|
||||||
requestUrl?: string;
|
requestUrl?: string;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: false })
|
||||||
|
requestMethod?: string;
|
||||||
|
|
||||||
@Prop({ type: Number, required: false })
|
@Prop({ type: Number, required: false })
|
||||||
httpStatus?: number;
|
httpStatus?: number;
|
||||||
|
|
||||||
|
/** Sanitized outbound headers (secrets masked). */
|
||||||
|
@Prop({ type: Object, required: false })
|
||||||
|
requestHeaders?: Record<string, unknown>;
|
||||||
|
|
||||||
|
/** Outbound JSON/body (truncated; secrets masked). */
|
||||||
|
@Prop({ type: Object, required: false })
|
||||||
|
requestBody?: unknown;
|
||||||
|
|
||||||
|
/** Compact structured facts (ids, flags) — kept for filtering/dashboards. */
|
||||||
@Prop({ type: Object, required: false })
|
@Prop({ type: Object, required: false })
|
||||||
requestMeta?: Record<string, unknown>;
|
requestMeta?: Record<string, unknown>;
|
||||||
|
|
||||||
|
/** Sanitized inbound response headers. */
|
||||||
|
@Prop({ type: Object, required: false })
|
||||||
|
responseHeaders?: Record<string, unknown>;
|
||||||
|
|
||||||
|
/** Inbound response body (truncated). */
|
||||||
|
@Prop({ type: Object, required: false })
|
||||||
|
responseBody?: unknown;
|
||||||
|
|
||||||
|
/** Compact structured facts from the response. */
|
||||||
@Prop({ type: Object, required: false })
|
@Prop({ type: Object, required: false })
|
||||||
responseMeta?: Record<string, unknown>;
|
responseMeta?: Record<string, unknown>;
|
||||||
|
|
||||||
@@ -86,3 +107,4 @@ export const FanavaranAuditLogSchema =
|
|||||||
SchemaFactory.createForClass(FanavaranAuditLog);
|
SchemaFactory.createForClass(FanavaranAuditLog);
|
||||||
|
|
||||||
FanavaranAuditLogSchema.index({ trackingCode: 1, createdAt: 1 });
|
FanavaranAuditLogSchema.index({ trackingCode: 1, createdAt: 1 });
|
||||||
|
FanavaranAuditLogSchema.index({ claimCaseId: 1, createdAt: 1 });
|
||||||
|
|||||||
24
src/fanavaran/schema/fanavaran-auth-token.schema.ts
Normal file
24
src/fanavaran/schema/fanavaran-auth-token.schema.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
|
import { HydratedDocument } from "mongoose";
|
||||||
|
import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared Fanavaran authenticationToken per tenant.
|
||||||
|
* Survives process restarts / multi-instance so we do not Login on every request.
|
||||||
|
*/
|
||||||
|
@Schema({ collection: "fanavaranAuthTokens", timestamps: true })
|
||||||
|
export class FanavaranAuthToken {
|
||||||
|
@Prop({ type: String, required: true, unique: true, index: true })
|
||||||
|
clientKey: FanavaranClientKey;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: true })
|
||||||
|
authenticationToken: string;
|
||||||
|
|
||||||
|
/** When this token should be refreshed (Asia/Tehran midnight). */
|
||||||
|
@Prop({ type: Date, required: true, index: true })
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FanavaranAuthTokenDocument = HydratedDocument<FanavaranAuthToken>;
|
||||||
|
export const FanavaranAuthTokenSchema =
|
||||||
|
SchemaFactory.createForClass(FanavaranAuthToken);
|
||||||
111
src/fanavaran/schema/fanavaran-client-config.schema.ts
Normal file
111
src/fanavaran/schema/fanavaran-client-config.schema.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
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 FanavaranAuthConfigEmbed {
|
||||||
|
@Prop({ type: String, required: true })
|
||||||
|
appName: string;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: true })
|
||||||
|
secret: string;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: true })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: true })
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: true })
|
||||||
|
corpId: string;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: true })
|
||||||
|
contractId: string;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: true })
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
export const FanavaranAuthConfigEmbedSchema = SchemaFactory.createForClass(
|
||||||
|
FanavaranAuthConfigEmbed,
|
||||||
|
);
|
||||||
|
|
||||||
|
@Schema({ _id: false })
|
||||||
|
export class FanavaranPayloadDefaultsEmbed {
|
||||||
|
@Prop({ type: Number, required: true })
|
||||||
|
AccidentCityId: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number, required: true })
|
||||||
|
AccidentReportTypeId: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number, required: true })
|
||||||
|
AccidentVehicleUsedId: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GEN.03 base claim — کارشناس مسئول پرونده مالی
|
||||||
|
* (Parsian example: 154 / 4662 — NOT the assessor role).
|
||||||
|
*/
|
||||||
|
@Prop({ type: Number, required: true })
|
||||||
|
ClaimExpertId: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GEN.08 expertise — کارشناس ارزیاب خسارت ثالث مالی (or وکیل/تحقیق).
|
||||||
|
* (Parsian example: 29; Tejaratno proven send: 2709).
|
||||||
|
*/
|
||||||
|
@Prop({ type: Number, required: true })
|
||||||
|
ExpertiseClaimExpertId: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number, required: true })
|
||||||
|
CompensationReferenceId: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number, required: true })
|
||||||
|
CulpritLicenceTypeId: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number, required: true })
|
||||||
|
CulpritTypeId: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number, required: true })
|
||||||
|
DmgCaseTypeId: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number, required: true })
|
||||||
|
DmgHistoryStatus: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number, required: true })
|
||||||
|
PlaqueKindId: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number, required: true })
|
||||||
|
PlaqueSampleId: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number, required: true })
|
||||||
|
DriverIsOwner: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number, required: true })
|
||||||
|
FaultPercent: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number, required: true })
|
||||||
|
ClaimFileTypeId: number;
|
||||||
|
}
|
||||||
|
export const FanavaranPayloadDefaultsEmbedSchema = SchemaFactory.createForClass(
|
||||||
|
FanavaranPayloadDefaultsEmbed,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-tenant Fanavaran auth + payload defaults.
|
||||||
|
* Seeded on boot when missing; edit in Mongo without redeploying code.
|
||||||
|
*/
|
||||||
|
@Schema({ collection: "fanavaranClientConfigs", timestamps: true })
|
||||||
|
export class FanavaranClientConfig {
|
||||||
|
@Prop({ type: String, required: true, unique: true, index: true })
|
||||||
|
key: FanavaranClientKey;
|
||||||
|
|
||||||
|
@Prop({ type: FanavaranAuthConfigEmbedSchema, required: true })
|
||||||
|
auth: FanavaranAuthConfigEmbed;
|
||||||
|
|
||||||
|
@Prop({ type: FanavaranPayloadDefaultsEmbedSchema, required: true })
|
||||||
|
defaults: FanavaranPayloadDefaultsEmbed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FanavaranClientConfigDocument =
|
||||||
|
HydratedDocument<FanavaranClientConfig>;
|
||||||
|
export const FanavaranClientConfigSchema = SchemaFactory.createForClass(
|
||||||
|
FanavaranClientConfig,
|
||||||
|
);
|
||||||
@@ -369,6 +369,7 @@ export class InquiryRefreshService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
next.insurance.policyNumber =
|
next.insurance.policyNumber =
|
||||||
|
mapped.PrntCmpDocNo ||
|
||||||
mapped.PrntPlcyCmpDocNo ||
|
mapped.PrntPlcyCmpDocNo ||
|
||||||
mapped.printNumber ||
|
mapped.printNumber ||
|
||||||
mapped.insuranceNumber ||
|
mapped.insuranceNumber ||
|
||||||
@@ -384,9 +385,17 @@ export class InquiryRefreshService {
|
|||||||
? String(financialCeiling)
|
? String(financialCeiling)
|
||||||
: next.insurance.financialCeiling;
|
: next.insurance.financialCeiling;
|
||||||
next.insurance.startDate =
|
next.insurance.startDate =
|
||||||
mapped.persianStartDate || mapped.IssueDate || mapped.SatrtDate || next.insurance.startDate;
|
mapped.StartDate ||
|
||||||
|
mapped.HBgnDte ||
|
||||||
|
mapped.persianStartDate ||
|
||||||
|
mapped.SatrtDate ||
|
||||||
|
mapped.IssueDate ||
|
||||||
|
next.insurance.startDate;
|
||||||
next.insurance.endDate =
|
next.insurance.endDate =
|
||||||
mapped.persianEndDate || mapped.EndDate || next.insurance.endDate;
|
mapped.EndDate ||
|
||||||
|
mapped.HEndDte ||
|
||||||
|
mapped.persianEndDate ||
|
||||||
|
next.insurance.endDate;
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1371,11 +1371,23 @@ export class RequestManagementService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!party.insurance) party.insurance = {} as any;
|
if (!party.insurance) party.insurance = {} as any;
|
||||||
party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo;
|
party.insurance.policyNumber =
|
||||||
|
inquiryMapped?.PrntCmpDocNo ||
|
||||||
|
inquiryMapped?.PrntPlcyCmpDocNo ||
|
||||||
|
inquiryMapped?.printNumber ||
|
||||||
|
inquiryMapped?.insuranceNumber;
|
||||||
party.insurance.company = clientName;
|
party.insurance.company = clientName;
|
||||||
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
|
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
|
||||||
party.insurance.startDate = inquiryMapped?.IssueDate;
|
party.insurance.startDate =
|
||||||
party.insurance.endDate = inquiryMapped?.EndDate;
|
inquiryMapped?.StartDate ||
|
||||||
|
inquiryMapped?.HBgnDte ||
|
||||||
|
inquiryMapped?.persianStartDate ||
|
||||||
|
inquiryMapped?.SatrtDate ||
|
||||||
|
inquiryMapped?.IssueDate;
|
||||||
|
party.insurance.endDate =
|
||||||
|
inquiryMapped?.EndDate ||
|
||||||
|
inquiryMapped?.HEndDte ||
|
||||||
|
inquiryMapped?.persianEndDate;
|
||||||
|
|
||||||
// CAR BODY EXTERNAL API INQUIRY
|
// CAR BODY EXTERNAL API INQUIRY
|
||||||
if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) {
|
if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) {
|
||||||
@@ -1776,11 +1788,23 @@ export class RequestManagementService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!party.insurance) party.insurance = {} as any;
|
if (!party.insurance) party.insurance = {} as any;
|
||||||
party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo;
|
party.insurance.policyNumber =
|
||||||
|
inquiryMapped?.PrntCmpDocNo ||
|
||||||
|
inquiryMapped?.PrntPlcyCmpDocNo ||
|
||||||
|
inquiryMapped?.printNumber ||
|
||||||
|
inquiryMapped?.insuranceNumber;
|
||||||
party.insurance.company = clientName;
|
party.insurance.company = clientName;
|
||||||
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
|
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
|
||||||
party.insurance.startDate = inquiryMapped?.IssueDate;
|
party.insurance.startDate =
|
||||||
party.insurance.endDate = inquiryMapped?.EndDate;
|
inquiryMapped?.StartDate ||
|
||||||
|
inquiryMapped?.HBgnDte ||
|
||||||
|
inquiryMapped?.persianStartDate ||
|
||||||
|
inquiryMapped?.SatrtDate ||
|
||||||
|
inquiryMapped?.IssueDate;
|
||||||
|
party.insurance.endDate =
|
||||||
|
inquiryMapped?.EndDate ||
|
||||||
|
inquiryMapped?.HEndDte ||
|
||||||
|
inquiryMapped?.persianEndDate;
|
||||||
|
|
||||||
// Advance workflow
|
// Advance workflow
|
||||||
await this.advanceWorkflowToNext(req, stepKey);
|
await this.advanceWorkflowToNext(req, stepKey);
|
||||||
@@ -8592,11 +8616,23 @@ export class RequestManagementService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!party.insurance) party.insurance = {} as any;
|
if (!party.insurance) party.insurance = {} as any;
|
||||||
party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo;
|
party.insurance.policyNumber =
|
||||||
|
inquiryMapped?.PrntCmpDocNo ||
|
||||||
|
inquiryMapped?.PrntPlcyCmpDocNo ||
|
||||||
|
inquiryMapped?.printNumber ||
|
||||||
|
inquiryMapped?.insuranceNumber;
|
||||||
party.insurance.company = clientName;
|
party.insurance.company = clientName;
|
||||||
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
|
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
|
||||||
party.insurance.startDate = inquiryMapped?.IssueDate;
|
party.insurance.startDate =
|
||||||
party.insurance.endDate = inquiryMapped?.EndDate;
|
inquiryMapped?.StartDate ||
|
||||||
|
inquiryMapped?.HBgnDte ||
|
||||||
|
inquiryMapped?.persianStartDate ||
|
||||||
|
inquiryMapped?.SatrtDate ||
|
||||||
|
inquiryMapped?.IssueDate;
|
||||||
|
party.insurance.endDate =
|
||||||
|
inquiryMapped?.EndDate ||
|
||||||
|
inquiryMapped?.HEndDte ||
|
||||||
|
inquiryMapped?.persianEndDate;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
req.type === BlameRequestType.CAR_BODY &&
|
req.type === BlameRequestType.CAR_BODY &&
|
||||||
@@ -9392,11 +9428,23 @@ export class RequestManagementService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!party.insurance) party.insurance = {} as any;
|
if (!party.insurance) party.insurance = {} as any;
|
||||||
party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo;
|
party.insurance.policyNumber =
|
||||||
|
inquiryMapped?.PrntCmpDocNo ||
|
||||||
|
inquiryMapped?.PrntPlcyCmpDocNo ||
|
||||||
|
inquiryMapped?.printNumber ||
|
||||||
|
inquiryMapped?.insuranceNumber;
|
||||||
party.insurance.company = clientName;
|
party.insurance.company = clientName;
|
||||||
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
|
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
|
||||||
party.insurance.startDate = inquiryMapped?.IssueDate;
|
party.insurance.startDate =
|
||||||
party.insurance.endDate = inquiryMapped?.EndDate;
|
inquiryMapped?.StartDate ||
|
||||||
|
inquiryMapped?.HBgnDte ||
|
||||||
|
inquiryMapped?.persianStartDate ||
|
||||||
|
inquiryMapped?.SatrtDate ||
|
||||||
|
inquiryMapped?.IssueDate;
|
||||||
|
party.insurance.endDate =
|
||||||
|
inquiryMapped?.EndDate ||
|
||||||
|
inquiryMapped?.HEndDte ||
|
||||||
|
inquiryMapped?.persianEndDate;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
req.type === BlameRequestType.CAR_BODY &&
|
req.type === BlameRequestType.CAR_BODY &&
|
||||||
|
|||||||
@@ -114,4 +114,32 @@ describe("SandHubService inquiry mocks", () => {
|
|||||||
|
|
||||||
expect(mapped.PrntPlcyCmpDocNo).toBe("1405/1143-LEGACY/200/1");
|
expect(mapped.PrntPlcyCmpDocNo).toBe("1405/1143-LEGACY/200/1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps ESG PrntCmpDocNo → PrntPlcyCmpDocNo and preserves begin/end dates", () => {
|
||||||
|
const mapped = (service as any).mapEsgPolicyByPlateToOldFormat({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
PrntCmpDocNo: "1110/111130/404/003412",
|
||||||
|
LastCmpDocNo: "1110/111130/403/002650",
|
||||||
|
PlcyUnqCod: "16018466143",
|
||||||
|
HBgnDte: "1404/10/23",
|
||||||
|
HEndDte: "1405/10/23",
|
||||||
|
HIsuDte: "1404/10/21",
|
||||||
|
VIN: "IRFC891V7D2301065",
|
||||||
|
MtrNum: "13389030167",
|
||||||
|
ShsNum: "NAAP41FD5BJ301065",
|
||||||
|
PrdDte: "1389",
|
||||||
|
CmpCod: "8",
|
||||||
|
CmpNam: "بيمه پارسيان",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mapped.PrntPlcyCmpDocNo).toBe("1110/111130/404/003412");
|
||||||
|
expect(mapped.ThirdPolicyCode).toBe("16018466143");
|
||||||
|
expect(mapped.StartDate).toBe("1404/10/23");
|
||||||
|
expect(mapped.EndDate).toBe("1405/10/23");
|
||||||
|
expect(mapped.IssueDate).toBe("1404/10/21");
|
||||||
|
expect(mapped.VIN).toBe("IRFC891V7D2301065");
|
||||||
|
expect(mapped.ModelField).toBe("1389");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -560,14 +560,22 @@ export class SandHubService {
|
|||||||
mtrnum: data.MtrNum ?? data.mtrnum,
|
mtrnum: data.MtrNum ?? data.mtrnum,
|
||||||
shsNam: data.ShsNum ?? data.ShsNam ?? data.shsNam,
|
shsNam: data.ShsNum ?? data.ShsNam ?? data.shsNam,
|
||||||
vin: data.VIN ?? data.vin,
|
vin: data.VIN ?? data.vin,
|
||||||
|
VIN: data.VIN ?? data.vin,
|
||||||
ntnlId: data.NtnlId ?? data.ntnlId,
|
ntnlId: data.NtnlId ?? data.ntnlId,
|
||||||
fullname: data.InsNam ?? data.fullname,
|
fullname: data.InsNam ?? data.fullname,
|
||||||
ThirdPolicyCode: data.PlcyUnqCod ?? data.ThirdPolicyCode,
|
ThirdPolicyCode: data.PlcyUnqCod ?? data.ThirdPolicyCode,
|
||||||
|
// ESG print/policy document number — canonical Tejarat name is PrntPlcyCmpDocNo
|
||||||
|
PrntPlcyCmpDocNo:
|
||||||
|
data.PrntCmpDocNo ??
|
||||||
|
data.PrntPlcyCmpDocNo ??
|
||||||
|
data.printNumber ??
|
||||||
|
data.insuranceNumber,
|
||||||
LastCompanyDocumentNumber:
|
LastCompanyDocumentNumber:
|
||||||
data.LastCmpDocNo ?? data.LastCompanyDocumentNumber,
|
data.LastCmpDocNo ?? data.LastCompanyDocumentNumber,
|
||||||
IssueDate: data.HIsuDte ?? data.IssueDate,
|
IssueDate: data.HIsuDte ?? data.IssueDate,
|
||||||
StartDate: data.HBgnDte ?? data.StartDate,
|
StartDate: data.HBgnDte ?? data.StartDate,
|
||||||
EndDate: data.HEndDte ?? data.EndDate,
|
EndDate: data.HEndDte ?? data.EndDate,
|
||||||
|
ModelField: data.PrdDte ?? data.ModelField ?? data.ModelCii,
|
||||||
EdrsJson: data.Edrses ?? data.EdrsJson,
|
EdrsJson: data.Edrses ?? data.EdrsJson,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1010,14 +1018,26 @@ export class SandHubService {
|
|||||||
FinancialCvrCptl:
|
FinancialCvrCptl:
|
||||||
newResponse.financeCoverage || newResponse.FinancialCvrCptl || "0",
|
newResponse.financeCoverage || newResponse.FinancialCvrCptl || "0",
|
||||||
|
|
||||||
// Dates
|
// Dates — keep IssueDate (HIsuDte) distinct from StartDate (HBgnDte)
|
||||||
IssueDate: newResponse.persianStartDate || newResponse.IssueDate,
|
IssueDate:
|
||||||
EndDate: newResponse.persianEndDate || newResponse.EndDate,
|
newResponse.IssueDate ||
|
||||||
|
newResponse.HIsuDte ||
|
||||||
|
newResponse.persianStartDate,
|
||||||
|
StartDate:
|
||||||
|
newResponse.StartDate ||
|
||||||
|
newResponse.HBgnDte ||
|
||||||
|
newResponse.SatrtDate ||
|
||||||
|
newResponse.persianStartDate,
|
||||||
|
EndDate:
|
||||||
|
newResponse.EndDate ||
|
||||||
|
newResponse.HEndDte ||
|
||||||
|
newResponse.persianEndDate,
|
||||||
|
|
||||||
// Insurance policy number — canonical Tejarat name is PrntPlcyCmpDocNo;
|
// Insurance policy number — canonical Tejarat name is PrntPlcyCmpDocNo;
|
||||||
// newer API formats surface it as printNumber or insuranceNumber.
|
// ESG uses PrntCmpDocNo; newer formats use printNumber / insuranceNumber.
|
||||||
PrntPlcyCmpDocNo:
|
PrntPlcyCmpDocNo:
|
||||||
newResponse.PrntPlcyCmpDocNo ||
|
newResponse.PrntPlcyCmpDocNo ||
|
||||||
|
newResponse.PrntCmpDocNo ||
|
||||||
newResponse.printNumber ||
|
newResponse.printNumber ||
|
||||||
newResponse.insuranceNumber ||
|
newResponse.insuranceNumber ||
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const PARSIAN_TEMPLATE_BODIES: Record<string, string> = {
|
|||||||
"لطفاً مدارک پرونده {token} ({token2}) را مجدداً ارسال کنید.\nلینک: {token3}",
|
"لطفاً مدارک پرونده {token} ({token2}) را مجدداً ارسال کنید.\nلینک: {token3}",
|
||||||
"yara-signature":
|
"yara-signature":
|
||||||
"امضای پرونده {token} ({token2}) توسط کارشناس {token3} بررسی شد.\nلینک: {token10}",
|
"امضای پرونده {token} ({token2}) توسط کارشناس {token3} بررسی شد.\nلینک: {token10}",
|
||||||
|
"yara-fanavaran-claim":
|
||||||
|
"کاربر گرامی پرونده شما به شماره {token3} در فناوران با شناسه {token2} و شماره {token} ثبت شده است. جهت پیگیری های آتی پرونده خود باید از این اطلاعات استفاده کنید.",
|
||||||
};
|
};
|
||||||
|
|
||||||
function applyTokens(
|
function applyTokens(
|
||||||
|
|||||||
@@ -191,6 +191,40 @@ export class SmsOrchestrationService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify the claim owner (damaged party) that the Fanavaran base claim was
|
||||||
|
* created. Uses the same SMS gateway/provider as login for this deployment.
|
||||||
|
*
|
||||||
|
* token = ClaimNo, token2 = claimId (Fanavaran Id), token3 = Yara publicId
|
||||||
|
*
|
||||||
|
* Parsian body:
|
||||||
|
* کاربر گرامی پرونده شما به شماره {publicId} در فناوران با شناسه {claimId}
|
||||||
|
* و شماره {claimNo} ثبت شده است. جهت پیگیری های آتی پرونده خود باید از این
|
||||||
|
* اطلاعات استفاده کنید.
|
||||||
|
*/
|
||||||
|
async sendFanavaranBaseClaimRegisteredNotice(params: {
|
||||||
|
receptor: string;
|
||||||
|
publicId: string;
|
||||||
|
claimNo?: string | number | null;
|
||||||
|
claimId?: string | number | null;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const claimNo =
|
||||||
|
params.claimNo != null && String(params.claimNo).trim()
|
||||||
|
? String(params.claimNo)
|
||||||
|
: "-";
|
||||||
|
const claimId =
|
||||||
|
params.claimId != null && String(params.claimId).trim()
|
||||||
|
? String(params.claimId)
|
||||||
|
: "-";
|
||||||
|
return this.sendTemplate({
|
||||||
|
template: "yara-fanavaran-claim",
|
||||||
|
receptor: params.receptor,
|
||||||
|
token: params.publicId || "-",
|
||||||
|
token2: claimId,
|
||||||
|
token3: claimNo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async sendTemplate(args: TemplateArgs): Promise<boolean> {
|
private async sendTemplate(args: TemplateArgs): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await this.smsGatewayService.verifyLookUp(args);
|
await this.smsGatewayService.verifyLookUp(args);
|
||||||
|
|||||||
Reference in New Issue
Block a user