forked from Yara724/api
fanavaran rate bugs fixed + sms of claimId and claimNo added
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 { 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[];
|
||||||
|
|
||||||
|
|||||||
104
src/fanavaran/fanavaran-auth.service.spec.ts
Normal file
104
src/fanavaran/fanavaran-auth.service.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { FanavaranAuthService } from "./fanavaran-auth.service";
|
||||||
|
|
||||||
|
describe("FanavaranAuthService", () => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
let loginCalls = 0;
|
||||||
|
http.post.mockImplementation((url: string) => {
|
||||||
|
if (url.includes("GetAppToken")) {
|
||||||
|
return {
|
||||||
|
toPromise: undefined,
|
||||||
|
pipe: undefined,
|
||||||
|
subscribe: undefined,
|
||||||
|
// firstValueFrom uses Observable — mock as Observable-like via rxjs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use real firstValueFrom path by mocking httpService.post to return an Observable
|
||||||
|
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);
|
||||||
|
|
||||||
|
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("enters tenant backoff on try-later errors", () => {
|
||||||
|
const service = new FanavaranAuthService({} as any, {} 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", () => {
|
||||||
|
// 2026-08-02 10:00:00 UTC ≈ 13:30 Tehran (UTC+3:30) → same calendar day midnight
|
||||||
|
const now = Date.parse("2026-08-02T10:00:00.000Z");
|
||||||
|
const expiry = FanavaranAuthService.getNextMidnightExpiryMs(now);
|
||||||
|
expect(expiry).toBeGreaterThan(now);
|
||||||
|
// Must land within ~14h (before next Tehran midnight)
|
||||||
|
expect(expiry - now).toBeLessThanOrEqual(24 * 60 * 60 * 1000);
|
||||||
|
expect(expiry - now).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Just after Tehran midnight: 2026-08-01 20:30:01 UTC = 2026-08-02 00:00:01 Tehran
|
||||||
|
const justAfterMidnight = Date.parse("2026-08-01T20:30:01.000Z");
|
||||||
|
const next = FanavaranAuthService.getNextMidnightExpiryMs(justAfterMidnight);
|
||||||
|
expect(next - justAfterMidnight).toBeGreaterThan(23 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
433
src/fanavaran/fanavaran-auth.service.ts
Normal file
433
src/fanavaran/fanavaran-auth.service.ts
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
import { HttpService } from "@nestjs/axios";
|
||||||
|
import {
|
||||||
|
BadGatewayException,
|
||||||
|
HttpException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { isAxiosError } from "axios";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 cached = this.tokenCache.get(clientKey);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
return cached.authenticationToken;
|
||||||
|
}
|
||||||
|
} 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();
|
||||||
|
this.tokenCache.set(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 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();
|
||||||
|
if (auditSession) {
|
||||||
|
await this.fanavaranAuditService.recordStep({
|
||||||
|
session: auditSession,
|
||||||
|
step: FanavaranAuditStep.GET_APP_TOKEN,
|
||||||
|
status: FanavaranAuditStatus.STARTED,
|
||||||
|
requestUrl: this.getAppTokenUrl,
|
||||||
|
requestMeta: { appName: config.appName, cached: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.post(this.getAppTokenUrl, "", {
|
||||||
|
headers: {
|
||||||
|
appname: config.appName,
|
||||||
|
secret: config.secret,
|
||||||
|
"Content-Length": "0",
|
||||||
|
},
|
||||||
|
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) {
|
||||||
|
await this.fanavaranAuditService.recordStep({
|
||||||
|
session: auditSession,
|
||||||
|
step: FanavaranAuditStep.GET_APP_TOKEN,
|
||||||
|
status: FanavaranAuditStatus.SUCCESS,
|
||||||
|
requestUrl: this.getAppTokenUrl,
|
||||||
|
httpStatus: response.status,
|
||||||
|
responseMeta: { hasAppToken: true },
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return appToken;
|
||||||
|
} catch (error) {
|
||||||
|
if (auditSession) {
|
||||||
|
await this.fanavaranAuditService.recordStep({
|
||||||
|
session: auditSession,
|
||||||
|
step: FanavaranAuditStep.GET_APP_TOKEN,
|
||||||
|
status: FanavaranAuditStatus.FAILURE,
|
||||||
|
requestUrl: this.getAppTokenUrl,
|
||||||
|
httpStatus: isAxiosError(error) ? error.response?.status : undefined,
|
||||||
|
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();
|
||||||
|
if (auditSession) {
|
||||||
|
await this.fanavaranAuditService.recordStep({
|
||||||
|
session: auditSession,
|
||||||
|
step: FanavaranAuditStep.LOGIN,
|
||||||
|
status: FanavaranAuditStatus.STARTED,
|
||||||
|
requestUrl: this.loginUrl,
|
||||||
|
requestMeta: { userName: config.username, cached: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.post(this.loginUrl, "", {
|
||||||
|
headers: {
|
||||||
|
appToken,
|
||||||
|
userName: config.username,
|
||||||
|
password: config.password,
|
||||||
|
"Content-Length": "0",
|
||||||
|
},
|
||||||
|
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) {
|
||||||
|
await this.fanavaranAuditService.recordStep({
|
||||||
|
session: auditSession,
|
||||||
|
step: FanavaranAuditStep.LOGIN,
|
||||||
|
status: FanavaranAuditStatus.SUCCESS,
|
||||||
|
requestUrl: this.loginUrl,
|
||||||
|
httpStatus: response.status,
|
||||||
|
responseMeta: { hasAuthenticationToken: true },
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return authenticationToken;
|
||||||
|
} catch (error) {
|
||||||
|
if (auditSession) {
|
||||||
|
await this.fanavaranAuditService.recordStep({
|
||||||
|
session: auditSession,
|
||||||
|
step: FanavaranAuditStep.LOGIN,
|
||||||
|
status: FanavaranAuditStatus.FAILURE,
|
||||||
|
requestUrl: this.loginUrl,
|
||||||
|
httpStatus: isAxiosError(error) ? error.response?.status : undefined,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ 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 { 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 { FanavaranLookupService } from "./fanavaran-lookup.service";
|
import { FanavaranLookupService } from "./fanavaran-lookup.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -11,8 +13,9 @@ import { FanavaranLookupService } from "./fanavaran-lookup.service";
|
|||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: createHttpModuleOptions,
|
useFactory: createHttpModuleOptions,
|
||||||
}),
|
}),
|
||||||
|
FanavaranAuditModule,
|
||||||
],
|
],
|
||||||
providers: [FanavaranLookupService],
|
providers: [FanavaranAuthService, FanavaranLookupService],
|
||||||
exports: [FanavaranLookupService],
|
exports: [FanavaranAuthService, 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 ||
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ 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",
|
||||||
@@ -71,16 +71,32 @@ 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",
|
||||||
|
})
|
||||||
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);
|
||||||
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" ||
|
||||||
|
resolvePolicy === "1" ||
|
||||||
|
resolvePolicy === "true",
|
||||||
|
requirePolicyId: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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