forked from Yara724/api
Merge pull request 'main' (#238) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#238
This commit is contained in:
@@ -58,6 +58,7 @@ import { MediaPolicyModule } from "src/media-policy/media-policy.module";
|
||||
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
|
||||
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
||||
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -76,6 +77,7 @@ import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normaliz
|
||||
SandHubModule,
|
||||
ClientModule,
|
||||
MediaPolicyModule,
|
||||
SmsOrchestrationModule,
|
||||
JwtModule.register({}),
|
||||
MongooseModule.forFeature([
|
||||
{ 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 })
|
||||
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: [] })
|
||||
files?: unknown[];
|
||||
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
export type FanavaranClientKey = "parsian" | "tejaratno";
|
||||
export type FanavaranClientKey = "parsian" | "tejaratno" | "moallem";
|
||||
|
||||
export const FANAVARAN_CLIENT_KEYS: readonly FanavaranClientKey[] = [
|
||||
"parsian",
|
||||
"tejaratno",
|
||||
"moallem",
|
||||
] 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(
|
||||
value: string,
|
||||
): value is FanavaranClientKey {
|
||||
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 {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "parsian" || normalized === "tejaratno") {
|
||||
if (isFanavaranClientKey(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(
|
||||
@@ -56,6 +62,29 @@ export interface FanavaranClientProfile {
|
||||
defaults: FanavaranPayloadDefaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared codebook-ish defaults used when a tenant has not supplied its own
|
||||
* ClaimExpertId / plaque ids yet. Moallem auth is real; ClaimExpertId may need
|
||||
* a Moallem-specific value from Fanavaran lookups after first deploy.
|
||||
*/
|
||||
const SHARED_FANAVARAN_DEFAULTS: FanavaranPayloadDefaults = {
|
||||
AccidentCityId: 701,
|
||||
AccidentReportTypeId: 155,
|
||||
AccidentVehicleUsedId: 1,
|
||||
ClaimExpertId: 4543092,
|
||||
ExpertiseClaimExpertId: 4543092,
|
||||
CompensationReferenceId: 167,
|
||||
CulpritLicenceTypeId: 2,
|
||||
CulpritTypeId: 337,
|
||||
DmgCaseTypeId: 175,
|
||||
DmgHistoryStatus: 5214,
|
||||
PlaqueKindId: 8,
|
||||
PlaqueSampleId: 10,
|
||||
DriverIsOwner: 0,
|
||||
FaultPercent: 100,
|
||||
ClaimFileTypeId: 23,
|
||||
};
|
||||
|
||||
const FANAVARAN_CLIENT_PROFILES: Record<
|
||||
FanavaranClientKey,
|
||||
FanavaranClientProfile
|
||||
@@ -72,20 +101,9 @@ const FANAVARAN_CLIENT_PROFILES: Record<
|
||||
location: "100",
|
||||
},
|
||||
defaults: {
|
||||
AccidentCityId: 701,
|
||||
AccidentReportTypeId: 155,
|
||||
AccidentVehicleUsedId: 1,
|
||||
...SHARED_FANAVARAN_DEFAULTS,
|
||||
ClaimExpertId: 4543092,
|
||||
ExpertiseClaimExpertId: 4543092,
|
||||
CompensationReferenceId: 167,
|
||||
CulpritLicenceTypeId: 2,
|
||||
CulpritTypeId: 337,
|
||||
DmgCaseTypeId: 175,
|
||||
DmgHistoryStatus: 5214,
|
||||
PlaqueKindId: 8,
|
||||
PlaqueSampleId: 10,
|
||||
DriverIsOwner: 0,
|
||||
FaultPercent: 100,
|
||||
ClaimFileTypeId: 23,
|
||||
},
|
||||
},
|
||||
@@ -101,29 +119,35 @@ const FANAVARAN_CLIENT_PROFILES: Record<
|
||||
location: "210050",
|
||||
},
|
||||
defaults: {
|
||||
AccidentCityId: 701,
|
||||
AccidentReportTypeId: 155,
|
||||
AccidentVehicleUsedId: 1,
|
||||
...SHARED_FANAVARAN_DEFAULTS,
|
||||
ClaimExpertId: 154,
|
||||
ExpertiseClaimExpertId: 29,
|
||||
CompensationReferenceId: 167,
|
||||
CulpritLicenceTypeId: 2,
|
||||
CulpritTypeId: 337,
|
||||
DmgCaseTypeId: 175,
|
||||
DmgHistoryStatus: 5214,
|
||||
PlaqueKindId: 8,
|
||||
PlaqueSampleId: 10,
|
||||
DriverIsOwner: 0,
|
||||
FaultPercent: 100,
|
||||
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",
|
||||
},
|
||||
// ClaimExpertId / ClaimFileTypeId not yet confirmed for Moallem — start from
|
||||
// shared Fanavaran codebook defaults and override after lookup.
|
||||
defaults: {
|
||||
...SHARED_FANAVARAN_DEFAULTS,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Resolve active Fanavaran tenant from env (`FANAVARAN_CLIENT`) with optional CLIENT_ID fallback. */
|
||||
export function resolveFanavaranClientKey(): FanavaranClientKey {
|
||||
const explicit = process.env.FANAVARAN_CLIENT?.trim().toLowerCase();
|
||||
if (explicit === "parsian" || explicit === "tejaratno") {
|
||||
if (explicit && isFanavaranClientKey(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { InjectModel } from "@nestjs/mongoose";
|
||||
import { isAxiosError } from "axios";
|
||||
import { isAxiosError, type AxiosResponse } from "axios";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { Model, Types } from "mongoose";
|
||||
import {
|
||||
@@ -16,18 +16,54 @@ export interface RecordFanavaranAuditStepInput {
|
||||
step: FanavaranAuditStep;
|
||||
status: FanavaranAuditStatus;
|
||||
requestUrl?: string;
|
||||
requestMethod?: string;
|
||||
httpStatus?: number;
|
||||
requestHeaders?: Record<string, unknown>;
|
||||
requestBody?: unknown;
|
||||
requestMeta?: Record<string, unknown>;
|
||||
responseHeaders?: Record<string, unknown>;
|
||||
responseBody?: unknown;
|
||||
responseMeta?: Record<string, unknown>;
|
||||
errorMessage?: string;
|
||||
errorDetails?: Record<string, unknown>;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface FanavaranHttpExchange {
|
||||
httpStatus?: number;
|
||||
requestHeaders?: Record<string, unknown>;
|
||||
responseHeaders?: Record<string, unknown>;
|
||||
responseBody?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FanavaranAuditService {
|
||||
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(
|
||||
@InjectModel(FanavaranAuditLog.name)
|
||||
private readonly auditModel: Model<FanavaranAuditLogDocument>,
|
||||
@@ -47,6 +83,138 @@ export class FanavaranAuditService {
|
||||
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> {
|
||||
if (isAxiosError(error)) {
|
||||
const data = error.response?.data;
|
||||
@@ -54,12 +222,13 @@ export class FanavaranAuditService {
|
||||
type: "axios",
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data:
|
||||
data: this.sanitizeBody(
|
||||
typeof data === "object" && data !== null
|
||||
? data
|
||||
: typeof data === "string"
|
||||
? data.slice(0, 2000)
|
||||
: data,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
@@ -112,8 +281,13 @@ export class FanavaranAuditService {
|
||||
? { claimRequestId: new Types.ObjectId(input.session.claimRequestId) }
|
||||
: {}),
|
||||
requestUrl: input.requestUrl,
|
||||
requestMethod: input.requestMethod,
|
||||
httpStatus: input.httpStatus,
|
||||
requestHeaders: this.sanitizeHeaders(input.requestHeaders),
|
||||
requestBody: this.sanitizeBody(input.requestBody),
|
||||
requestMeta: input.requestMeta,
|
||||
responseHeaders: this.sanitizeHeaders(input.responseHeaders),
|
||||
responseBody: this.sanitizeBody(input.responseBody),
|
||||
responseMeta: input.responseMeta,
|
||||
errorMessage: input.errorMessage,
|
||||
errorDetails: input.errorDetails,
|
||||
@@ -134,4 +308,31 @@ export class FanavaranAuditService {
|
||||
.lean()
|
||||
.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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
|
||||
import { FanavaranAuditModule } from "./fanavaran-audit.module";
|
||||
import { FanavaranAuthService } from "./fanavaran-auth.service";
|
||||
import { FanavaranLookupService } from "./fanavaran-lookup.service";
|
||||
import {
|
||||
FanavaranAuthToken,
|
||||
FanavaranAuthTokenSchema,
|
||||
} from "./schema/fanavaran-auth-token.schema";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -11,8 +18,12 @@ import { FanavaranLookupService } from "./fanavaran-lookup.service";
|
||||
inject: [ConfigService],
|
||||
useFactory: createHttpModuleOptions,
|
||||
}),
|
||||
MongooseModule.forFeature([
|
||||
{ name: FanavaranAuthToken.name, schema: FanavaranAuthTokenSchema },
|
||||
]),
|
||||
FanavaranAuditModule,
|
||||
],
|
||||
providers: [FanavaranLookupService],
|
||||
exports: [FanavaranLookupService],
|
||||
providers: [FanavaranAuthService, FanavaranLookupService],
|
||||
exports: [FanavaranAuthService, FanavaranLookupService],
|
||||
})
|
||||
export class FanavaranLookupModule {}
|
||||
|
||||
@@ -9,26 +9,22 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
getFanavaranClientProfile,
|
||||
type FanavaranClientKey,
|
||||
} from "src/core/config/fanavaran-client.config";
|
||||
import { type FanavaranClientKey } from "src/core/config/fanavaran-client.config";
|
||||
import {
|
||||
FANAVARAN_LOOKUP_BASE_URL,
|
||||
fanavaranLookupCacheDir,
|
||||
tejaratStaticAccidentFilePath,
|
||||
} from "./fanavaran-lookup.config";
|
||||
import { FanavaranAuthService } from "./fanavaran-auth.service";
|
||||
|
||||
@Injectable()
|
||||
export class FanavaranLookupService {
|
||||
private readonly logger = new Logger(FanavaranLookupService.name);
|
||||
|
||||
private readonly getAppTokenUrl =
|
||||
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken";
|
||||
private readonly loginUrl =
|
||||
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login";
|
||||
|
||||
constructor(private readonly httpService: HttpService) {}
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly fanavaranAuthService: FanavaranAuthService,
|
||||
) {}
|
||||
|
||||
private cacheFilePath(clientKey: FanavaranClientKey, fileName: string): string {
|
||||
return join(fanavaranLookupCacheDir(clientKey), fileName);
|
||||
@@ -71,91 +67,12 @@ export class FanavaranLookupService {
|
||||
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(
|
||||
clientKey: FanavaranClientKey,
|
||||
url: string,
|
||||
): Promise<unknown> {
|
||||
const profile = getFanavaranClientProfile(clientKey);
|
||||
|
||||
try {
|
||||
const appToken = await this.getAppToken(profile.auth);
|
||||
const authenticationToken = await this.login(appToken, profile.auth);
|
||||
const headers = await this.fanavaranAuthService.getRequestHeaders(clientKey);
|
||||
|
||||
this.logger.log(
|
||||
`[${clientKey}] Calling Fanavaran lookup API: ${url}`,
|
||||
@@ -164,10 +81,7 @@ export class FanavaranLookupService {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get(url, {
|
||||
headers: {
|
||||
authenticationToken,
|
||||
CorpId: profile.auth.corpId,
|
||||
ContractId: profile.auth.contractId,
|
||||
Location: profile.auth.location,
|
||||
...headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 20000,
|
||||
@@ -184,8 +98,10 @@ export class FanavaranLookupService {
|
||||
`[${clientKey}] Fanavaran lookup response status=${response.status} dataCount=${dataCount}`,
|
||||
);
|
||||
|
||||
this.fanavaranAuthService.clearBackoff(clientKey);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.fanavaranAuthService.registerFailure(clientKey, error);
|
||||
const message = isAxiosError(error)
|
||||
? error.response?.data?.Message ||
|
||||
error.response?.data?.message ||
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||
import {
|
||||
FANAVARAN_CLIENT_KEYS,
|
||||
FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||
isFanavaranClientKey,
|
||||
listFanavaranClientProfiles,
|
||||
normalizeFanavaranClientKey,
|
||||
@@ -37,7 +39,7 @@ export class FanavaranController {
|
||||
@ApiOperation({
|
||||
summary: "List supported Fanavaran insurance clients",
|
||||
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() {
|
||||
const activeClient = resolveFanavaranClientKey();
|
||||
@@ -55,12 +57,12 @@ export class FanavaranController {
|
||||
@ApiOperation({
|
||||
summary: "Preview Fanavaran base claim create payload",
|
||||
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({
|
||||
name: "client",
|
||||
description: "Fanavaran tenant key",
|
||||
enum: ["parsian", "tejaratno"],
|
||||
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimCaseId",
|
||||
@@ -71,16 +73,39 @@ export class FanavaranController {
|
||||
required: false,
|
||||
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(
|
||||
@Param("client") client: string,
|
||||
@Param("claimCaseId") claimCaseId: string,
|
||||
@Query("debug") debug?: string,
|
||||
@Query("forceRefreshPolicy") forceRefreshPolicy?: string,
|
||||
@Query("resolvePolicy") _resolvePolicy?: string,
|
||||
) {
|
||||
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(
|
||||
claimCaseId,
|
||||
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({
|
||||
name: "client",
|
||||
description: "Fanavaran tenant key",
|
||||
enum: ["parsian", "tejaratno"],
|
||||
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimCaseId",
|
||||
@@ -121,7 +146,7 @@ export class FanavaranController {
|
||||
@ApiParam({
|
||||
name: "client",
|
||||
description: "Fanavaran tenant key",
|
||||
enum: ["parsian", "tejaratno"],
|
||||
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimCaseId",
|
||||
@@ -142,12 +167,12 @@ export class FanavaranController {
|
||||
@ApiOperation({
|
||||
summary: "Submit Fanavaran damage-case request",
|
||||
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({
|
||||
name: "client",
|
||||
description: "Fanavaran tenant key",
|
||||
enum: ["parsian", "tejaratno"],
|
||||
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimCaseId",
|
||||
@@ -175,7 +200,7 @@ export class FanavaranController {
|
||||
@ApiParam({
|
||||
name: "client",
|
||||
description: "Fanavaran tenant key",
|
||||
enum: ["parsian", "tejaratno"],
|
||||
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimCaseId",
|
||||
@@ -201,7 +226,7 @@ export class FanavaranController {
|
||||
@ApiParam({
|
||||
name: "client",
|
||||
description: "Fanavaran tenant key",
|
||||
enum: ["parsian", "tejaratno"],
|
||||
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimCaseId",
|
||||
@@ -227,7 +252,7 @@ export class FanavaranController {
|
||||
@ApiParam({
|
||||
name: "client",
|
||||
description: "Fanavaran tenant key",
|
||||
enum: ["parsian", "tejaratno"],
|
||||
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimCaseId",
|
||||
@@ -253,7 +278,7 @@ export class FanavaranController {
|
||||
@ApiParam({
|
||||
name: "client",
|
||||
description: "Fanavaran tenant key",
|
||||
enum: ["parsian", "tejaratno"],
|
||||
enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimCaseId",
|
||||
@@ -275,7 +300,7 @@ export class FanavaranController {
|
||||
private parseClientParam(client: string) {
|
||||
if (!isFanavaranClientKey(client)) {
|
||||
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);
|
||||
|
||||
@@ -62,12 +62,33 @@ export class FanavaranAuditLog {
|
||||
@Prop({ type: String, required: false })
|
||||
requestUrl?: string;
|
||||
|
||||
@Prop({ type: String, required: false })
|
||||
requestMethod?: string;
|
||||
|
||||
@Prop({ type: Number, required: false })
|
||||
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 })
|
||||
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 })
|
||||
responseMeta?: Record<string, unknown>;
|
||||
|
||||
@@ -86,3 +107,4 @@ export const FanavaranAuditLogSchema =
|
||||
SchemaFactory.createForClass(FanavaranAuditLog);
|
||||
|
||||
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);
|
||||
@@ -18,6 +18,8 @@ const PARSIAN_TEMPLATE_BODIES: Record<string, string> = {
|
||||
"لطفاً مدارک پرونده {token} ({token2}) را مجدداً ارسال کنید.\nلینک: {token3}",
|
||||
"yara-signature":
|
||||
"امضای پرونده {token} ({token2}) توسط کارشناس {token3} بررسی شد.\nلینک: {token10}",
|
||||
"yara-fanavaran-claim":
|
||||
"کاربر گرامی پرونده شما به شماره {token3} در فناوران با شناسه {token2} و شماره {token} ثبت شده است. جهت پیگیری های آتی پرونده خود باید از این اطلاعات استفاده کنید.",
|
||||
};
|
||||
|
||||
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> {
|
||||
try {
|
||||
await this.smsGatewayService.verifyLookUp(args);
|
||||
|
||||
Reference in New Issue
Block a user