import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { AuthToken, ApiResponse, QueryParams, UMBRELLA_ENDPOINTS } from './types.js';
import { AccountMapper, PlainSubUsersData } from './utils/account-mapper.js';
import { debugLog } from './utils/debug-logger.js';
export class UmbrellaApiClient {
private baseURL: string;
private frontendBaseURL: string;
private axiosInstance: AxiosInstance;
private _authToken: AuthToken | null = null;
private _auth: any = null; // Reference to UmbrellaDualAuth for cloud context switching
private _userData?: PlainSubUsersData; // Cached plain-sub-users data
constructor(baseURL: string, frontendBaseURL?: string) {
this.baseURL = baseURL;
this.frontendBaseURL = frontendBaseURL || baseURL;
this.axiosInstance = axios.create({
baseURL: this.baseURL,
timeout: 60000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
}
setAuthToken(token: AuthToken): void {
this._authToken = token;
}
setAuthHeaders(headers: AuthToken): void {
this._authToken = headers;
}
setAuth(auth: any): void {
this._auth = auth;
}
getAuthToken(): AuthToken | null {
return this._authToken;
}
setUserData(userData: PlainSubUsersData): void {
this._userData = userData;
console.error('[API-CLIENT] User data set - type:', userData?.user_type);
}
async ensureUserData(): Promise<PlainSubUsersData | undefined> {
// DEBUG: Log initial state before condition
console.error('[API-CLIENT] 🔍 ensureUserData() called');
console.error(`[API-CLIENT] _userData exists: ${!!this._userData}`);
console.error(`[API-CLIENT] _auth exists: ${!!this._auth}`);
console.error(`[API-CLIENT] _auth.getUserManagementInfo exists: ${!!this._auth?.getUserManagementInfo}`);
console.error(`[API-CLIENT] _auth.detectUserManagementSystem exists: ${!!this._auth?.detectUserManagementSystem}`);
if (!this._userData) {
console.error('[API-CLIENT] Fetching user data on first use (lazy loading)');
try {
// Check if user is on Keycloak UM 2.0
// If getUserManagementInfo returns null (OAuth user, detection not complete), wait for it
let umInfo = this._auth?.getUserManagementInfo?.();
console.error(`[API-CLIENT] umInfo from getUserManagementInfo: ${JSON.stringify(umInfo)}`);
console.error(`[API-CLIENT] Checking condition: !umInfo=${!umInfo}, detectUserManagementSystem exists=${!!this._auth?.detectUserManagementSystem}`);
if (!umInfo) {
// This should NOT happen if userManagementInfo was properly set from JWT token
console.error('[API-CLIENT] ⚠️ WARNING: userManagementInfo not set! This should not happen for OAuth users.');
console.error('[API-CLIENT] The JWT token should have contained userManagementInfo.');
console.error('[API-CLIENT] Defaulting to Cognito for safety (most common legacy auth method).');
// DO NOT call detectUserManagementSystem() - it's unreliable for users in migration state
umInfo = { isKeycloak: false, authMethod: 'cognito' };
}
const isKeycloak = umInfo?.isKeycloak || false;
console.error(`[API-CLIENT] Final isKeycloak: ${isKeycloak}`);
let userDataResponse;
if (isKeycloak) {
// For Keycloak UM 2.0 Direct customers: use /user-management/accounts
console.error('[API-CLIENT] Keycloak UM 2.0 detected - using /user-management/accounts');
userDataResponse = await this.makeDirectRequest('/user-management/accounts');
} else {
// For Cognito (Old UM): use /users/plain-sub-users
console.error('[API-CLIENT] Cognito detected - using /users/plain-sub-users');
userDataResponse = await this.makeDirectRequest('/users/plain-sub-users');
}
if (userDataResponse.success && userDataResponse.data) {
this._userData = userDataResponse.data;
console.error(`[API-CLIENT] Lazy loaded user data - auth mode: ${isKeycloak ? 'Keycloak UM 2.0' : 'Cognito'}`);
}
} catch (error: any) {
console.error(`[API-CLIENT] Failed to lazy load user data: ${error.message}`);
}
}
return this._userData;
}
get authToken(): AuthToken | null {
return this._authToken;
}
private async getAuthHeaders(cloudContext?: string, customerAccountKey?: string, apiPath?: string, customerDivisionId?: string, accountId?: string): Promise<Record<string, string>> {
if (!this._authToken) {
throw new Error('Authentication token not set. Please authenticate first.');
}
const headers: Record<string, string> = {
'Authorization': this._authToken.Authorization
};
// Priority: Customer account key > Cloud context key > Default key
let apiKey = null;
// 1. Customer-specific account key (highest priority)
if (customerAccountKey && this._auth && typeof this._auth.buildCustomerApiKey === 'function') {
try {
console.error(`[API-CLIENT] Building customer API key with divisionId: ${customerDivisionId} (type: ${typeof customerDivisionId})`);
apiKey = await this._auth.buildCustomerApiKey(customerAccountKey, apiPath, customerDivisionId);
if (apiKey) {
console.error(`[API-CLIENT] Using customer-specific API key for account ${customerAccountKey}: ${apiKey}`);
}
} catch (error: any) {
console.error(`[API-CLIENT] Failed to build customer API key: ${error.message}`);
}
}
// 2. Cloud-specific API key (if no customer key or customer key failed)
if (!apiKey && cloudContext && this._auth && typeof this._auth.buildCloudContextApiKey === 'function') {
apiKey = this._auth.buildCloudContextApiKey(cloudContext);
if (apiKey) {
console.error(`[API-CLIENT] Using ${cloudContext.toUpperCase()} cloud context API key: ${apiKey}`);
}
}
// 3. Dynamic API key based on accountId (for direct customers)
if (!apiKey && accountId && !customerAccountKey) {
// Ensure user data is loaded (lazy loading)
const userData = await this.ensureUserData();
if (userData) {
// For now, skip dynamic API key building as we need to implement it properly
// TODO: Implement dynamic API key building based on accountId
console.error(`[API-CLIENT] Dynamic API key building not implemented yet for accountId ${accountId}`);
}
}
// 4. Default API key (fallback) - need to get from auth instance
if (!apiKey && this._auth && typeof this._auth.getDefaultApiKey === 'function') {
apiKey = this._auth.getDefaultApiKey();
console.error(`[API-CLIENT] Using default API key: ${apiKey}`);
}
if (apiKey) {
headers['apikey'] = apiKey;
}
return headers;
}
async makeRequest(path: string, params?: QueryParams): Promise<ApiResponse> {
try {
// Special handling for MSP customers endpoint
if (path === '/msp/customers') {
console.error(`[API-CLIENT] MSP customers endpoint detected - using enhanced method`);
return await this.getMspCustomers();
}
// Special handling for v2 recommendations endpoint
if (path === '/v2/recommendations/list') {
console.error(`[API-CLIENT] V2 recommendations endpoint detected - using enhanced method`);
return await this.getRecommendations(params);
}
// Special handling for accounts/sub-users endpoint - route based on auth mode
if (path === '/v1/users/plain-sub-users') {
console.error('[API-CLIENT] 🔍 Routing plain-sub-users endpoint - checking auth mode');
// Check if user is on Keycloak UM 2.0
// If getUserManagementInfo returns null (OAuth user, detection not complete), wait for it
let umInfo = this._auth?.getUserManagementInfo?.();
console.error(`[API-CLIENT] Initial umInfo: ${JSON.stringify(umInfo)}`);
if (!umInfo) {
// This should NOT happen if userManagementInfo was properly set from JWT token
console.error('[API-CLIENT] ⚠️ WARNING: userManagementInfo not set! This should not happen for OAuth users.');
console.error('[API-CLIENT] The JWT token should have contained userManagementInfo.');
console.error('[API-CLIENT] Defaulting to Cognito for safety (most common legacy auth method).');
// DO NOT call detectUserManagementSystem() - it's unreliable for users in migration state
umInfo = { isKeycloak: false, authMethod: 'cognito' };
} else {
console.error('[API-CLIENT] UM info already available or detection method not found');
}
const isKeycloak = umInfo?.isKeycloak || false;
console.error(`[API-CLIENT] Final isKeycloak: ${isKeycloak}`);
if (isKeycloak) {
console.error(`[API-CLIENT] ✅ Keycloak UM 2.0 - routing /v1/users/plain-sub-users → /v1/user-management/accounts`);
return await this.makeDirectRequest('/v1/user-management/accounts', params);
} else {
console.error(`[API-CLIENT] ℹ️ Cognito Old UM - using /v1/users/plain-sub-users`);
}
}
return await this.makeDirectRequest(path, params);
} catch (error: any) {
// Error handling is done in makeDirectRequest
throw error;
}
}
async makeDirectRequest(path: string, params?: QueryParams): Promise<ApiResponse> {
try {
// Extract special _requestBody parameter for custom POST bodies
const customRequestBody = (params as any)?._requestBody;
// Extract cloud context, customer account key, and division ID from parameters
let cloudContext = params?.cloud_context;
const customerAccountKey = params?.customer_account_key;
const customerDivisionId = params?.customer_division_id;
const accountId = params?.accountId as string | undefined;
// Auto-detect cloud context if not provided
if (!cloudContext && accountId) {
const userData = await this.ensureUserData();
if (userData) {
const detectedContext = AccountMapper.getCloudContext(userData, accountId);
if (detectedContext) {
cloudContext = detectedContext;
console.error(`[API-CLIENT] Auto-detected cloud context for ${accountId}: ${cloudContext}`);
}
}
}
// Use backend API for ALL endpoints - simplified routing
const apiUrl = this.baseURL;
// Auto-detect if this is a GCP account
let isGcpAccount = cloudContext === 'gcp';
if (!cloudContext && accountId) {
const userData = await this.ensureUserData();
if (userData) {
const detectedContext = AccountMapper.getCloudContext(userData, accountId);
if (detectedContext === 'gcp') {
isGcpAccount = true;
console.error(`[API-CLIENT] Auto-detected GCP account for ${accountId}`);
}
}
}
// Extract isPpApplied for header (not URL parameter)
const isPpApplied = params?.isPpApplied;
// Convert params to handle arrays properly for axios
const axiosParams: Record<string, any> = {};
if (params) {
for (const [key, value] of Object.entries(params)) {
// Skip internal parameters that shouldn't be sent to API
if (key === 'customer_account_key' || key === 'customer_division_id' || key === 'userQuery' || key === 'cloud_context' || key === 'isPpApplied' || key === 'priceView') {
continue;
}
// GCP-specific exclusions: Skip isUnblended and excludeFilters for GCP accounts
if (isGcpAccount) {
if (key === 'isUnblended') {
console.error(`[API-CLIENT] Skipping isUnblended for GCP account`);
continue;
}
if (key === 'excludeFilters') {
console.error(`[API-CLIENT] Skipping excludeFilters for GCP account`);
continue;
}
}
if (Array.isArray(value)) {
// Axios handles arrays by repeating the parameter
axiosParams[key] = value;
} else if (value !== undefined && value !== null) {
axiosParams[key] = value;
}
}
}
// Don't add isPpApplied as URL parameter - it goes in commonparams header for MSP only
const isMspCheck = customerAccountKey && customerDivisionId && customerDivisionId !== '0';
console.error(`[API-CLIENT] Customer Check: accountKey=${customerAccountKey}, divisionId=${customerDivisionId}, isMSP=${isMspCheck}`);
if (isMspCheck) {
console.error(`[API-CLIENT] ✅ MSP customer detected - will add isPpApplied to commonparams header`);
} else if (customerAccountKey) {
console.error(`[API-CLIENT] ℹ️ Direct customer detected (divisionId=0) - NO commonparams header`);
}
// Determine HTTP method based on endpoint
const endpoint = UMBRELLA_ENDPOINTS.find(ep => ep.path === path);
const method = endpoint?.method?.toLowerCase() || 'get';
// Use already extracted accountId for dynamic API key generation
const authHeaders = await this.getAuthHeaders(cloudContext, customerAccountKey, path, customerDivisionId, accountId);
// Only add commonparams for MSP customers (divisionId > 0)
// Direct customers (divisionId 0) should NOT have this header
const isMspCustomer = customerAccountKey && customerDivisionId && customerDivisionId !== '0';
// Log isPpApplied header value
if (isPpApplied !== undefined) {
console.error(`[API-CLIENT] Adding commonparams header: { isPpApplied: ${isPpApplied} }`);
}
const config: AxiosRequestConfig = {
method: method as any,
headers: {
...authHeaders,
// Add commonparams header with isPpApplied value (defaults to false for customer prices)
// Only add header if isPpApplied is explicitly set (true or false)
...(isPpApplied !== undefined ? {
'commonparams': JSON.stringify({ isPpApplied: isPpApplied })
} : {})
},
};
// For POST requests, send parameters in body; for GET requests, use query params
if (method === 'post') {
// If custom request body is provided, use it directly
if (customRequestBody) {
config.data = customRequestBody;
console.error(`[API-CLIENT] Using custom request body for ${path}`);
} else {
config.data = axiosParams;
}
} else {
config.params = axiosParams;
}
// Build the full URL using the appropriate API
const fullUrl = `${apiUrl}${path}`;
// Check if we're using a dynamic API key (contains account info)
const currentApiKey = authHeaders['apikey'] || '';
const isDynamicApiKey = currentApiKey.includes(':') && accountId;
if (isDynamicApiKey) {
console.error(`[API-CLIENT] Dynamic API key detected - will skip accountId in URL`);
}
// Use the isGcpAccount value from earlier detection
// Properly handle array parameters and filter parameters
const searchParams = new URLSearchParams();
if (params) {
for (const [key, value] of Object.entries(params)) {
// Skip internal parameters that shouldn't be sent to API
if (key === 'customer_account_key' || key === 'cloud_context' || key === 'customer_division_id' || key === 'userQuery' || key === 'isPpApplied' || key === 'priceView') {
continue;
}
// Skip accountId when using dynamic API key (it's already in the API key)
if (key === 'accountId' && isDynamicApiKey) {
console.error(`[API-CLIENT] Skipping accountId in URL since it's in the API key`);
continue;
}
// GCP-specific exclusions: Skip isUnblended and excludeFilters for GCP accounts
if (isGcpAccount) {
if (key === 'isUnblended') {
console.error(`[API-CLIENT] Skipping isUnblended for GCP account`);
continue;
}
if (key === 'excludeFilters') {
console.error(`[API-CLIENT] Skipping excludeFilters for GCP account`);
continue;
}
}
// Handle excludeFilters object (e.g., excludeFilters[chargetype][]=Tax)
if (key === 'excludeFilters' && typeof value === 'object' && value !== null) {
for (const [filterKey, filterValue] of Object.entries(value)) {
if (Array.isArray(filterValue)) {
filterValue.forEach(item => {
searchParams.append(`excludeFilters[${filterKey}][]`, String(item));
});
} else {
searchParams.append(`excludeFilters[${filterKey}]`, String(filterValue));
}
}
}
// Handle filter parameters (e.g., filters[service]=AmazonCloudWatch)
else if (key.startsWith('filters[')) {
// Keep the filter format as-is for the API
searchParams.append(key, String(value));
} else if (Array.isArray(value)) {
// Add each array item as separate parameter
value.forEach(item => searchParams.append(key, String(item)));
} else if (value !== undefined && value !== null && typeof value !== 'object') {
searchParams.append(key, String(value));
}
}
// Note: isPpApplied is sent in commonparams header, not URL parameters
}
const queryString = searchParams.toString();
const finalUrl = queryString ? `${fullUrl}?${queryString}` : fullUrl;
console.error('\\n🔍 API REQUEST:');
console.error(` URL: ${finalUrl}`);
console.error(` Cloud Context: ${cloudContext || 'none'}`);
console.error(` Customer Account: ${customerAccountKey || 'none'}`);
console.error(` Division ID: ${customerDivisionId || 'none'}`);
console.error(` Customer Type: ${isMspCustomer ? 'MSP' : 'Direct'}`);
console.error(` Frontend API: false`);
console.error(` commonparams header: ${isMspCustomer ? JSON.stringify({ isPpApplied: true }) : 'not-set (direct customer)'}`);
console.error(` Headers:`, JSON.stringify(config.headers, null, 2));
// Log what axios will ACTUALLY use
const actualUrl = this.baseURL + path;
const actualParams = config.params || {};
const paramString = new URLSearchParams(actualParams).toString();
const fullActualUrl = paramString ? `${actualUrl}?${paramString}` : actualUrl;
console.error(` ACTUAL URL (axios will use): ${fullActualUrl}`);
console.error(` ACTUAL PARAMS:`, JSON.stringify(config.params || {}, null, 2));
// Add detailed RAW REQUEST logging to debug file
debugLog.logState('[API-CLIENT]', '[RAW REQUEST] ====================================');
debugLog.logState('[API-CLIENT]', `[RAW REQUEST] Full URL: ${finalUrl}`);
debugLog.logState('[API-CLIENT]', `[RAW REQUEST] Method: ${method.toUpperCase()}`);
debugLog.logState('[API-CLIENT]', '[RAW REQUEST] Headers:', config.headers);
debugLog.logState('[API-CLIENT]', '[RAW REQUEST] Query Parameters:', config.params || {});
debugLog.logState('[API-CLIENT]', `[RAW REQUEST] Timeout: ${config.timeout || 60000}ms`);
debugLog.logState('[API-CLIENT]', `[RAW REQUEST] Base URL: ${this.baseURL}`);
debugLog.logState('[API-CLIENT]', `[RAW REQUEST] Path: ${path}`);
if (config.data) {
debugLog.logState('[API-CLIENT]', '[RAW REQUEST] Body Data:', config.data);
}
debugLog.logState('[API-CLIENT]', '[RAW REQUEST] ====================================');
// Use axios instance for all requests (backend API only)
const response = await this.axiosInstance.request({...config, url: path});
// DEBUGGING: Log the response details
console.error('\\n✅ API RESPONSE:');
console.error(` Status: ${response.status}`);
console.error(` Headers: ${JSON.stringify(response.headers, null, 2)}`);
console.error(` Data: ${JSON.stringify(response.data, null, 2)}`);
console.error(' =====================================');
// Add detailed RAW RESPONSE logging to debug file
debugLog.logState('[API-CLIENT]', '[RAW RESPONSE] ====================================');
debugLog.logState('[API-CLIENT]', `[RAW RESPONSE] Status: ${response.status}`);
debugLog.logState('[API-CLIENT]', `[RAW RESPONSE] Status Text: ${response.statusText}`);
debugLog.logState('[API-CLIENT]', '[RAW RESPONSE] Headers:', response.headers);
debugLog.logState('[API-CLIENT]', '[RAW RESPONSE] Data:', response.data);
debugLog.logState('[API-CLIENT]', '[RAW RESPONSE] ====================================');
return {
data: response.data,
success: true,
message: 'Request completed successfully'
};
} catch (error: any) {
// DEBUGGING: Log the error details
console.error('\\n❌ API ERROR:');
if (error.response) {
console.error(` Status: ${error.response.status}`);
console.error(` Headers: ${JSON.stringify(error.response.headers, null, 2)}`);
console.error(` Data: ${JSON.stringify(error.response.data, null, 2)}`);
} else if (error.request) {
console.error(` Request Error: ${error.message}`);
console.error(` URL: ${error.config?.url}`);
} else {
console.error(` General Error: ${error.message}`);
}
console.error(' =====================================');
// Add detailed RAW ERROR logging to debug file
debugLog.logState('[API-CLIENT]', '[RAW ERROR] ====================================');
if (error.response) {
debugLog.logState('[API-CLIENT]', `[RAW ERROR] Status: ${error.response.status}`);
debugLog.logState('[API-CLIENT]', `[RAW ERROR] Status Text: ${error.response.statusText}`);
debugLog.logState('[API-CLIENT]', '[RAW ERROR] Headers:', error.response.headers);
debugLog.logState('[API-CLIENT]', '[RAW ERROR] Data:', error.response.data);
} else if (error.request) {
debugLog.logState('[API-CLIENT]', `[RAW ERROR] Request Error: ${error.message}`);
debugLog.logState('[API-CLIENT]', `[RAW ERROR] URL: ${error.config?.url}`);
debugLog.logState('[API-CLIENT]', '[RAW ERROR] Config:', error.config);
} else {
debugLog.logState('[API-CLIENT]', `[RAW ERROR] General Error: ${error.message}`);
debugLog.logState('[API-CLIENT]', '[RAW ERROR] Stack:', error.stack);
}
debugLog.logState('[API-CLIENT]', '[RAW ERROR] ====================================');
if (error.response) {
const status = error.response.status;
const message = error.response.data?.message || error.response.statusText || 'Request failed';
if (status === 401) {
return {
success: false,
error: 'Authentication failed. Please check your credentials and try again.',
message: message
};
} else if (status === 403) {
return {
success: false,
error: 'Access denied. You do not have permission to access this resource.',
message: message
};
} else if (status === 404) {
return {
success: false,
error: 'Resource not found. Please check the endpoint path.',
message: message
};
} else if (status === 429) {
return {
success: false,
error: 'Rate limit exceeded. Please wait and try again.',
message: message
};
} else {
return {
success: false,
error: `Server error (${status}): ${message}`,
message: message
};
}
} else if (error.request) {
return {
success: false,
error: 'Network error: Unable to connect to Umbrella Cost API.',
message: 'Please check your network connection and try again.'
};
} else {
return {
success: false,
error: `Request error: ${error.message}`,
message: error.message
};
}
}
}
// Helper methods for common endpoints
async getCostAndUsage(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/v2/invoices/cost-and-usage', params);
}
async getCosts(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/v2/invoices/cost-and-usage', params);
}
async getAccounts(): Promise<ApiResponse> {
// DISABLED: This method corrupts session state and breaks recommendations
// Returning empty response to prevent session corruption
console.error('[GETACCOUNTS] Disabled to prevent session corruption and preserve recommendations');
return { success: false, error: 'getAccounts disabled to prevent session corruption' };
}
async getPublicCostAndUsage(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/v1/invoices/cost-and-usage', params);
}
async getRDSInstanceCosts(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/v1/usage/rds/instance-costs', params);
}
async getS3BucketCosts(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/v1/usage/s3/bucket-costs', params);
}
async getResourceExplorerData(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/v1/usage/resource-explorer/resource', params);
}
async getDashboards(): Promise<ApiResponse> {
return this.makeRequest('/v1/usage/custom-dashboard/dashboards');
}
async getBudgets(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/v1/budgets', params);
}
async getRecommendations(params?: QueryParams & { useNetAmortized?: boolean, cloud_context?: string }): Promise<ApiResponse> {
console.error('[GETRECOMMENDATIONS] ⭐ METHOD CALLED - Starting v2 recommendations processing');
debugLog.logState('[GETRECOMMENDATIONS]', '[METHOD-ENTRY]', { params });
// Use the correct V2 recommendations API with POST method and commonParams header
// DEFAULT: Use last 12 months date range to match UI exactly
const today = new Date();
const last12Months = new Date(today);
last12Months.setFullYear(today.getFullYear() - 1);
const fromDate = last12Months.toISOString().split('T')[0];
const toDate = today.toISOString().split('T')[0];
// Parse optional filter parameters with defaults
const recommendationStatus = params?.recommendation_status || 'open';
const isOpen = recommendationStatus === 'open';
const isDone = recommendationStatus === 'done';
// For done/closed recommendations, use "actual_savings", for open use "potential_savings"
const statusFilter = isDone ? "actual_savings" : "potential_savings";
const categoryId = params?.categoryId;
const customerTags = params?.customerTags;
const costMode = params?.costMode?.toUpperCase() || 'UNBLENDED';
// Use provided dates or defaults
const creationFrom = params?.creationDateFrom || fromDate;
const creationTo = params?.creationDateTo || toDate;
const requestBody: any = {
filters: {
status_filter: statusFilter,
is_open: isOpen,
user_status: {
done: isDone,
excluded: false
},
open_recs_creation_date: {
from: creationFrom,
to: creationTo
},
closed_and_done_recs_dates: {
last_update_date: {
from: creationFrom,
to: creationTo
}
},
cost_mode: costMode
},
sort: [{
by: "savings",
order: "desc"
}],
page_size: 100,
pagination_token: null
};
// Add optional filters if provided
if (categoryId) {
requestBody.filters.cat_id = categoryId;
}
if (customerTags) {
requestBody.filters.customer_tags = customerTags.split(',').map((t: string) => t.trim());
}
try {
// Detect if this is an MSP customer (divisionId > 0) or direct customer (divisionId = 0)
const customerDivisionId = params?.customer_division_id || params?.divisionId;
const customerAccountKey = params?.customer_account_key || params?.accountKey;
const isMspCustomer = customerAccountKey && customerDivisionId && customerDivisionId !== '0';
console.error(`\n🔧 ========== V2 RECOMMENDATIONS SETUP ==========`);
console.error(` Account Key: ${customerAccountKey}`);
console.error(` Division ID: ${customerDivisionId}`);
console.error(` Customer Type: ${isMspCustomer ? 'MSP' : 'Direct'}`);
console.error(` Recommendation Status: ${recommendationStatus}`);
console.error(` Status Filter: ${statusFilter}`);
console.error(` Is Open: ${isOpen}`);
console.error(` Is Done: ${isDone}`);
console.error(` Date Range: ${creationFrom} to ${creationTo}`);
console.error(` Cost Mode: ${costMode}`);
console.error(` Category Filter: ${categoryId || 'none'}`);
console.error(` Customer Tags: ${customerTags || 'none'}`);
console.error(`================================================\n`);
// Fetch ALL recommendations through pagination from working V2 API
let allRecommendations: any[] = [];
let paginationToken: string | null = null;
let pageCount = 0;
do {
const pageRequestBody: any = {
...requestBody,
pagination_token: paginationToken
};
// Debug log the exact payload being sent
debugLog.logState('[V2-RECOMMENDATIONS]', '[REQUEST-PAYLOAD]', pageRequestBody);
const response: any = await axios.post(`${this.baseURL}/v2/recommendations/list`, pageRequestBody, {
headers: {
...(await this.getAuthHeaders(params?.cloud_context, params?.customer_account_key, '/v2/recommendations/list', params?.customer_division_id)),
// Only add commonParams for MSP customers (divisionId > 0), not direct customers (divisionId = 0)
...(isMspCustomer ? {
'commonParams': '{"isPpApplied":true}'
} : {}),
'Content-Type': 'application/json'
},
timeout: 30000
});
// V2 API returns { page: [...], paginationToken: "..." } structure
const pageData = response.data.page || [];
allRecommendations.push(...pageData);
// API uses camelCase "paginationToken", not snake_case
paginationToken = response.data.paginationToken;
pageCount++;
console.error(`\n📄 ========== PAGE ${pageCount} RESULTS ==========`);
console.error(` Items in this page: ${pageData.length}`);
console.error(` Running total: ${allRecommendations.length} recommendations`);
console.error(` Pagination token: ${paginationToken || 'NULL (no more pages)'}`);
console.error(` Response keys: ${Object.keys(response.data).join(', ')}`);
// Log the full response structure for debugging
debugLog.logState('[V2-RECOMMENDATIONS]', `[PAGE-${pageCount}-RESPONSE]`, {
pageItemCount: pageData.length,
totalSoFar: allRecommendations.length,
paginationToken: paginationToken || null,
responseKeys: Object.keys(response.data)
});
// Debug: Log if we got a pagination token but it's the same as before
if (paginationToken && pageCount > 1) {
console.error(` ✅ Has pagination token - will continue to next page`);
} else if (!paginationToken && pageData.length === 100) {
console.error(` ⚠️ WARNING: Got exactly 100 items but NO pagination token!`);
console.error(` ⚠️ This indicates the API stopped pagination prematurely`);
} else if (!paginationToken) {
console.error(` ✅ No pagination token - this is the last page`);
}
console.error(`================================================\n`);
// Safety limit to prevent infinite loops
if (pageCount > 20) {
console.error(`⚠️ Hit safety limit of 20 pages`);
break;
}
} while (paginationToken);
console.error(`📊 Total fetched: ${allRecommendations.length} recommendations across ${pageCount} pages`);
// Debug: Log structure of first recommendation to understand field names
if (allRecommendations.length > 0) {
console.error(`🔍 First recommendation structure:`, JSON.stringify(allRecommendations[0], null, 2));
}
const recommendations = allRecommendations;
// Filter recommendations based on cloud context (if specified)
let filteredRecommendations = recommendations;
if (params?.cloud_context) {
const targetCloud = params.cloud_context.toUpperCase();
filteredRecommendations = recommendations.filter((rec: any) => {
return rec.cloudProvider === targetCloud;
});
console.error(`🎯 ${targetCloud} Filtering: ${allRecommendations.length} total -> ${filteredRecommendations.length} ${targetCloud} recommendations`);
} else {
// No cloud filter specified - return ALL cloud recommendations
console.error(`🌐 No cloud filter: Returning all ${allRecommendations.length} recommendations`);
}
// DEFAULT: Use on-demand (unblended) costs unless client explicitly requests net amortized
const useNetAmortized = params?.useNetAmortized === true;
const savingsField = useNetAmortized ? 'netAmortized' : 'unblended';
const costField = useNetAmortized ? 'netAmortized' : 'unblended';
const totalSavings = filteredRecommendations.reduce((sum: number, rec: any) =>
sum + (rec.annualSavings?.[savingsField] || 0), 0);
// Calculate category breakdown from filtered recommendations
const categoryBreakdown: Record<string, {amount: number, count: number}> = {};
filteredRecommendations.forEach((rec: any) => {
const category = rec.category || 'Unknown';
if (!categoryBreakdown[category]) categoryBreakdown[category] = {amount: 0, count: 0};
categoryBreakdown[category].amount += rec.annualSavings?.[savingsField] || 0;
categoryBreakdown[category].count += 1;
});
const formattedRecommendations = filteredRecommendations.slice(0, 10).map((rec: any) => ({
id: rec.recId,
type: rec.typeName || rec.typeId,
category: rec.category,
service: rec.service,
resource_id: rec.resourceId,
resource_name: rec.resourceName || rec.resourceId,
region: rec.region,
// Include all cost types in the response
annual_savings: {
unblended: rec.annualSavings?.unblended || 0,
amortized: rec.annualSavings?.amortized || 0,
net_unblended: rec.annualSavings?.netUnblended || 0,
net_amortized: rec.annualSavings?.netAmortized || 0
},
monthly_savings: {
unblended: rec.monthlySavings?.unblended || 0,
amortized: rec.monthlySavings?.amortized || 0,
net_unblended: rec.monthlySavings?.netUnblended || 0,
net_amortized: rec.monthlySavings?.netAmortized || 0
},
annual_current_cost: {
unblended: rec.annualCurrentCost?.unblended || 0,
amortized: rec.annualCurrentCost?.amortized || 0,
net_unblended: rec.annualCurrentCost?.netUnblended || 0,
net_amortized: rec.annualCurrentCost?.netAmortized || 0
},
monthly_current_cost: {
unblended: rec.monthlyCurrentCost?.unblended || 0,
amortized: rec.monthlyCurrentCost?.amortized || 0,
net_unblended: rec.monthlyCurrentCost?.netUnblended || 0,
net_amortized: rec.monthlyCurrentCost?.netAmortized || 0
},
current_instance_type: rec.instanceType,
recommended_instance_type: rec.recData?.instance_type_recommended || rec.recData?.recommended_instance_type,
recommended_action: rec.recommendedAction,
description: rec.recData?.description || `${rec.typeName} recommendation for ${rec.service}`,
status: rec.open ? 'Open' : 'Closed',
age_days: rec.age,
cost_mode: costMode,
customer_tags: rec.customerTags || []
}));
return {
success: true,
data: formattedRecommendations,
total_count: filteredRecommendations.length,
total_potential_savings: totalSavings,
category_breakdown: categoryBreakdown,
message: `Found ${filteredRecommendations.length} cost optimization recommendations with $${totalSavings.toLocaleString()} total potential annual savings`
};
} catch (error: any) {
console.error('Recommendations API Error:', error.message);
debugLog.logState('[V2-RECOMMENDATIONS]', '[ERROR-DETAILS]', {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
url: error.config?.url
});
if (error.response?.status === 500) {
return {
success: false,
error: 'Recommendations service temporarily unavailable',
message: 'The recommendations feature may not be configured for this account'
};
}
throw error;
}
}
async getAnomalyDetection(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/v1/anomaly-detection', params);
}
async getCommitmentAnalysis(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/v1/commitment', params);
}
async getKubernetesCosts(params?: QueryParams): Promise<ApiResponse> {
return this.makeRequest('/v1/kubernetes', params);
}
async getUsers(): Promise<ApiResponse> {
return this.makeRequest('/v1/users');
}
async getCustomers(): Promise<ApiResponse> {
return this.makeRequest('/v1/user-management/customers');
}
async getMspCustomers(): Promise<ApiResponse> {
console.error(`[API-CLIENT] MSP Customer List: Using plain-sub-users API to get actual customer names`);
try {
// Use the correct API endpoint to get customerDivisions
const plainSubUsersResponse = await this.makeDirectRequest('/users/plain-sub-users');
if (plainSubUsersResponse.success && plainSubUsersResponse.data?.customerDivisions) {
const customerDivisions = plainSubUsersResponse.data.customerDivisions;
// Extract customer names from customerDivisions object keys
const customerNames = Object.keys(customerDivisions);
console.error(`[API-CLIENT] MSP Customer List: Found ${customerNames.length} actual customers from customerDivisions`);
// Convert to customer format
const customers = customerNames.map((customerName, index) => {
const divisionData = customerDivisions[customerName];
const firstDivision = Array.isArray(divisionData) && divisionData.length > 0 ? divisionData[0] : {};
return {
customerName: customerName,
customerDisplayName: customerName, // Add this field for server.ts
customerCode: `CUST-${index + 1}`,
customerId: index + 1,
divisionId: firstDivision.divisionId || null,
accountKey: firstDivision.accountKey || null,
accountName: firstDivision.accountName || null,
linkedAccountIds: [String(firstDivision.accountId || firstDivision.accountKey || '')],
customerNameId: customerName,
divisionData: divisionData // Include full division data for multi-account selection
};
});
return {
data: customers,
success: true,
message: `Found ${customers.length} MSP customers from customerDivisions API`
};
} else {
console.error(`[API-CLIENT] MSP Customer List: No customerDivisions in plain-sub-users response`);
// Fallback to auth-based approach
return await this.getMspCustomersFromAuth();
}
} catch (error: any) {
console.error(`[API-CLIENT] MSP Customer List: plain-sub-users API failed: ${error.message}`);
// Fallback to auth-based approach
try {
return await this.getMspCustomersFromAuth();
} catch (fallbackError: any) {
return {
success: false,
error: 'Unable to retrieve MSP customer list from both customerDivisions and auth data',
message: `Both methods failed: ${error.message}, ${fallbackError.message}`
};
}
}
}
private async getMspCustomersFromAuth(): Promise<ApiResponse> {
console.error(`[API-CLIENT] MSP Customer List: Using auth-based approach as fallback`);
if (this._auth && typeof this._auth.getAvailableAccounts === 'function') {
const availableAccounts = this._auth.getAvailableAccounts();
console.error(`[API-CLIENT] MSP Customer List: Found ${availableAccounts.length} available accounts from auth`);
if (availableAccounts.length > 5) {
// Convert ALL available accounts to customer format (less filtering for maximum results)
const customerAccounts = availableAccounts
.filter((acc: any) => {
const name = acc.accountName || acc.account_name || acc.name || '';
const accountKey = acc.accountKey || acc.account_key || acc.accountId || acc.account_id || '';
// Minimal filtering - only exclude truly invalid entries
return name.trim() !== '' && accountKey !== '' && accountKey !== 'undefined';
})
.map((acc: any, index: number) => ({
customerName: acc.accountName || acc.account_name || acc.name || `Account ${acc.accountKey}`,
customerCode: `MSP-${acc.accountKey || index}`,
customerId: index + 1,
linkedAccountIds: [String(acc.accountKey || acc.account_key || acc.accountId || acc.account_id)],
customerNameId: acc.accountName || acc.account_name || acc.name || `Account ${acc.accountKey}`,
accountKey: acc.accountKey || acc.account_key || acc.accountId || acc.account_id
}));
console.error(`[API-CLIENT] MSP Customer List: Converted ${availableAccounts.length} auth accounts to ${customerAccounts.length} customer records`);
return {
data: customerAccounts,
success: true,
message: `Found ${customerAccounts.length} MSP customers from authentication data (fallback method)`
};
}
}
return {
success: false,
error: 'Unable to retrieve MSP customer list - pagination and auth fallback both failed',
message: 'MSP customer data not available'
};
}
async getDistinctServiceNames(): Promise<ApiResponse> {
return this.makeRequest('/v1/invoices/service-names/distinct');
}
async getDistinctServiceCosts(): Promise<ApiResponse> {
return this.makeRequest('/v1/invoices/service-costs/distinct');
}
/**
* Get heatmap summary with BOTH potential and actual savings
* This makes TWO separate API calls and combines the results
*/
async getHeatmapSummary(params?: QueryParams): Promise<ApiResponse> {
console.error('[HEATMAP] Making dual API calls for potential + actual savings');
const today = new Date().toISOString().split('T')[0];
const yearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const baseFilters = {
"user_status": {
"excluded": false
},
"open_recs_creation_date": {
"from": yearAgo,
"to": today
},
"closed_and_done_recs_dates": {
"last_update_date": {
"from": yearAgo,
"to": today
}
},
"cost_mode": "UNBLENDED"
};
try {
// Call 1: Potential savings (open recommendations, not done)
const potentialResponse = await this.makeDirectRequest('/v1/recommendationsNew/heatmap/summary', {
...params,
_requestBody: {
filters: {
...baseFilters,
"status_filter": "potential_savings",
"is_open": true,
"user_status": {
"done": false,
"excluded": false
}
}
}
} as any);
// Call 2: Actual savings (same flags as UI: is_open: true, done: false)
const actualResponse = await this.makeDirectRequest('/v1/recommendationsNew/heatmap/summary', {
...params,
_requestBody: {
filters: {
...baseFilters,
"status_filter": "actual_savings",
"is_open": true,
"user_status": {
"done": false,
"excluded": false
}
}
}
} as any);
// Combine results
const combinedData = {
potentialAnnualSavings: potentialResponse.data?.potentialAnnualSavings || 0,
potentialSavingsRecommendationCount: potentialResponse.data?.potentialSavingsRecommendationCount || 0,
expectedSavingsRatePercent: potentialResponse.data?.expectedSavingsRatePercent || 0,
actualAnnualSavings: actualResponse.data?.actualAnnualSavings || 0,
actualSavingsRecommendationCount: actualResponse.data?.actualSavingsRecommendationCount || 0,
effectiveSavingsRatePercent: actualResponse.data?.effectiveSavingsRatePercent || 0,
totalSavings: (potentialResponse.data?.totalSavings || 0) + (actualResponse.data?.totalSavings || 0),
totalCount: (potentialResponse.data?.totalCount || 0) + (actualResponse.data?.totalCount || 0),
excludedRecommendationsSavings: (potentialResponse.data?.excludedRecommendationsSavings || 0) + (actualResponse.data?.excludedRecommendationsSavings || 0),
excludedRecommendationsCount: (potentialResponse.data?.excludedRecommendationsCount || 0) + (actualResponse.data?.excludedRecommendationsCount || 0),
// Include breakdowns from potential savings call (they're usually the same)
service: potentialResponse.data?.service,
type_id: potentialResponse.data?.type_id,
linked_account_id: potentialResponse.data?.linked_account_id,
cat_id: potentialResponse.data?.cat_id,
instance_type: potentialResponse.data?.instance_type
};
console.error('[HEATMAP] Combined results:');
console.error(` Potential: $${combinedData.potentialAnnualSavings} (${combinedData.potentialSavingsRecommendationCount} recs)`);
console.error(` Actual: $${combinedData.actualAnnualSavings} (${combinedData.actualSavingsRecommendationCount} recs)`);
return {
success: true,
data: combinedData
};
} catch (error: any) {
console.error('[HEATMAP] Error getting heatmap summary:', error.message);
return {
success: false,
error: error.message,
data: null
};
}
}
// Generic method to get available endpoints
getAvailableEndpoints() {
return UMBRELLA_ENDPOINTS;
}
}