- Deleted migration file that removed cached profile fields from the users table, centralizing profile data retrieval from WHMCS. - Updated CsrfMiddleware to include new public authentication endpoints for password reset, setting password, and WHMCS account linking. - Enhanced error handling in password and WHMCS linking workflows to provide clearer feedback on missing mappings and improve user experience. - Adjusted user creation and update methods in UsersFacade to handle cases where WHMCS mappings are not yet available, ensuring smoother account setup.
296 lines
9.6 KiB
TypeScript
296 lines
9.6 KiB
TypeScript
import { Injectable, Inject } from "@nestjs/common";
|
|
import { Logger } from "nestjs-pino";
|
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
|
import { FreebitAuthService } from "./freebit-auth.service";
|
|
import { FreebitError } from "./freebit-error.service";
|
|
|
|
interface FreebitResponseBase {
|
|
resultCode?: string | number;
|
|
status?: {
|
|
message?: string;
|
|
statusCode?: string | number;
|
|
};
|
|
}
|
|
|
|
@Injectable()
|
|
export class FreebitClientService {
|
|
constructor(
|
|
private readonly authService: FreebitAuthService,
|
|
@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 authKey = await this.authService.getAuthKey();
|
|
const config = this.authService.getConfig();
|
|
|
|
const requestPayload = { ...payload, authKey };
|
|
// Ensure proper URL construction - remove double slashes
|
|
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
|
const url = `${baseUrl}${cleanEndpoint}`;
|
|
|
|
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
|
try {
|
|
this.logger.debug(`Freebit API request (attempt ${attempt}/${config.retryAttempts})`, {
|
|
url,
|
|
payload: this.sanitizePayload(requestPayload),
|
|
});
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), config.timeout);
|
|
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: `json=${JSON.stringify(requestPayload)}`,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeout);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => "Unable to read response body");
|
|
this.logger.error(`Freebit API HTTP error`, {
|
|
url,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
responseBody: errorText,
|
|
attempt,
|
|
payload: this.sanitizePayload(requestPayload),
|
|
});
|
|
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") {
|
|
this.logger.warn("Freebit API returned error response", {
|
|
url,
|
|
resultCode,
|
|
statusCode,
|
|
statusMessage: responseData.status?.message,
|
|
fullResponse: responseData,
|
|
});
|
|
|
|
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;
|
|
} catch (error: unknown) {
|
|
if (error instanceof FreebitError) {
|
|
if (error.isAuthError() && attempt === 1) {
|
|
this.logger.warn("Auth error detected, clearing cache and retrying");
|
|
this.authService.clearAuthCache();
|
|
continue;
|
|
}
|
|
if (!error.isRetryable() || attempt === config.retryAttempts) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (attempt === config.retryAttempts) {
|
|
const message = getErrorMessage(error);
|
|
this.logger.error(`Freebit API request failed after ${config.retryAttempts} attempts`, {
|
|
url,
|
|
error: message,
|
|
});
|
|
throw new FreebitError(`Request failed: ${message}`);
|
|
}
|
|
|
|
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
|
|
this.logger.warn(`Freebit API request failed, retrying in ${delay}ms`, {
|
|
url,
|
|
attempt,
|
|
error: getErrorMessage(error),
|
|
});
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
|
|
throw new FreebitError("Request failed after all retry attempts");
|
|
}
|
|
|
|
/**
|
|
* Make an authenticated JSON request to Freebit API (for PA05-41)
|
|
*/
|
|
async makeAuthenticatedJsonRequest<
|
|
TResponse extends FreebitResponseBase,
|
|
TPayload extends object,
|
|
>(endpoint: string, payload: TPayload): Promise<TResponse> {
|
|
const config = this.authService.getConfig();
|
|
// Ensure proper URL construction - remove double slashes
|
|
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
|
const url = `${baseUrl}${cleanEndpoint}`;
|
|
|
|
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
|
try {
|
|
this.logger.debug(`Freebit JSON API request (attempt ${attempt}/${config.retryAttempts})`, {
|
|
url,
|
|
payload: this.sanitizePayload(payload as Record<string, unknown>),
|
|
});
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), config.timeout);
|
|
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeout);
|
|
|
|
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") {
|
|
this.logger.error(`Freebit API returned error result code`, {
|
|
url,
|
|
resultCode,
|
|
statusCode,
|
|
message: responseData.status?.message,
|
|
responseData: this.sanitizePayload(responseData as unknown as Record<string, unknown>),
|
|
attempt,
|
|
});
|
|
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;
|
|
} catch (error: unknown) {
|
|
if (error instanceof FreebitError) {
|
|
if (error.isAuthError() && attempt === 1) {
|
|
this.logger.warn("Auth error detected, clearing cache and retrying");
|
|
this.authService.clearAuthCache();
|
|
continue;
|
|
}
|
|
if (!error.isRetryable() || attempt === config.retryAttempts) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (attempt === config.retryAttempts) {
|
|
const message = getErrorMessage(error);
|
|
this.logger.error(
|
|
`Freebit JSON API request failed after ${config.retryAttempts} attempts`,
|
|
{
|
|
url,
|
|
error: message,
|
|
}
|
|
);
|
|
throw new FreebitError(`Request failed: ${message}`);
|
|
}
|
|
|
|
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
|
|
this.logger.warn(`Freebit JSON API request failed, retrying in ${delay}ms`, {
|
|
url,
|
|
attempt,
|
|
error: getErrorMessage(error),
|
|
});
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
|
|
throw new FreebitError("Request failed after all retry attempts");
|
|
}
|
|
|
|
/**
|
|
* Make a simple request without authentication (for health checks)
|
|
*/
|
|
async makeSimpleRequest(endpoint: string): Promise<boolean> {
|
|
const config = this.authService.getConfig();
|
|
// Ensure proper URL construction - remove double slashes
|
|
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
|
const url = `${baseUrl}${cleanEndpoint}`;
|
|
|
|
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: getErrorMessage(error),
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sanitize payload for logging (remove sensitive data)
|
|
*/
|
|
private sanitizePayload(payload: Record<string, unknown>): Record<string, unknown> {
|
|
const sanitized = { ...payload };
|
|
|
|
// Remove sensitive fields
|
|
const sensitiveFields = ["authKey", "oemKey", "password", "secret"];
|
|
for (const field of sensitiveFields) {
|
|
if (sanitized[field]) {
|
|
sanitized[field] = "[REDACTED]";
|
|
}
|
|
}
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|