import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; import type { WhmcsResponse } from "@customer-portal/domain/common"; import type { WhmcsApiConfig, WhmcsRequestOptions, WhmcsConnectionStats, } from "../types/connection.types.js"; /** * 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( config: WhmcsApiConfig, action: string, params: Record, options: WhmcsRequestOptions = {} ): Promise> { const startTime = Date.now(); this.stats.totalRequests++; this.stats.lastRequestTime = new Date(); try { const response = await this.executeRequest(config, action, params, options); const responseTime = Date.now() - startTime; this.updateSuccessStats(responseTime); return response; } catch (error) { this.stats.failedRequests++; this.stats.lastErrorTime = new Date(); this.logger.error(`WHMCS HTTP request failed [${action}]`, { error: getErrorMessage(error), action, params: this.sanitizeLogParams(params), responseTime: Date.now() - startTime, }); 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, }; } /** * Execute the actual HTTP request with retry logic */ private async executeRequest( config: WhmcsApiConfig, action: string, params: Record, options: WhmcsRequestOptions ): Promise> { const maxAttempts = options.retryAttempts ?? config.retryAttempts ?? 3; let lastError: Error; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await this.performSingleRequest(config, action, params, options); } catch (error) { lastError = error as Error; if (attempt === maxAttempts) { break; } // Don't retry on certain error types if (this.shouldNotRetry(error)) { break; } const delay = this.calculateRetryDelay(attempt, config.retryDelay ?? 1000); this.logger.warn(`WHMCS request failed, retrying in ${delay}ms`, { action, attempt, maxAttempts, error: getErrorMessage(error), }); await this.sleep(delay); } } throw lastError!; } /** * Perform a single HTTP request */ private async performSingleRequest( config: WhmcsApiConfig, action: string, params: Record, options: WhmcsRequestOptions ): Promise> { const timeout = options.timeout ?? config.timeout ?? 30000; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const requestBody = this.buildRequestBody(config, action, params); 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) { // 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}`); } return this.parseResponse(responseText, action, params); } finally { clearTimeout(timeoutId); } } /** * Build request body for WHMCS API * Uses API key authentication (identifier + secret) */ private buildRequestBody( config: WhmcsApiConfig, action: string, params: Record ): string { const formData = new URLSearchParams(); // Add API key authentication formData.append("identifier", config.identifier); formData.append("secret", config.secret); // Add action and response format formData.append("action", action); formData.append("responsetype", "json"); // Add parameters for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null) { continue; } this.appendFormParam(formData, key, value); } return formData.toString(); } private appendFormParam(formData: URLSearchParams, key: string, value: unknown): void { if (Array.isArray(value)) { value.forEach((entry, index) => { this.appendFormParam(formData, `${key}[${index}]`, entry); }); return; } if (value && typeof value === "object" && !(value instanceof Date)) { // WHMCS does not accept nested objects; serialize to JSON for logging/debug. try { formData.append(key, JSON.stringify(value)); return; } catch { formData.append(key, Object.prototype.toString.call(value)); return; } } formData.append(key, this.serializeScalarParam(value)); } private serializeScalarParam(value: unknown): string { if (typeof value === "string") { return value; } if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { return String(value); } if (value instanceof Date) { return value.toISOString(); } 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); } /** * Parse WHMCS API response according to official documentation */ private parseResponse( responseText: string, action: string, params: Record ): WhmcsResponse { let parsedResponse: unknown; try { parsedResponse = JSON.parse(responseText); } catch (parseError) { this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, { responseText: responseText.substring(0, 500), parseError: getErrorMessage(parseError), params: this.sanitizeLogParams(params), }); throw new Error("Invalid JSON response from WHMCS API"); } // Validate basic response structure if (!this.isWhmcsResponse(parsedResponse)) { this.logger.error(`WHMCS API returned invalid response structure [${action}]`, { responseType: typeof parsedResponse, responseText: responseText.substring(0, 500), params: this.sanitizeLogParams(params), }); throw new Error("Invalid response structure from WHMCS API"); } // Handle error responses according to WHMCS API documentation // Return the error response instead of throwing - let the orchestrator handle it // This allows the error handler to properly classify errors (NotFoundException, etc.) if (parsedResponse.result === "error") { const { message, error, errorcode } = parsedResponse; const errorMessage = this.toDisplayString(message ?? error, "Unknown WHMCS API error"); const errorCode = this.toDisplayString(errorcode, "unknown"); // 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}]`, { errorMessage, errorCode, params: this.sanitizeLogParams(params), }); // Return error response for the orchestrator to handle with proper exception types return { result: "error" as const, message: errorMessage, errorcode: errorCode, } as WhmcsResponse; } // For successful responses, WHMCS API returns data directly at the root level // The response structure is: { "result": "success", ...actualData } // We return the parsed response directly as T since it contains the actual data const { result, message, ...rest } = parsedResponse; return { result, message: typeof message === "string" ? message : undefined, data: rest as T, } satisfies WhmcsResponse; } private isWhmcsResponse(value: unknown): value is { result: "success" | "error"; message?: unknown; error?: unknown; errorcode?: unknown; } & Record { if (!value || typeof value !== "object") { return false; } const record = value as Record; 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; } /** * Check if error should not be retried */ private shouldNotRetry(error: unknown): boolean { const message = getErrorMessage(error).toLowerCase(); // Don't retry authentication errors if (message.includes("authentication") || message.includes("unauthorized")) { return true; } // Don't retry validation errors if (message.includes("invalid") || message.includes("required")) { return true; } // Don't retry not found errors if (message.includes("not found")) { return true; } return false; } /** * Calculate retry delay with exponential backoff */ private calculateRetryDelay(attempt: number, baseDelay: number): number { const exponentialDelay = baseDelay * Math.pow(2, attempt - 1); const jitter = Math.random() * 0.1 * exponentialDelay; return Math.min(exponentialDelay + jitter, 10000); // Max 10 seconds } /** * Sleep for specified milliseconds */ private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Update success statistics */ private updateSuccessStats(responseTime: number): void { this.stats.successfulRequests++; // Update average response time const totalSuccessful = this.stats.successfulRequests; this.stats.averageResponseTime = (this.stats.averageResponseTime * (totalSuccessful - 1) + responseTime) / totalSuccessful; } /** * Sanitize parameters for logging (remove sensitive data) */ private sanitizeLogParams(params: Record): Record { const sensitiveKeys = [ "password", "secret", "token", "key", "auth", "credit_card", "cvv", "ssn", "social_security", ]; const sanitized: Record = {}; for (const [key, value] of Object.entries(params)) { const keyLower = key.toLowerCase(); const isSensitive = sensitiveKeys.some(sensitive => keyLower.includes(sensitive)); if (isSensitive) { sanitized[key] = "[REDACTED]"; } else { sanitized[key] = value; } } return sanitized; } }