Assist_Design/apps/bff/src/integrations/freebit/services/freebit-client.service.ts

382 lines
13 KiB
TypeScript
Raw Normal View History

import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { RetryableErrors, withRetry } from "@bff/core/utils/retry.util.js";
import { redactForLogs } from "@bff/core/logging/redaction.util.js";
import { FreebitAuthService } from "./freebit-auth.service.js";
import { FreebitError } from "./freebit-error.service.js";
import { FreebitTestTrackerService } from "./freebit-test-tracker.service.js";
interface FreebitResponseBase {
resultCode?: string | number;
status?: {
message?: string;
statusCode?: string | number;
};
}
@Injectable()
export class FreebitClientService {
constructor(
private readonly authService: FreebitAuthService,
private readonly testTracker: FreebitTestTrackerService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Make an authenticated request to Freebit API with retry logic
*/
async makeAuthenticatedRequest<TResponse extends FreebitResponseBase, TPayload extends object>(
endpoint: string,
payload: TPayload
): Promise<TResponse> {
const config = this.authService.getConfig();
const authKey = await this.authService.getAuthKey();
const url = this.buildUrl(config.baseUrl, endpoint);
const requestPayload = { ...payload, authKey };
let attempt = 0;
try {
const responseData = await withRetry(
async () => {
attempt += 1;
this.logger.debug(`Freebit API request`, {
url,
attempt,
maxAttempts: config.retryAttempts,
payload: redactForLogs(requestPayload),
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `json=${JSON.stringify(requestPayload)}`,
signal: controller.signal,
});
if (!response.ok) {
const isProd = process.env["NODE_ENV"] === "production";
const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response);
this.logger.error("Freebit API HTTP error", {
url,
status: response.status,
statusText: response.statusText,
...(bodySnippet ? { responseBodySnippet: bodySnippet } : {}),
attempt,
});
throw new FreebitError(
`HTTP ${response.status}: ${response.statusText}`,
response.status.toString()
);
}
const responseData = (await response.json()) as TResponse;
const resultCode = this.normalizeResultCode(responseData.resultCode);
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
if (resultCode && resultCode !== "100") {
const isProd = process.env["NODE_ENV"] === "production";
const errorDetails = {
url,
resultCode,
statusCode,
statusMessage: responseData.status?.message,
...(isProd ? {} : { fullResponse: JSON.stringify(responseData) }),
};
this.logger.error("Freebit API returned error response", errorDetails);
// Also log to console for visibility in dev
if (!isProd) {
console.error("[FREEBIT ERROR]", JSON.stringify(errorDetails, null, 2));
}
throw new FreebitError(
`API Error: ${responseData.status?.message || "Unknown error"}`,
resultCode,
statusCode,
responseData.status?.message
);
}
this.logger.debug("Freebit API request successful", { url, resultCode });
return responseData;
} finally {
clearTimeout(timeoutId);
}
},
{
maxAttempts: config.retryAttempts,
baseDelayMs: 1000,
maxDelayMs: 10000,
isRetryable: error => {
if (error instanceof FreebitError) {
if (error.isAuthError() && attempt === 1) {
this.logger.warn("Freebit auth error detected, clearing cache and retrying", {
url,
});
this.authService.clearAuthCache();
return true;
}
return error.isRetryable();
}
return RetryableErrors.isTransientError(error);
},
logger: this.logger,
logContext: "Freebit API request",
}
);
// Track successful API call
this.trackApiCall(endpoint, payload, responseData, null).catch((error: unknown) => {
this.logger.debug("Failed to track API call", {
error: error instanceof Error ? error.message : String(error),
});
});
return responseData;
} catch (error: unknown) {
// Track failed API call
this.trackApiCall(endpoint, payload, null, error).catch((trackError: unknown) => {
this.logger.debug("Failed to track API call error", {
error: trackError instanceof Error ? trackError.message : String(trackError),
});
});
throw error;
}
}
/**
* Make an authenticated JSON request to Freebit API (for PA05-38, PA05-41, etc.)
*/
async makeAuthenticatedJsonRequest<
TResponse extends FreebitResponseBase,
TPayload extends object,
>(endpoint: string, payload: TPayload): Promise<TResponse> {
const config = this.authService.getConfig();
const authKey = await this.authService.getAuthKey();
const url = this.buildUrl(config.baseUrl, endpoint);
// Add authKey to the payload for authentication
const requestPayload = { ...payload, authKey };
let attempt = 0;
// Log request details in dev for debugging
const isProd = process.env["NODE_ENV"] === "production";
if (!isProd) {
this.logger.debug("[FREEBIT JSON API REQUEST]", {
url,
payload: redactForLogs(requestPayload),
});
}
try {
const responseData = await withRetry(
async () => {
attempt += 1;
this.logger.debug("Freebit JSON API request", {
url,
attempt,
maxAttempts: config.retryAttempts,
payload: redactForLogs(requestPayload),
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestPayload),
signal: controller.signal,
});
if (!response.ok) {
throw new FreebitError(
`HTTP ${response.status}: ${response.statusText}`,
response.status.toString()
);
}
const responseData = (await response.json()) as TResponse;
const resultCode = this.normalizeResultCode(responseData.resultCode);
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
if (resultCode && resultCode !== "100") {
const isProd = process.env["NODE_ENV"] === "production";
const errorDetails = {
url,
resultCode,
statusCode,
message: responseData.status?.message,
...(isProd ? {} : { response: redactForLogs(responseData as unknown) }),
attempt,
};
this.logger.error("Freebit JSON API returned error result code", errorDetails);
// Always log to console in dev for visibility
if (!isProd) {
console.error("[FREEBIT JSON API ERROR]", JSON.stringify(errorDetails, null, 2));
}
throw new FreebitError(
`API Error: ${responseData.status?.message || "Unknown error"}`,
resultCode,
statusCode,
responseData.status?.message
);
}
this.logger.debug("Freebit JSON API request successful", { url, resultCode });
return responseData;
} finally {
clearTimeout(timeoutId);
}
},
{
maxAttempts: config.retryAttempts,
baseDelayMs: 1000,
maxDelayMs: 10000,
isRetryable: error => {
if (error instanceof FreebitError) {
if (error.isAuthError() && attempt === 1) {
this.logger.warn("Freebit auth error detected, clearing cache and retrying", {
url,
});
this.authService.clearAuthCache();
return true;
}
return error.isRetryable();
}
return RetryableErrors.isTransientError(error);
},
logger: this.logger,
logContext: "Freebit JSON API request",
}
);
// Track successful API call
this.trackApiCall(endpoint, payload, responseData, null).catch((error: unknown) => {
this.logger.debug("Failed to track API call", {
error: error instanceof Error ? error.message : String(error),
});
});
return responseData;
} catch (error: unknown) {
// Track failed API call
this.trackApiCall(endpoint, payload, null, error).catch((trackError: unknown) => {
this.logger.debug("Failed to track API call error", {
error: trackError instanceof Error ? trackError.message : String(trackError),
});
});
throw error;
}
}
/**
* Make a simple request without authentication (for health checks)
*/
async makeSimpleRequest(endpoint: string): Promise<boolean> {
const config = this.authService.getConfig();
const url = this.buildUrl(config.baseUrl, endpoint);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.timeout);
const response = await fetch(url, {
method: "GET",
signal: controller.signal,
});
clearTimeout(timeout);
return response.ok;
} catch (error) {
this.logger.debug("Simple request failed", {
url,
error: extractErrorMessage(error),
});
return false;
}
}
/**
* Ensure proper URL construction - remove double slashes
*/
private buildUrl(baseUrl: string, endpoint: string): string {
const cleanBase = baseUrl.replace(/\/$/, "");
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
return `${cleanBase}${cleanEndpoint}`;
}
private async safeReadBodySnippet(response: Response): Promise<string | undefined> {
try {
const text = await response.text();
return text ? text.slice(0, 300) : undefined;
} catch {
return undefined;
}
}
private normalizeResultCode(code?: string | number | null): string | undefined {
if (code === undefined || code === null) {
return undefined;
}
const normalized = String(code).trim();
return normalized.length > 0 ? normalized : undefined;
}
/**
* Track API call for testing purposes
*/
private async trackApiCall(
endpoint: string,
payload: unknown,
response: FreebitResponseBase | null,
error: unknown
): Promise<void> {
const payloadObj = payload as Record<string, unknown>;
const phoneNumber = this.testTracker.extractPhoneNumber(
(payloadObj["account"] as string) || "",
payload
);
// Only track if we have a phone number (SIM-related calls)
if (!phoneNumber) {
return;
}
const timestamp = this.testTracker.getCurrentTimestamp();
const resultCode = response?.resultCode
? String(response.resultCode)
: (error instanceof FreebitError
? String(error.resultCode || "ERROR")
: "ERROR");
const statusMessage =
response?.status?.message ||
(error instanceof FreebitError
? error.message
: (error
? extractErrorMessage(error)
: "Success"));
await this.testTracker.logApiCall({
timestamp,
apiEndpoint: endpoint,
apiMethod: "POST",
phoneNumber,
simIdentifier: (payloadObj["account"] as string) || phoneNumber,
requestPayload: JSON.stringify(redactForLogs(payload)),
responseStatus: resultCode === "100" ? "Success" : `Error: ${resultCode}`,
...(error ? { error: extractErrorMessage(error) } : {}),
additionalInfo: statusMessage,
});
}
}