2025-09-25 15:14:36 +09:00
|
|
|
import { Injectable, Inject } from "@nestjs/common";
|
|
|
|
|
import { Logger } from "nestjs-pino";
|
2025-12-29 15:07:11 +09:00
|
|
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
2025-12-29 17:17:36 +09:00
|
|
|
import { redactForLogs } from "@bff/core/logging/redaction.util.js";
|
2025-12-26 14:53:03 +09:00
|
|
|
import type { WhmcsResponse } from "@customer-portal/domain/common/providers";
|
2025-09-25 17:42:36 +09:00
|
|
|
import type {
|
|
|
|
|
WhmcsApiConfig,
|
2025-09-25 15:14:36 +09:00
|
|
|
WhmcsRequestOptions,
|
2025-09-25 17:42:36 +09:00
|
|
|
WhmcsConnectionStats,
|
2025-12-10 16:08:34 +09:00
|
|
|
} from "../types/connection.types.js";
|
2025-09-25 15:14:36 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Service for handling HTTP requests to WHMCS API
|
|
|
|
|
* Manages timeouts, retries, and response parsing
|
|
|
|
|
*/
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class WhmcsHttpClientService {
|
|
|
|
|
private stats: WhmcsConnectionStats = {
|
|
|
|
|
totalRequests: 0,
|
|
|
|
|
successfulRequests: 0,
|
|
|
|
|
failedRequests: 0,
|
|
|
|
|
averageResponseTime: 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
constructor(@Inject(Logger) private readonly logger: Logger) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Make HTTP request to WHMCS API
|
|
|
|
|
*/
|
|
|
|
|
async makeRequest<T>(
|
|
|
|
|
config: WhmcsApiConfig,
|
|
|
|
|
action: string,
|
|
|
|
|
params: Record<string, unknown>,
|
|
|
|
|
options: WhmcsRequestOptions = {}
|
2025-10-08 13:46:23 +09:00
|
|
|
): Promise<WhmcsResponse<T>> {
|
2025-09-25 15:14:36 +09:00
|
|
|
const startTime = Date.now();
|
|
|
|
|
this.stats.totalRequests++;
|
|
|
|
|
this.stats.lastRequestTime = new Date();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await this.executeRequest<T>(config, action, params, options);
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
const responseTime = Date.now() - startTime;
|
|
|
|
|
this.updateSuccessStats(responseTime);
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
return response;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.stats.failedRequests++;
|
|
|
|
|
this.stats.lastErrorTime = new Date();
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
this.logger.error(`WHMCS HTTP request failed [${action}]`, {
|
2025-12-29 15:07:11 +09:00
|
|
|
error: extractErrorMessage(error),
|
2025-09-25 15:14:36 +09:00
|
|
|
action,
|
2025-12-29 17:17:36 +09:00
|
|
|
params: redactForLogs(params),
|
2025-09-25 15:14:36 +09:00
|
|
|
responseTime: Date.now() - startTime,
|
|
|
|
|
});
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get connection statistics
|
|
|
|
|
*/
|
|
|
|
|
getStats(): WhmcsConnectionStats {
|
|
|
|
|
return { ...this.stats };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Reset connection statistics
|
|
|
|
|
*/
|
|
|
|
|
resetStats(): void {
|
|
|
|
|
this.stats = {
|
|
|
|
|
totalRequests: 0,
|
|
|
|
|
successfulRequests: 0,
|
|
|
|
|
failedRequests: 0,
|
|
|
|
|
averageResponseTime: 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-29 17:17:36 +09:00
|
|
|
* Execute the actual HTTP request.
|
|
|
|
|
*
|
|
|
|
|
* NOTE: Retries are handled at the queue/orchestrator level (`WhmcsRequestQueueService`)
|
|
|
|
|
* to avoid nested retries (retry storms) and to keep concurrency/rate-limit behavior
|
|
|
|
|
* centralized.
|
2025-09-25 15:14:36 +09:00
|
|
|
*/
|
|
|
|
|
private async executeRequest<T>(
|
|
|
|
|
config: WhmcsApiConfig,
|
|
|
|
|
action: string,
|
|
|
|
|
params: Record<string, unknown>,
|
|
|
|
|
options: WhmcsRequestOptions
|
2025-10-08 13:46:23 +09:00
|
|
|
): Promise<WhmcsResponse<T>> {
|
2025-12-29 17:17:36 +09:00
|
|
|
return this.performSingleRequest<T>(config, action, params, options);
|
2025-09-25 15:14:36 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Perform a single HTTP request
|
|
|
|
|
*/
|
|
|
|
|
private async performSingleRequest<T>(
|
|
|
|
|
config: WhmcsApiConfig,
|
|
|
|
|
action: string,
|
|
|
|
|
params: Record<string, unknown>,
|
|
|
|
|
options: WhmcsRequestOptions
|
2025-10-08 13:46:23 +09:00
|
|
|
): Promise<WhmcsResponse<T>> {
|
2025-09-25 15:14:36 +09:00
|
|
|
const timeout = options.timeout ?? config.timeout ?? 30000;
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
|
|
|
|
|
|
|
|
try {
|
2025-11-17 11:49:58 +09:00
|
|
|
const requestBody = this.buildRequestBody(config, action, params);
|
2025-09-25 15:14:36 +09:00
|
|
|
const url = `${config.baseUrl}/includes/api.php`;
|
|
|
|
|
|
|
|
|
|
const response = await fetch(url, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
|
|
|
"User-Agent": "CustomerPortal/1.0",
|
|
|
|
|
},
|
|
|
|
|
body: requestBody,
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
|
|
|
|
|
const responseText = await response.text();
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
2025-12-26 10:30:09 +09:00
|
|
|
// Do NOT include response body in thrown error messages (could contain sensitive/PII and
|
|
|
|
|
// would propagate into unified exception logs). If needed, emit a short snippet only in dev.
|
|
|
|
|
if (process.env.NODE_ENV !== "production") {
|
|
|
|
|
const snippet = responseText?.slice(0, 300);
|
|
|
|
|
if (snippet) {
|
|
|
|
|
this.logger.debug(`WHMCS non-OK response body snippet [${action}]`, {
|
|
|
|
|
action,
|
|
|
|
|
status: response.status,
|
|
|
|
|
statusText: response.statusText,
|
|
|
|
|
snippet,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
2025-09-25 15:14:36 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.parseResponse<T>(responseText, action, params);
|
|
|
|
|
} finally {
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build request body for WHMCS API
|
2025-11-05 18:17:59 +09:00
|
|
|
* Uses API key authentication (identifier + secret)
|
2025-09-25 15:14:36 +09:00
|
|
|
*/
|
|
|
|
|
private buildRequestBody(
|
|
|
|
|
config: WhmcsApiConfig,
|
|
|
|
|
action: string,
|
2025-11-17 11:49:58 +09:00
|
|
|
params: Record<string, unknown>
|
2025-09-25 15:14:36 +09:00
|
|
|
): string {
|
|
|
|
|
const formData = new URLSearchParams();
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-11-05 18:17:59 +09:00
|
|
|
// Add API key authentication
|
|
|
|
|
formData.append("identifier", config.identifier);
|
|
|
|
|
formData.append("secret", config.secret);
|
2025-09-25 15:14:36 +09:00
|
|
|
|
|
|
|
|
// Add action and response format
|
|
|
|
|
formData.append("action", action);
|
|
|
|
|
formData.append("responsetype", "json");
|
|
|
|
|
|
|
|
|
|
// Add parameters
|
|
|
|
|
for (const [key, value] of Object.entries(params)) {
|
2025-09-25 18:59:07 +09:00
|
|
|
if (value === undefined || value === null) {
|
|
|
|
|
continue;
|
2025-09-25 15:14:36 +09:00
|
|
|
}
|
2025-09-25 18:59:07 +09:00
|
|
|
|
2025-11-17 10:31:33 +09:00
|
|
|
this.appendFormParam(formData, key, value);
|
2025-09-25 15:14:36 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return formData.toString();
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-17 10:31:33 +09:00
|
|
|
private appendFormParam(formData: URLSearchParams, key: string, value: unknown): void {
|
2025-09-25 18:59:07 +09:00
|
|
|
if (Array.isArray(value)) {
|
2025-11-17 10:31:33 +09:00
|
|
|
value.forEach((entry, index) => {
|
|
|
|
|
this.appendFormParam(formData, `${key}[${index}]`, entry);
|
|
|
|
|
});
|
|
|
|
|
return;
|
2025-09-25 18:59:07 +09:00
|
|
|
}
|
|
|
|
|
|
2025-11-17 10:31:33 +09:00
|
|
|
if (value && typeof value === "object" && !(value instanceof Date)) {
|
|
|
|
|
// WHMCS does not accept nested objects; serialize to JSON for logging/debug.
|
2025-09-25 18:59:07 +09:00
|
|
|
try {
|
2025-11-17 10:31:33 +09:00
|
|
|
formData.append(key, JSON.stringify(value));
|
|
|
|
|
return;
|
2025-09-25 18:59:07 +09:00
|
|
|
} catch {
|
2025-11-17 10:31:33 +09:00
|
|
|
formData.append(key, Object.prototype.toString.call(value));
|
|
|
|
|
return;
|
2025-09-25 18:59:07 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-17 10:31:33 +09:00
|
|
|
formData.append(key, this.serializeScalarParam(value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private serializeScalarParam(value: unknown): string {
|
|
|
|
|
if (typeof value === "string") {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
2025-11-17 11:49:58 +09:00
|
|
|
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
2025-11-17 10:31:33 +09:00
|
|
|
return String(value);
|
|
|
|
|
}
|
|
|
|
|
if (value instanceof Date) {
|
|
|
|
|
return value.toISOString();
|
|
|
|
|
}
|
2025-09-25 18:59:07 +09:00
|
|
|
if (typeof value === "symbol") {
|
|
|
|
|
return value.description ? `Symbol(${value.description})` : "Symbol()";
|
|
|
|
|
}
|
|
|
|
|
if (typeof value === "function") {
|
|
|
|
|
return value.name ? `[Function ${value.name}]` : "[Function anonymous]";
|
|
|
|
|
}
|
|
|
|
|
return Object.prototype.toString.call(value);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
/**
|
2025-09-27 17:51:54 +09:00
|
|
|
* Parse WHMCS API response according to official documentation
|
2025-09-25 15:14:36 +09:00
|
|
|
*/
|
|
|
|
|
private parseResponse<T>(
|
|
|
|
|
responseText: string,
|
|
|
|
|
action: string,
|
|
|
|
|
params: Record<string, unknown>
|
2025-10-08 13:46:23 +09:00
|
|
|
): WhmcsResponse<T> {
|
2025-10-02 18:47:30 +09:00
|
|
|
let parsedResponse: unknown;
|
2025-09-25 15:14:36 +09:00
|
|
|
|
|
|
|
|
try {
|
2025-09-27 17:51:54 +09:00
|
|
|
parsedResponse = JSON.parse(responseText);
|
2025-09-25 15:14:36 +09:00
|
|
|
} catch (parseError) {
|
2025-12-29 17:17:36 +09:00
|
|
|
const isProd = process.env.NODE_ENV === "production";
|
2025-09-25 15:14:36 +09:00
|
|
|
this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, {
|
2025-12-29 17:17:36 +09:00
|
|
|
...(isProd
|
|
|
|
|
? { responseTextLength: responseText.length }
|
|
|
|
|
: { responseText: responseText.substring(0, 500) }),
|
2025-12-29 15:07:11 +09:00
|
|
|
parseError: extractErrorMessage(parseError),
|
2025-12-29 17:17:36 +09:00
|
|
|
params: redactForLogs(params),
|
2025-09-25 15:14:36 +09:00
|
|
|
});
|
|
|
|
|
throw new Error("Invalid JSON response from WHMCS API");
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-27 17:51:54 +09:00
|
|
|
// Validate basic response structure
|
2025-10-02 18:47:30 +09:00
|
|
|
if (!this.isWhmcsResponse(parsedResponse)) {
|
2025-12-29 17:17:36 +09:00
|
|
|
const isProd = process.env.NODE_ENV === "production";
|
2025-09-27 17:51:54 +09:00
|
|
|
this.logger.error(`WHMCS API returned invalid response structure [${action}]`, {
|
|
|
|
|
responseType: typeof parsedResponse,
|
2025-12-29 17:17:36 +09:00
|
|
|
...(isProd
|
|
|
|
|
? { responseTextLength: responseText.length }
|
|
|
|
|
: { responseText: responseText.substring(0, 500) }),
|
|
|
|
|
params: redactForLogs(params),
|
2025-09-27 17:51:54 +09:00
|
|
|
});
|
|
|
|
|
throw new Error("Invalid response structure from WHMCS API");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle error responses according to WHMCS API documentation
|
2025-12-02 18:56:38 +09:00
|
|
|
// Return the error response instead of throwing - let the orchestrator handle it
|
|
|
|
|
// This allows the error handler to properly classify errors (NotFoundException, etc.)
|
2025-09-27 17:51:54 +09:00
|
|
|
if (parsedResponse.result === "error") {
|
2025-12-02 18:56:38 +09:00
|
|
|
const { message, error, errorcode } = parsedResponse;
|
2025-11-17 11:49:58 +09:00
|
|
|
const errorMessage = this.toDisplayString(message ?? error, "Unknown WHMCS API error");
|
|
|
|
|
const errorCode = this.toDisplayString(errorcode, "unknown");
|
2025-11-05 18:17:59 +09:00
|
|
|
|
2025-12-15 13:32:42 +09:00
|
|
|
// Many WHMCS "result=error" responses are expected business outcomes (e.g. invalid credentials).
|
|
|
|
|
// Log as warning (not error) to avoid spamming error logs; the unified exception filter will
|
|
|
|
|
// still emit the request-level log based on the mapped error code.
|
|
|
|
|
this.logger.warn(`WHMCS API returned error [${action}]`, {
|
2025-09-27 17:51:54 +09:00
|
|
|
errorMessage,
|
|
|
|
|
errorCode,
|
2025-12-29 17:17:36 +09:00
|
|
|
params: redactForLogs(params),
|
2025-09-27 17:51:54 +09:00
|
|
|
});
|
2025-10-02 18:47:30 +09:00
|
|
|
|
2025-12-02 18:56:38 +09:00
|
|
|
// Return error response for the orchestrator to handle with proper exception types
|
|
|
|
|
return {
|
|
|
|
|
result: "error" as const,
|
|
|
|
|
message: errorMessage,
|
|
|
|
|
errorcode: errorCode,
|
|
|
|
|
} as WhmcsResponse<T>;
|
2025-09-25 15:14:36 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-27 17:51:54 +09:00
|
|
|
// For successful responses, WHMCS API returns data directly at the root level
|
|
|
|
|
// The response structure is: { "result": "success", ...actualData }
|
2025-09-27 18:28:35 +09:00
|
|
|
// We return the parsed response directly as T since it contains the actual data
|
2025-10-02 18:47:30 +09:00
|
|
|
const { result, message, ...rest } = parsedResponse;
|
2025-09-27 17:51:54 +09:00
|
|
|
return {
|
2025-10-02 18:47:30 +09:00
|
|
|
result,
|
|
|
|
|
message: typeof message === "string" ? message : undefined,
|
|
|
|
|
data: rest as T,
|
2025-10-08 13:46:23 +09:00
|
|
|
} satisfies WhmcsResponse<T>;
|
2025-10-02 18:47:30 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private isWhmcsResponse(value: unknown): value is {
|
|
|
|
|
result: "success" | "error";
|
|
|
|
|
message?: unknown;
|
|
|
|
|
error?: unknown;
|
|
|
|
|
errorcode?: unknown;
|
|
|
|
|
} & Record<string, unknown> {
|
|
|
|
|
if (!value || typeof value !== "object") {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const record = value as Record<string, unknown>;
|
|
|
|
|
const rawResult = record.result;
|
|
|
|
|
return rawResult === "success" || rawResult === "error";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private toDisplayString(value: unknown, fallback: string): string {
|
|
|
|
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
if (typeof value === "number" || typeof value === "boolean") {
|
|
|
|
|
return String(value);
|
|
|
|
|
}
|
|
|
|
|
if (value && typeof value === "object") {
|
|
|
|
|
try {
|
|
|
|
|
return JSON.stringify(value);
|
|
|
|
|
} catch {
|
|
|
|
|
return fallback;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return fallback;
|
2025-09-25 15:14:36 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update success statistics
|
|
|
|
|
*/
|
|
|
|
|
private updateSuccessStats(responseTime: number): void {
|
|
|
|
|
this.stats.successfulRequests++;
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
// Update average response time
|
|
|
|
|
const totalSuccessful = this.stats.successfulRequests;
|
2025-09-25 17:42:36 +09:00
|
|
|
this.stats.averageResponseTime =
|
2025-09-25 15:14:36 +09:00
|
|
|
(this.stats.averageResponseTime * (totalSuccessful - 1) + responseTime) / totalSuccessful;
|
|
|
|
|
}
|
|
|
|
|
}
|