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(); private readonly inflightLogin = new Map< FanavaranClientKey, Promise >(); 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; 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 { 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 { 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) => { if (headers) { delete headers["Content-Type"]; delete headers["content-type"]; } return _data; }, ]; } private async fetchAppToken( config: Pick, auditSession?: FanavaranAuditSession, ): Promise { 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, auditSession?: FanavaranAuditSession, ): Promise { 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, ), ); } }