// Centralized API configuration and functions // Auto-detect API URL based on current origin (supports both IP and subdomain access) import { useAuthStore } from '../store/authStore'; function getApiBaseUrl(): string { // First check environment variables const envUrl = import.meta.env.VITE_BACKEND_URL || import.meta.env.VITE_API_URL; if (envUrl) { // Ensure env URL ends with /api return envUrl.endsWith('/api') ? envUrl : `${envUrl}/api`; } // Auto-detect based on current origin const origin = window.location.origin; // If accessing via localhost or IP, use same origin with backend port if (origin.includes('localhost') || origin.includes('127.0.0.1') || /^\d+\.\d+\.\d+\.\d+/.test(origin)) { // Backend typically runs on port 8011 (external) or 8010 (internal) // If frontend is on port 3000, backend is on 8011 if (origin.includes(':3000')) { return origin.replace(':3000', ':8011') + '/api'; } // If frontend is on port 7921, backend is on 7911 if (origin.includes(':7921')) { return origin.replace(':7921', ':7911') + '/api'; } // Default: try port 8011 return origin.split(':')[0] + ':8011/api'; } // Production: use subdomain return 'https://api.igny8.com/api'; } export const API_BASE_URL = getApiBaseUrl(); // Helper function to get active site ID from store // Uses browser-compatible approach to avoid circular dependencies function getActiveSiteId(): number | null { try { // Access localStorage directly to get persisted site ID // This avoids circular dependency issues with importing the store const siteStorage = localStorage.getItem('site-storage'); if (siteStorage) { const parsed = JSON.parse(siteStorage); const activeSite = parsed?.state?.activeSite; if (activeSite && activeSite.id) { return activeSite.id; } } return null; } catch (error) { // If parsing fails or store not available, return null console.warn('Failed to get active site ID from storage:', error); return null; } } // Helper function to get active sector ID from store // Uses browser-compatible approach to avoid circular dependencies function getActiveSectorId(): number | null { try { // Access localStorage directly to get persisted sector ID // This avoids circular dependency issues with importing the store const sectorStorage = localStorage.getItem('sector-storage'); if (sectorStorage) { const parsed = JSON.parse(sectorStorage); const activeSector = parsed?.state?.activeSector; if (activeSector && activeSector.id) { return activeSector.id; } } return null; } catch (error) { // If parsing fails or store not available, return null console.warn('Failed to get active sector ID from storage:', error); return null; } } // Get auth token from store - try Zustand store first, then localStorage as fallback const getAuthToken = (): string | null => { try { // First try to get from Zustand store directly (faster, no parsing) const authState = useAuthStore.getState(); if (authState?.token) { return authState.token; } // Fallback to localStorage (for cases where store hasn't initialized yet) // CRITICAL: Use 'auth-storage' to match authStore persist config const authStorage = localStorage.getItem('auth-storage'); if (authStorage) { const parsed = JSON.parse(authStorage); return parsed?.state?.token || null; } } catch (e) { // Ignore parsing errors console.warn('Failed to get auth token:', e); } return null; }; /** * Extract user-friendly error message from API error * Removes technical prefixes like "Failed to save:", "Failed to load:", etc. * if the backend error message is already descriptive */ export function getUserFriendlyError(error: any, fallback: string = 'An error occurred. Please try again.'): string { const message = error?.message || error?.error || fallback; // If the message already describes a limit or specific problem, use it directly if (message.includes('limit exceeded') || message.includes('not found') || message.includes('already exists') || message.includes('invalid') || message.includes('required') || message.includes('permission') || message.includes('upgrade')) { return message; } // Otherwise return the message as-is return message; } // Get refresh token from store - try Zustand store first, then localStorage as fallback const getRefreshToken = (): string | null => { try { // First try to get from Zustand store directly (faster, no parsing) const authState = useAuthStore.getState(); if (authState?.refreshToken) { return authState.refreshToken; } // Fallback to localStorage (for cases where store hasn't initialized yet) // CRITICAL: Use 'auth-storage' to match authStore persist config const authStorage = localStorage.getItem('auth-storage'); if (authStorage) { const parsed = JSON.parse(authStorage); return parsed?.state?.refreshToken || null; } } catch (e) { // Ignore parsing errors console.warn('Failed to get refresh token:', e); } return null; }; // Generic API fetch function with timeout export async function fetchAPI(endpoint: string, options?: RequestInit & { timeout?: number }) { const timeout = options?.timeout || 30000; // Default 30 second timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const token = getAuthToken(); const headers: HeadersInit = { 'Content-Type': 'application/json', ...options?.headers, }; // Add JWT token if available if (token) { headers['Authorization'] = `Bearer ${token}`; } const response = await fetch(`${API_BASE_URL}${endpoint}`, { headers, credentials: 'include', signal: controller.signal, ...options, }); clearTimeout(timeoutId); // Check content type and length before reading body const contentType = response.headers.get('content-type') || ''; const contentLength = response.headers.get('content-length'); // Read response body once (can only be consumed once) const text = await response.text(); // Handle 403 Forbidden - check for authentication errors FIRST before throwing if (response.status === 403) { try { const errorData = text ? JSON.parse(text) : null; const errorMessage = errorData?.detail || errorData?.message || errorData?.error || response.statusText; // Check if it's an authentication credentials error (NOT permission/plan errors) if (errorMessage?.includes?.('Authentication credentials') || errorMessage?.includes?.('not authenticated')) { // CRITICAL: Only force logout if we're actually authenticated but token is missing/invalid // Don't logout for permission errors or plan issues const authState = useAuthStore.getState(); if (authState?.isAuthenticated || authState?.token) { const logoutReasonData = { code: 'AUTH_CREDENTIALS_MISSING', message: errorMessage, path: window.location.pathname, context: { errorData, hasToken: !!authState?.token, isAuthenticated: authState?.isAuthenticated }, timestamp: new Date().toISOString(), source: 'api_403_auth_error' }; console.error('🚨 LOGOUT TRIGGERED - Authentication Credentials Missing:', logoutReasonData); // Store logout reason before logout try { localStorage.setItem('logout_reason', JSON.stringify(logoutReasonData)); } catch (e) { console.warn('Failed to store logout reason:', e); } console.warn('Authentication credentials missing - forcing logout'); const { logout } = useAuthStore.getState(); logout(); // Redirect to login page if (typeof window !== 'undefined') { window.location.href = '/signin'; } } // Throw authentication error let err: any = new Error(errorMessage); err.status = 403; err.data = errorData; throw err; } // Not an auth error - could be permissions/plan issue - don't force logout let err: any = new Error(errorMessage); err.status = 403; err.data = errorData; throw err; } catch (e: any) { // If it's the error we just threw, re-throw it if (e.status === 403) throw e; // Parsing failed - throw generic 403 error let err: any = new Error(text || response.statusText); err.status = 403; throw err; } } // Handle 402 Payment Required - plan/limits issue if (response.status === 402) { let err: any = new Error(response.statusText); err.status = response.status; try { const parsed = text ? JSON.parse(text) : null; err.message = parsed?.error || parsed?.message || response.statusText; err.data = parsed; } catch (_) { err.message = text || response.statusText; } throw err; } // Handle 401 Unauthorized - try to refresh token if (response.status === 401) { // Parse error to check for logout reason from backend let logoutReason = null; try { const errorData = text ? JSON.parse(text) : null; if (errorData?.logout_reason) { logoutReason = { code: errorData.logout_reason, message: errorData.logout_message || errorData.error, path: errorData.logout_path || window.location.pathname, context: errorData.logout_context || {}, timestamp: new Date().toISOString(), source: 'backend_middleware' }; console.error('🚨 BACKEND FORCED LOGOUT:', logoutReason); // CRITICAL: Store logout reason IMMEDIATELY try { localStorage.setItem('logout_reason', JSON.stringify(logoutReason)); console.error('✅ Stored backend logout reason'); } catch (e) { console.error('❌ Failed to store logout reason:', e); } // If backend explicitly logged us out (session contamination, etc), // DON'T try to refresh - respect the forced logout console.error('⛔ Backend forced logout - not attempting token refresh'); const { logout } = useAuthStore.getState(); logout(); // Throw error to stop request processing let err: any = new Error(errorData.error || 'Session ended'); err.status = 401; err.data = errorData; throw err; } } catch (e) { // If we just threw the error above, re-throw it if (e instanceof Error && (e as any).status === 401) { throw e; } console.warn('Failed to parse logout reason from 401 response:', e); } // No explicit logout reason from backend, try token refresh const refreshToken = getRefreshToken(); if (refreshToken) { try { // Try to refresh the token const refreshResponse = await fetch(`${API_BASE_URL}/v1/auth/refresh/`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ refresh: refreshToken }), credentials: 'include', }); if (refreshResponse.ok) { const refreshData = await refreshResponse.json(); const accessToken = refreshData.data?.access || refreshData.access; if (refreshData.success && accessToken) { // Update token in Zustand store AND localStorage try { // Update Zustand store directly const { setToken } = useAuthStore.getState(); setToken(accessToken); // Also update localStorage for immediate availability const authStorage = localStorage.getItem('auth-storage'); if (authStorage) { const parsed = JSON.parse(authStorage); parsed.state.token = accessToken; localStorage.setItem('auth-storage', JSON.stringify(parsed)); } } catch (e) { console.warn('Failed to update token after refresh:', e); } // Retry original request with new token const newHeaders = { ...headers, 'Authorization': `Bearer ${accessToken}`, }; const retryResponse = await fetch(`${API_BASE_URL}${endpoint}`, { headers: newHeaders, credentials: 'include', ...options, }); // Process retry response const retryText = await retryResponse.text(); if (retryResponse.ok) { if (retryText && retryText.trim()) { try { return JSON.parse(retryText); } catch { return retryText; } } return null; } else { // Retry failed - parse and throw the retry error (not the original 401) let retryError: any = new Error(retryResponse.statusText); retryError.status = retryResponse.status; try { const retryErrorData = JSON.parse(retryText); retryError.message = retryErrorData.error || retryErrorData.message || retryResponse.statusText; retryError.data = retryErrorData; } catch (e) { retryError.message = retryText.substring(0, 200) || retryResponse.statusText; } throw retryError; } } } } catch (refreshError) { // Refresh failed, clear auth state and force re-login const logoutReasonData = { code: 'TOKEN_REFRESH_FAILED', message: 'Token refresh failed - session expired', path: window.location.pathname, context: { error: refreshError instanceof Error ? refreshError.message : String(refreshError), endpoint, }, timestamp: new Date().toISOString(), source: 'token_refresh_failure' }; console.error('🚨 LOGOUT TRIGGERED - Token Refresh Failed:', logoutReasonData); // Store logout reason before logout try { localStorage.setItem('logout_reason', JSON.stringify(logoutReasonData)); } catch (e) { console.warn('Failed to store logout reason:', e); } const { logout } = useAuthStore.getState(); logout(); throw refreshError; } } else { // No refresh token available, clear auth state const logoutReasonData = { code: 'NO_REFRESH_TOKEN', message: 'No refresh token available - please login again', path: window.location.pathname, context: { endpoint }, timestamp: new Date().toISOString(), source: 'missing_refresh_token' }; console.error('🚨 LOGOUT TRIGGERED - No Refresh Token:', logoutReasonData); // Store logout reason before logout try { localStorage.setItem('logout_reason', JSON.stringify(logoutReasonData)); } catch (e) { console.warn('Failed to store logout reason:', e); } const { logout } = useAuthStore.getState(); logout(); } } // Parse error response - extract meaningful error information if (!response.ok) { let errorMessage = response.statusText; let errorType = 'HTTP_ERROR'; let errorData = null; try { if (contentType.includes('application/json')) { try { errorData = JSON.parse(text); // Handle unified error format: {success: false, error: "...", errors: {...}} if (errorData.success === false) { // Extract error message from unified format errorMessage = errorData.error || errorData.message || errorData.detail || errorMessage; // Keep errorData for structured error handling } else { // Old format or other error structure errorMessage = errorData.error || errorData.message || errorData.detail || errorMessage; } // Classify error type if (errorData.error?.includes('OperationalError')) errorType = 'DATABASE_ERROR'; else if (errorData.error?.includes('ValidationError')) errorType = 'VALIDATION_ERROR'; else if (errorData.error?.includes('PermissionDenied')) errorType = 'PERMISSION_ERROR'; else if (errorData.error?.includes('NotFound')) errorType = 'NOT_FOUND_ERROR'; else if (errorData.error?.includes('IntegrityError')) errorType = 'DATABASE_ERROR'; else if (errorData.error?.includes('RelatedObjectDoesNotExist')) errorType = 'RELATED_OBJECT_ERROR'; } catch (e) { // JSON parse failed, use text errorMessage = text.substring(0, 200); } } else { // HTML or text response (Django debug page) if (text.includes('')) { // Extract error title from HTML const titleMatch = text.match(/([^<]+) at ([^<]+)<\/title>/); if (titleMatch) { errorType = titleMatch[1].trim(); // e.g., "OperationalError" errorMessage = `${errorType} at ${titleMatch[2].trim()}`; } else { // Fallback: try to extract from h1 const h1Match = text.match(/<h1[^>]*>([^<]+)<\/h1>/); if (h1Match) { errorMessage = h1Match[1].trim(); errorType = errorMessage.split(' ')[0]; // First word is usually error type } else { errorMessage = `HTTP ${response.status} Error`; } } } else { // Plain text error errorMessage = text.substring(0, 200); // Limit length } } } catch (e) { // If parsing fails, use status text errorMessage = response.statusText; } // Log structured error (not full HTML) console.error('API Error:', { status: response.status, type: errorType, message: errorMessage, endpoint, errorData, // Include full error data for debugging }); // Attach error data to error object so it can be accessed in catch block // Use clean user-friendly message without technical jargon const apiError = new Error(errorMessage); (apiError as any).response = errorData; (apiError as any).status = response.status; (apiError as any).errorType = errorType; throw apiError; } // Check if response has content before parsing JSON // DELETE requests often return 204 No Content with empty body if ( response.status === 204 || // No Content contentLength === '0' || !text || text.trim() === '' || (contentType && !contentType.includes('application/json')) ) { // Return void for empty responses return; } // Parse JSON response let parsedResponse; try { parsedResponse = JSON.parse(text); } catch (e) { // If JSON parsing fails, return text return text; } // Handle unified API response format // Paginated responses: {success: true, count: X, results: [...], next: ..., previous: ...} // Single object/list responses: {success: true, data: {...}} // Error responses: {success: false, error: "...", errors: {...}} // If it's a unified format response with success field if (parsedResponse && typeof parsedResponse === 'object' && 'success' in parsedResponse) { // For paginated responses, return as-is (results is at top level) if ('results' in parsedResponse && 'count' in parsedResponse) { return parsedResponse; } // For single object/list responses, extract data field if ('data' in parsedResponse) { return parsedResponse.data; } // Error responses should have been thrown already in !response.ok block above // If we somehow get here with an error response (shouldn't happen), throw it if (parsedResponse.success === false) { const errorMsg = parsedResponse.error || parsedResponse.message || 'Request failed'; const apiError = new Error(`API Error: ${errorMsg}`); (apiError as any).response = parsedResponse; (apiError as any).status = 400; throw apiError; } // If success is true but no data/results, return the whole response return parsedResponse; } // Not a unified format response, return as-is (backward compatibility) return parsedResponse; } catch (error: any) { clearTimeout(timeoutId); if (error.name === 'AbortError') { throw new Error(`Request timeout after ${timeout}ms: ${API_BASE_URL}${endpoint}`); } if (error instanceof TypeError && error.message.includes('Failed to fetch')) { throw new Error(`Network Error: Unable to reach API at ${API_BASE_URL}${endpoint}. Check CORS and network connectivity.`); } throw error; } } // Keywords-specific API functions export interface KeywordFilters { search?: string; status?: string; cluster_id?: string; country?: string; difficulty_min?: number; difficulty_max?: number; volume_min?: number; volume_max?: number; page?: number; page_size?: number; ordering?: string; site_id?: number; // Site filter - automatically added from siteStore sector_id?: number; // Sector filter - for second-level filtering } export interface KeywordsResponse { count: number; next: string | null; previous: string | null; results: Keyword[]; } export interface Keyword { id: number; seed_keyword_id: number; seed_keyword?: SeedKeyword; // Populated by serializer keyword: string; // Read-only property from seed_keyword volume: number; // Read-only property from seed_keyword or volume_override difficulty: number; // Read-only property from seed_keyword or difficulty_override country: string; // Read-only property from seed_keyword volume_override?: number | null; difficulty_override?: number | null; cluster_id: number | null; cluster_name?: string | null; // Optional: populated by serializer or frontend sector_name?: string | null; // Optional: populated by serializer status: string; created_at: string; updated_at: string; } export interface KeywordCreateData { keyword?: string; // For creating new custom keywords volume?: number | null; // For custom keywords difficulty?: number | null; // For custom keywords country?: string; // For custom keywords seed_keyword_id?: number; // For linking existing seed keywords (optional) volume_override?: number | null; difficulty_override?: number | null; cluster_id?: number | null; status: string; } export interface KeywordUpdateData extends Partial<KeywordCreateData> {} export async function fetchKeywords(filters: KeywordFilters = {}): Promise<KeywordsResponse> { const params = new URLSearchParams(); // Automatically add active site filter if not explicitly provided if (!filters.site_id) { const activeSiteId = getActiveSiteId(); if (activeSiteId) { filters.site_id = activeSiteId; } } // Automatically add active sector filter if not explicitly provided if (filters.sector_id === undefined) { const activeSectorId = getActiveSectorId(); if (activeSectorId !== null && activeSectorId !== undefined) { filters.sector_id = activeSectorId; } } if (filters.search) params.append('search', filters.search); if (filters.status) params.append('status', filters.status); if (filters.cluster_id) params.append('cluster_id', filters.cluster_id); if (filters.country) params.append('seed_keyword__country', filters.country); if (filters.difficulty_min !== undefined) params.append('difficulty_min', filters.difficulty_min.toString()); if (filters.difficulty_max !== undefined) params.append('difficulty_max', filters.difficulty_max.toString()); if (filters.volume_min !== undefined) params.append('volume_min', filters.volume_min.toString()); if (filters.volume_max !== undefined) params.append('volume_max', filters.volume_max.toString()); if (filters.site_id) params.append('site_id', filters.site_id.toString()); if (filters.sector_id) params.append('sector_id', filters.sector_id.toString()); if (filters.page) params.append('page', filters.page.toString()); if (filters.page_size !== undefined && filters.page_size !== null) { params.append('page_size', filters.page_size.toString()); } if (filters.ordering) params.append('ordering', filters.ordering); const queryString = params.toString(); const endpoint = `/v1/planner/keywords/${queryString ? `?${queryString}` : ''}`; return fetchAPI(endpoint); } export async function fetchKeyword(id: number): Promise<Keyword> { return fetchAPI(`/v1/planner/keywords/${id}/`); } export async function createKeyword(data: KeywordCreateData): Promise<Keyword> { // Transform frontend field names to backend field names const requestData: any = { ...data, }; // If creating a custom keyword, map to backend field names if (data.keyword) { requestData.custom_keyword = data.keyword; requestData.custom_volume = data.volume; requestData.custom_difficulty = data.difficulty; requestData.custom_country = data.country || 'US'; // Remove the frontend-only fields delete requestData.keyword; delete requestData.volume; delete requestData.difficulty; delete requestData.country; } return fetchAPI('/v1/planner/keywords/', { method: 'POST', body: JSON.stringify(requestData), }); } export async function updateKeyword(id: number, data: KeywordUpdateData): Promise<Keyword> { return fetchAPI(`/v1/planner/keywords/${id}/`, { method: 'PUT', body: JSON.stringify(data), }); } export async function deleteKeyword(id: number): Promise<void> { return fetchAPI(`/v1/planner/keywords/${id}/`, { method: 'DELETE', }); } export async function bulkDeleteKeywords(ids: number[]): Promise<{ deleted_count: number }> { return fetchAPI(`/v1/planner/keywords/bulk_delete/`, { method: 'POST', body: JSON.stringify({ ids }), }); } export async function bulkUpdateKeywordsStatus(ids: number[], status: string): Promise<{ updated_count: number }> { return fetchAPI(`/v1/planner/keywords/bulk_update/`, { method: 'POST', body: JSON.stringify({ ids, status }), }); } // Clusters-specific API functions export interface ClusterFilters { search?: string; status?: string; difficulty_min?: number; difficulty_max?: number; volume_min?: number; volume_max?: number; page?: number; page_size?: number; ordering?: string; site_id?: number; // Site filter - automatically added from siteStore sector_id?: number; // Sector filter - for second-level filtering } export interface ClustersResponse { count: number; next: string | null; previous: string | null; results: Cluster[]; } export interface Cluster { id: number; name: string; description?: string | null; keywords_count: number; volume: number; difficulty: number; // Average difficulty of keywords in cluster mapped_pages: number; ideas_count: number; content_count: number; status: string; sector_name?: string | null; // Optional: populated by serializer created_at: string; } export interface ClusterCreateData { name: string; description?: string | null; status?: string; } export interface ClusterUpdateData extends Partial<ClusterCreateData> {} export async function fetchClusters(filters: ClusterFilters = {}): Promise<ClustersResponse> { const params = new URLSearchParams(); // Automatically add active site filter if not explicitly provided if (!filters.site_id) { const activeSiteId = getActiveSiteId(); if (activeSiteId) { filters.site_id = activeSiteId; } } // Automatically add active sector filter if not explicitly provided if (filters.sector_id === undefined) { const activeSectorId = getActiveSectorId(); if (activeSectorId !== null && activeSectorId !== undefined) { filters.sector_id = activeSectorId; } } if (filters.search) params.append('search', filters.search); if (filters.status) params.append('status', filters.status); if (filters.difficulty_min !== undefined) params.append('difficulty_min', filters.difficulty_min.toString()); if (filters.difficulty_max !== undefined) params.append('difficulty_max', filters.difficulty_max.toString()); if (filters.volume_min !== undefined) params.append('volume_min', filters.volume_min.toString()); if (filters.volume_max !== undefined) params.append('volume_max', filters.volume_max.toString()); if (filters.site_id) params.append('site_id', filters.site_id.toString()); if (filters.sector_id) params.append('sector_id', filters.sector_id.toString()); if (filters.page) params.append('page', filters.page.toString()); if (filters.page_size !== undefined && filters.page_size !== null) { params.append('page_size', filters.page_size.toString()); } if (filters.ordering) params.append('ordering', filters.ordering); const queryString = params.toString(); const endpoint = `/v1/planner/clusters/${queryString ? `?${queryString}` : ''}`; return fetchAPI(endpoint); } export async function fetchCluster(id: number): Promise<Cluster> { return fetchAPI(`/v1/planner/clusters/${id}/`); } export async function createCluster(data: ClusterCreateData): Promise<Cluster> { return fetchAPI('/v1/planner/clusters/', { method: 'POST', body: JSON.stringify(data), }); } export async function updateCluster(id: number, data: ClusterUpdateData): Promise<Cluster> { return fetchAPI(`/v1/planner/clusters/${id}/`, { method: 'PUT', body: JSON.stringify(data), }); } export async function deleteCluster(id: number): Promise<void> { return fetchAPI(`/v1/planner/clusters/${id}/`, { method: 'DELETE', }); } export async function bulkDeleteClusters(ids: number[]): Promise<{ deleted_count: number }> { return fetchAPI(`/v1/planner/clusters/bulk_delete/`, { method: 'POST', body: JSON.stringify({ ids }), }); } export async function bulkUpdateClustersStatus(ids: number[], status: string): Promise<{ updated_count: number }> { return fetchAPI(`/v1/planner/clusters/bulk_update/`, { method: 'POST', body: JSON.stringify({ ids, status }), }); } export async function autoClusterKeywords(keywordIds: number[], sectorId?: number): Promise<{ success: boolean; task_id?: string; clusters_created?: number; keywords_updated?: number; message?: string; error?: string }> { const endpoint = `/v1/planner/keywords/auto_cluster/`; const requestBody = { ids: keywordIds, sector_id: sectorId }; try { // fetchAPI extracts data from unified format {success: true, data: {...}} // So response is already the data object: {task_id: "...", ...} const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); // Wrap extracted data with success: true for frontend compatibility if (response && typeof response === 'object') { return { success: true, ...response } as any; } return { success: true, ...response } as any; } catch (error: any) { // Error responses are thrown by fetchAPI, but wrap them for consistency if (error.response && typeof error.response === 'object') { return { success: false, error: error.message, ...error.response } as any; } throw error; } } export async function autoGenerateIdeas(clusterIds: number[]): Promise<{ success: boolean; task_id?: string; ideas_created?: number; message?: string; error?: string }> { const endpoint = `/v1/planner/clusters/auto_generate_ideas/`; const requestBody = { ids: clusterIds }; try { // fetchAPI extracts data from unified format {success: true, data: {...}} // So response is already the data object: {task_id: "...", ...} const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); // Wrap extracted data with success: true for frontend compatibility if (response && typeof response === 'object') { return { success: true, ...response } as any; } return { success: true, ...response } as any; } catch (error: any) { // Error responses are thrown by fetchAPI, but wrap them for consistency if (error.response && typeof error.response === 'object') { return { success: false, error: error.message, ...error.response } as any; } throw error; } } export async function generateSingleIdea(ideaId: string | number, clusterId: number): Promise<{ success: boolean; task_id?: string; idea_created?: number; message?: string; error?: string }> { const endpoint = `/v1/planner/ideas/${ideaId}/generate_idea/`; const requestBody = { cluster_id: clusterId }; try { // fetchAPI extracts data from unified format {success: true, data: {...}} // So response is already the data object: {task_id: "...", ...} const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); // Wrap extracted data with success: true for frontend compatibility if (response && typeof response === 'object') { return { success: true, ...response } as any; } return { success: true, ...response } as any; } catch (error: any) { // Error responses are thrown by fetchAPI, but wrap them for consistency if (error.response && typeof error.response === 'object') { return { success: false, error: error.message, ...error.response } as any; } throw error; } } // Helper function to fetch all clusters (for dropdowns) export async function fetchAllClusters(): Promise<Cluster[]> { const response = await fetchClusters({ ordering: 'name' }); return response.results; } // ContentIdeas API functions export interface ContentIdeasFilters { search?: string; status?: string; keyword_cluster_id?: string; content_structure?: string; content_type?: string; page?: number; page_size?: number; ordering?: string; site_id?: number; // Site filter - automatically added from siteStore sector_id?: number; // Sector filter - for second-level filtering } export interface ContentIdeasResponse { count: number; next: string | null; previous: string | null; results: ContentIdea[]; } export interface ContentIdea { id: number; idea_title: string; description?: string | null; content_type: string; // post, page, product, taxonomy content_structure: string; // article, guide, comparison, review, etc. target_keywords?: string | null; keyword_cluster_id?: number | null; keyword_cluster_name?: string | null; sector_name?: string | null; // Optional: populated by serializer status: string; estimated_word_count: number; created_at: string; updated_at: string; // Taxonomy fields taxonomy_id?: number | null; taxonomy_name?: string | null; } export interface ContentIdeaCreateData { idea_title: string; description?: string | null; content_type?: string; content_structure?: string; target_keywords?: string | null; keyword_cluster_id?: number | null; status?: string; estimated_word_count?: number; } export interface ContentIdeaUpdateData extends Partial<ContentIdeaCreateData> {} export async function fetchContentIdeas(filters: ContentIdeasFilters = {}): Promise<ContentIdeasResponse> { const params = new URLSearchParams(); // Automatically add active site filter if not explicitly provided if (!filters.site_id) { const activeSiteId = getActiveSiteId(); if (activeSiteId) { filters.site_id = activeSiteId; } } // Automatically add active sector filter if not explicitly provided if (filters.sector_id === undefined) { const activeSectorId = getActiveSectorId(); if (activeSectorId !== null && activeSectorId !== undefined) { filters.sector_id = activeSectorId; } } if (filters.search) params.append('search', filters.search); if (filters.status) params.append('status', filters.status); if (filters.keyword_cluster_id) params.append('keyword_cluster_id', filters.keyword_cluster_id); if (filters.content_structure) params.append('content_structure', filters.content_structure); if (filters.content_type) params.append('content_type', filters.content_type); if (filters.site_id) params.append('site_id', filters.site_id.toString()); if (filters.sector_id) params.append('sector_id', filters.sector_id.toString()); if (filters.page) params.append('page', filters.page.toString()); if (filters.page_size !== undefined && filters.page_size !== null) { params.append('page_size', filters.page_size.toString()); } if (filters.ordering) params.append('ordering', filters.ordering); const queryString = params.toString(); const endpoint = `/v1/planner/ideas/${queryString ? `?${queryString}` : ''}`; return fetchAPI(endpoint); } export async function fetchContentIdea(id: number): Promise<ContentIdea> { return fetchAPI(`/v1/planner/ideas/${id}/`); } export async function createContentIdea(data: ContentIdeaCreateData): Promise<ContentIdea> { return fetchAPI('/v1/planner/ideas/', { method: 'POST', body: JSON.stringify(data), }); } export async function updateContentIdea(id: number, data: ContentIdeaUpdateData): Promise<ContentIdea> { return fetchAPI(`/v1/planner/ideas/${id}/`, { method: 'PUT', body: JSON.stringify(data), }); } export async function deleteContentIdea(id: number): Promise<void> { return fetchAPI(`/v1/planner/ideas/${id}/`, { method: 'DELETE', }); } export async function bulkDeleteContentIdeas(ids: number[]): Promise<{ deleted_count: number }> { return fetchAPI(`/v1/planner/ideas/bulk_delete/`, { method: 'POST', body: JSON.stringify({ ids }), }); } export async function bulkUpdateContentIdeasStatus(ids: number[], status: string): Promise<{ updated_count: number }> { return fetchAPI(`/v1/planner/ideas/bulk_update/`, { method: 'POST', body: JSON.stringify({ ids, status }), }); } export async function bulkQueueIdeasToWriter(ids: number[]): Promise<{ created_count: number; task_ids: number[] }> { return fetchAPI(`/v1/planner/ideas/bulk_queue_to_writer/`, { method: 'POST', body: JSON.stringify({ ids }), }); } // Tasks API functions export interface TasksFilters { search?: string; status?: string; cluster_id?: string; content_type?: string; content_structure?: string; page?: number; page_size?: number; ordering?: string; site_id?: number; // Site filter - automatically added from siteStore sector_id?: number; // Sector filter - for second-level filtering } export interface TasksResponse { count: number; next: string | null; previous: string | null; results: Task[]; } export interface Task { id: number; title: string; description?: string | null; keywords?: string | null; cluster_id?: number | null; cluster_name?: string | null; sector_name?: string | null; // Optional: populated by serializer idea_id?: number | null; idea_title?: string | null; content_structure: string; content_type: string; status: string; content?: string | null; word_count: number; meta_title?: string | null; meta_description?: string | null; content_html?: string | null; content_primary_keyword?: string | null; content_secondary_keywords?: string[]; content_tags?: string[]; content_categories?: string[]; assigned_post_id?: number | null; post_url?: string | null; created_at: string; updated_at: string; // Taxonomy fields taxonomy_term_id?: number | null; taxonomy_id?: number | null; taxonomy_name?: string | null; } export interface TaskCreateData { title: string; description?: string | null; keywords?: string | null; cluster_id?: number | null; idea_id?: number | null; content_structure?: string; content_type?: string; status?: string; content?: string | null; word_count?: number; meta_title?: string | null; meta_description?: string | null; } export interface TaskUpdateData extends Partial<TaskCreateData> {} export async function fetchTasks(filters: TasksFilters = {}): Promise<TasksResponse> { const params = new URLSearchParams(); // Automatically add active site filter if not explicitly provided if (!filters.site_id) { const activeSiteId = getActiveSiteId(); if (activeSiteId) { filters.site_id = activeSiteId; } } // Automatically add active sector filter if not explicitly provided if (filters.sector_id === undefined) { const activeSectorId = getActiveSectorId(); if (activeSectorId !== null && activeSectorId !== undefined) { filters.sector_id = activeSectorId; } } if (filters.search) params.append('search', filters.search); if (filters.status) params.append('status', filters.status); if (filters.cluster_id) params.append('cluster_id', filters.cluster_id); if (filters.content_type) params.append('content_type', filters.content_type); if (filters.content_structure) params.append('content_structure', filters.content_structure); if (filters.site_id) params.append('site_id', filters.site_id.toString()); if (filters.sector_id) params.append('sector_id', filters.sector_id.toString()); if (filters.page) params.append('page', filters.page.toString()); if (filters.page_size !== undefined && filters.page_size !== null) { params.append('page_size', filters.page_size.toString()); } if (filters.ordering) params.append('ordering', filters.ordering); const queryString = params.toString(); const endpoint = `/v1/writer/tasks/${queryString ? `?${queryString}` : ''}`; return fetchAPI(endpoint); } export async function fetchTask(id: number): Promise<Task> { return fetchAPI(`/v1/writer/tasks/${id}/`); } export async function createTask(data: TaskCreateData): Promise<Task> { return fetchAPI('/v1/writer/tasks/', { method: 'POST', body: JSON.stringify(data), }); } export async function updateTask(id: number, data: TaskUpdateData): Promise<Task> { return fetchAPI(`/v1/writer/tasks/${id}/`, { method: 'PUT', body: JSON.stringify(data), }); } export async function deleteTask(id: number): Promise<void> { return fetchAPI(`/v1/writer/tasks/${id}/`, { method: 'DELETE', }); } export async function bulkDeleteTasks(ids: number[]): Promise<{ deleted_count: number }> { return fetchAPI(`/v1/writer/tasks/bulk_delete/`, { method: 'POST', body: JSON.stringify({ ids }), }); } export async function bulkUpdateTasksStatus(ids: number[], status: string): Promise<{ updated_count: number }> { return fetchAPI(`/v1/writer/tasks/bulk_update/`, { method: 'POST', body: JSON.stringify({ ids, status }), }); } export async function autoGenerateContent(ids: number[]): Promise<{ success: boolean; task_id?: string; tasks_updated?: number; message?: string; error?: string }> { const endpoint = `/v1/writer/tasks/auto_generate_content/`; const requestBody = { ids }; try { // fetchAPI extracts data from unified format {success: true, data: {...}} // So response is already the data object: {task_id: "...", ...} const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); // Wrap extracted data with success: true for frontend compatibility if (response && typeof response === 'object') { return { success: true, ...response } as any; } return { success: true, ...response } as any; } catch (error: any) { // Error responses are thrown by fetchAPI, but wrap them for consistency if (error.response && typeof error.response === 'object') { return { success: false, error: error.message, ...error.response } as any; } throw error; } } export async function autoGenerateImages(taskIds: number[]): Promise<{ success: boolean; task_id?: string; images_created?: number; message?: string; error?: string }> { const endpoint = `/v1/writer/tasks/auto_generate_images/`; const requestBody = { task_ids: taskIds }; try { // fetchAPI extracts data from unified format {success: true, data: {...}} // So response is already the data object: {task_id: "...", ...} const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); // Wrap extracted data with success: true for frontend compatibility if (response && typeof response === 'object') { return { success: true, ...response } as any; } return { success: true, ...response } as any; } catch (error: any) { // Error responses are thrown by fetchAPI, but wrap them for consistency if (error.response && typeof error.response === 'object') { return { success: false, error: error.message, ...error.response } as any; } throw error; } } export async function generateImagePrompts(contentIds: number[]): Promise<{ success: boolean; task_id?: string; message?: string; error?: string }> { try { // fetchAPI extracts data from unified format {success: true, data: {...}} // So response is already the data object: {task_id: "...", ...} const response = await fetchAPI('/v1/writer/content/generate_image_prompts/', { method: 'POST', body: JSON.stringify({ ids: contentIds }), }); // Wrap extracted data with success: true for frontend compatibility if (response && typeof response === 'object') { return { success: true, ...response } as any; } return { success: true, ...response } as any; } catch (error: any) { // Error responses are thrown by fetchAPI, but wrap them for consistency if (error.response && typeof error.response === 'object') { return { success: false, error: error.message, ...error.response } as any; } throw error; } } // TaskImages API functions export interface TaskImage { id: number; task_id: number; task_title?: string | null; image_type: string; image_url?: string | null; image_path?: string | null; prompt?: string | null; status: string; position: number; created_at: string; updated_at: string; } export interface TaskImageFilters { task_id?: string; image_type?: string; status?: string; page?: number; ordering?: string; } export interface TaskImagesResponse { count: number; next: string | null; previous: string | null; results: TaskImage[]; } export async function fetchTaskImages(filters: TaskImageFilters = {}): Promise<TaskImagesResponse> { const params = new URLSearchParams(); if (filters.task_id) params.append('task_id', filters.task_id); if (filters.image_type) params.append('image_type', filters.image_type); if (filters.status) params.append('status', filters.status); if (filters.page) params.append('page', filters.page.toString()); if (filters.ordering) params.append('ordering', filters.ordering || 'task,position,-created_at'); const queryString = params.toString(); const endpoint = `/v1/writer/images/${queryString ? `?${queryString}` : ''}`; return fetchAPI(endpoint); } // Content Images (grouped by content) export interface ContentImage { id: number; image_type: string; image_url?: string | null; image_path?: string | null; prompt?: string | null; status: string; position: number; created_at: string; updated_at: string; } export interface ContentImagesGroup { content_id: number; content_title: string; content_status: 'draft' | 'review' | 'publish'; featured_image: ContentImage | null; in_article_images: ContentImage[]; overall_status: 'pending' | 'partial' | 'complete' | 'failed'; } export interface ContentImagesResponse { count: number; results: ContentImagesGroup[]; } export interface ImageRecord { id: number; task_id?: number | null; task_title?: string | null; content_id?: number | null; content_title?: string | null; image_type: string; image_url?: string | null; image_path?: string | null; prompt?: string | null; status: string; position: number; created_at: string; updated_at: string; account_id?: number | null; } export interface ImageListResponse { count: number; next: string | null; previous: string | null; results: ImageRecord[]; } export interface ImageFilters { content_id?: number; task_id?: number; image_type?: string; status?: string; ordering?: string; page?: number; page_size?: number; } export interface ContentImagesFilters { site_id?: number; sector_id?: number; } export async function fetchContentImages(filters: ContentImagesFilters = {}): Promise<ContentImagesResponse> { const params = new URLSearchParams(); // Automatically add active site filter if not explicitly provided if (!filters.site_id) { const activeSiteId = getActiveSiteId(); if (activeSiteId) { filters.site_id = activeSiteId; } } // Automatically add active sector filter if not explicitly provided if (filters.sector_id === undefined) { const activeSectorId = getActiveSectorId(); if (activeSectorId !== null && activeSectorId !== undefined) { filters.sector_id = activeSectorId; } } if (filters.site_id) params.append('site_id', filters.site_id.toString()); if (filters.sector_id) params.append('sector_id', filters.sector_id.toString()); const queryString = params.toString(); // fetchAPI automatically extracts data field from unified format return fetchAPI(`/v1/writer/images/content_images/${queryString ? `?${queryString}` : ''}`); } export async function bulkUpdateImagesStatus(contentId: number, status: string): Promise<{ updated_count: number }> { return fetchAPI(`/v1/writer/images/bulk_update/`, { method: 'POST', body: JSON.stringify({ content_id: contentId, status }), }); } export async function generateImages(imageIds: number[], contentId?: number): Promise<{ success: boolean; task_id?: string; message?: string; error?: string }> { try { // fetchAPI extracts data from unified format {success: true, data: {...}} // So response is already the data object: {task_id: "...", ...} const response = await fetchAPI('/v1/writer/images/generate_images/', { method: 'POST', body: JSON.stringify({ ids: imageIds, content_id: contentId }), }); // Wrap extracted data with success: true for frontend compatibility if (response && typeof response === 'object') { return { success: true, ...response } as any; } return { success: true, ...response } as any; } catch (error: any) { // Error responses are thrown by fetchAPI, but wrap them for consistency if (error.response && typeof error.response === 'object') { return { success: false, error: error.message, ...error.response } as any; } throw error; } } export async function fetchImages(filters: ImageFilters = {}): Promise<ImageListResponse> { const params = new URLSearchParams(); if (filters.content_id) params.append('content_id', filters.content_id.toString()); if (filters.task_id) params.append('task_id', filters.task_id.toString()); if (filters.image_type) params.append('image_type', filters.image_type); if (filters.status) params.append('status', filters.status); if (filters.ordering) params.append('ordering', filters.ordering); if (filters.page) params.append('page', filters.page.toString()); if (filters.page_size) params.append('page_size', filters.page_size.toString()); const queryString = params.toString(); return fetchAPI(`/v1/writer/images/${queryString ? `?${queryString}` : ''}`); } export interface ImageGenerationSettings { success: boolean; config: { provider: string; model: string; image_type: string; max_in_article_images: number; image_format: string; desktop_enabled: boolean; mobile_enabled: boolean; }; } export async function fetchImageGenerationSettings(): Promise<ImageGenerationSettings> { return fetchAPI('/v1/system/integrations/image_generation/'); } export async function deleteTaskImage(id: number): Promise<void> { return fetchAPI(`/v1/writer/images/${id}/`, { method: 'DELETE', }); } // Sites API functions export interface Site { id: number; name: string; slug: string; domain?: string | null; description?: string | null; industry?: number | null; industry_name?: string | null; industry_slug?: string | null; is_active: boolean; status: string; wp_url?: string | null; wp_username?: string | null; sectors_count: number; active_sectors_count: number; selected_sectors: number[]; can_add_sectors: boolean; created_at: string; updated_at: string; } export interface SiteCreateData { name: string; industry?: number; // Industry ID - required by backend slug?: string; domain?: string; description?: string; is_active?: boolean; status?: string; wp_url?: string; wp_username?: string; wp_app_password?: string; hosting_type?: string; } export interface SitesResponse { count: number; next: string | null; previous: string | null; results: Site[]; } export async function fetchSites(): Promise<SitesResponse> { return fetchAPI('/v1/auth/sites/'); } export async function fetchSite(id: number): Promise<Site> { return fetchAPI(`/v1/auth/sites/${id}/`); } export async function createSite(data: SiteCreateData): Promise<Site> { return fetchAPI('/v1/auth/sites/', { method: 'POST', body: JSON.stringify(data), }); } export async function updateSite(id: number, data: Partial<SiteCreateData>): Promise<Site> { return fetchAPI(`/v1/auth/sites/${id}/`, { method: 'PATCH', body: JSON.stringify(data), }); } export async function deleteSite(id: number): Promise<void> { return fetchAPI(`/v1/auth/sites/${id}/`, { method: 'DELETE', }); } export async function setActiveSite(id: number): Promise<{ success: boolean; message: string; site: Site }> { return fetchAPI(`/v1/auth/sites/${id}/set_active/`, { method: 'POST', }); } export async function selectSectorsForSite( siteId: number, industrySlug: string, sectorSlugs: string[] ): Promise<{ success: boolean; message: string; created_count: number; updated_count: number; sectors: any[] }> { return fetchAPI(`/v1/auth/sites/${siteId}/select_sectors/`, { method: 'POST', body: JSON.stringify({ industry_slug: industrySlug, sector_slugs: sectorSlugs, }), }); } export async function fetchSiteSectors(siteId: number): Promise<any[]> { const response = await fetchAPI(`/v1/auth/sites/${siteId}/sectors/`); // fetchAPI automatically extracts data field from unified format return Array.isArray(response) ? response : []; } // Industries API functions export interface Industry { id?: number; name: string; slug: string; description: string; sectors: Sector[]; sectors_count?: number; keywords_count?: number; is_active?: boolean; } export interface Sector { name: string; slug: string; description: string; } export interface IndustriesResponse { success: boolean; industries: Industry[]; } export async function fetchIndustries(): Promise<IndustriesResponse> { const response = await fetchAPI('/v1/auth/industries/'); // fetchAPI automatically extracts data field, but industries endpoint returns {industries: [...]} // So we need to handle the nested structure if (response && typeof response === 'object' && 'industries' in response) { return { success: true, industries: response.industries || [] }; } // If response is already an array or different format return { success: true, industries: Array.isArray(response) ? response : [] }; } // Sectors API functions // Settings API functions export interface AccountSetting { id: number; key: string; config: Record<string, any>; is_active: boolean; created_at: string; updated_at: string; } export interface AccountSettingsResponse { count: number; next: string | null; previous: string | null; results: AccountSetting[]; } export type AccountSettingsErrorType = | 'ACCOUNT_SETTINGS_API_ERROR' | 'ACCOUNT_SETTINGS_NOT_FOUND' | 'ACCOUNT_SETTINGS_VALIDATION_ERROR'; export class AccountSettingsError extends Error { type: AccountSettingsErrorType; status?: number; details?: unknown; constructor(type: AccountSettingsErrorType, message: string, status?: number, details?: unknown) { super(message); this.name = 'AccountSettingsError'; this.type = type; this.status = status; this.details = details; } } function buildAccountSettingsError(error: any, fallbackMessage: string): AccountSettingsError { const status = error?.status; const response = error?.response; const details = response || error; if (status === 404) { return new AccountSettingsError( 'ACCOUNT_SETTINGS_NOT_FOUND', 'No account settings were found for this account yet.', status, details ); } if (status === 400 || response?.errors) { const validationMessage = response?.error || response?.message || response?.detail || 'The account settings request is invalid. Please review the submitted data.'; return new AccountSettingsError( 'ACCOUNT_SETTINGS_VALIDATION_ERROR', validationMessage, status, details ); } return new AccountSettingsError( 'ACCOUNT_SETTINGS_API_ERROR', error?.message || fallbackMessage, status, details ); } export async function fetchAccountSettings(): Promise<AccountSettingsResponse> { try { return await fetchAPI('/v1/system/settings/account/'); } catch (error: any) { throw buildAccountSettingsError(error, 'Unable to load account settings right now.'); } } export async function fetchAccountSetting(key: string): Promise<AccountSetting> { try { return await fetchAPI(`/v1/system/settings/account/${key}/`); } catch (error: any) { throw buildAccountSettingsError(error, `Account setting "${key}" is not available.`); } } export async function createAccountSetting(data: { key: string; config: Record<string, any>; is_active?: boolean }): Promise<AccountSetting> { try { return await fetchAPI('/v1/system/settings/account/', { method: 'POST', body: JSON.stringify(data), }); } catch (error: any) { throw buildAccountSettingsError(error, 'Unable to create the account setting.'); } } export async function updateAccountSetting(key: string, data: Partial<{ config: Record<string, any>; is_active: boolean }>): Promise<AccountSetting> { try { return await fetchAPI(`/v1/system/settings/account/${key}/`, { method: 'PUT', body: JSON.stringify(data), }); } catch (error: any) { throw buildAccountSettingsError(error, `Unable to update account setting "${key}".`); } } export async function deleteAccountSetting(key: string): Promise<void> { try { await fetchAPI(`/v1/system/settings/account/${key}/`, { method: 'DELETE', }); } catch (error: any) { throw buildAccountSettingsError(error, `Unable to delete account setting "${key}".`); } } // User Settings API functions export interface UserSetting { id: number; key: string; value: Record<string, any>; created_at: string; updated_at: string; } export interface UserSettingsResponse { count: number; next: string | null; previous: string | null; results: UserSetting[]; } export async function fetchUserSettings(): Promise<UserSettingsResponse> { return fetchAPI('/v1/system/settings/user/'); } export async function fetchUserSetting(key: string): Promise<UserSetting> { return fetchAPI(`/v1/system/settings/user/${key}/`); } export async function createUserSetting(data: { key: string; value: Record<string, any> }): Promise<UserSetting> { return fetchAPI('/v1/system/settings/user/', { method: 'POST', body: JSON.stringify(data), }); } export async function updateUserSetting(key: string, data: { value: Record<string, any> }): Promise<UserSetting> { return fetchAPI(`/v1/system/settings/user/${key}/`, { method: 'PUT', body: JSON.stringify(data), }); } export async function deleteUserSetting(key: string): Promise<void> { await fetchAPI(`/v1/system/settings/user/${key}/`, { method: 'DELETE', }); } // Module Settings export interface ModuleEnableSettings { id: number; planner_enabled: boolean; writer_enabled: boolean; thinker_enabled: boolean; automation_enabled: boolean; site_builder_enabled: boolean; linker_enabled: boolean; optimizer_enabled: boolean; publisher_enabled: boolean; created_at: string; updated_at: string; } export interface ModuleSetting { id: number; module_name: string; key: string; config: Record<string, any>; is_active: boolean; created_at: string; updated_at: string; } // Deduplicate module-enable fetches to prevent 429s for normal users let moduleEnableSettingsInFlight: Promise<ModuleEnableSettings> | null = null; export async function fetchModuleSettings(moduleName: string): Promise<ModuleSetting[]> { // fetchAPI extracts data from unified format {success: true, data: [...]} // So response IS the array, not an object with results const response = await fetchAPI(`/v1/system/settings/modules/module/${moduleName}/`); return Array.isArray(response) ? response : []; } export async function createModuleSetting(data: { module_name: string; key: string; config: Record<string, any>; is_active?: boolean }): Promise<ModuleSetting> { return fetchAPI('/v1/system/settings/modules/', { method: 'POST', body: JSON.stringify(data), }); } export async function fetchModuleEnableSettings(): Promise<ModuleEnableSettings> { if (moduleEnableSettingsInFlight) { return moduleEnableSettingsInFlight; } moduleEnableSettingsInFlight = fetchAPI('/v1/system/settings/modules/enable/'); try { const response = await moduleEnableSettingsInFlight; return response; } finally { moduleEnableSettingsInFlight = null; } } export async function updateModuleEnableSettings(data: Partial<ModuleEnableSettings>): Promise<ModuleEnableSettings> { const response = await fetchAPI('/v1/system/settings/modules/enable/', { method: 'PUT', body: JSON.stringify(data), }); return response; } export async function updateModuleSetting(moduleName: string, key: string, data: Partial<{ config: Record<string, any>; is_active: boolean }>): Promise<ModuleSetting> { return fetchAPI(`/v1/system/settings/modules/${key}/?module_name=${moduleName}`, { method: 'PUT', body: JSON.stringify(data), }); } // Billing API functions export interface CreditBalance { credits: number; plan_credits_per_month: number; credits_used_this_month: number; credits_remaining: number; } export interface CreditUsageLog { id: number; operation_type: string; operation_type_display: string; credits_used: number; cost_usd: string | null; model_used: string; tokens_input: number | null; tokens_output: number | null; related_object_type: string; related_object_id: number | null; metadata: Record<string, any>; created_at: string; } export interface CreditUsageResponse { count: number; next: string | null; previous: string | null; results: CreditUsageLog[]; } export interface UsageSummary { period: { start: string; end: string; }; total_credits_used: number; total_cost_usd: number; by_operation: Record<string, { credits: number; cost: number; count: number; }>; by_model: Record<string, { credits: number; cost: number; }>; } export async function fetchCreditBalance(): Promise<CreditBalance> { try { // Canonical balance endpoint (business billing CreditTransactionViewSet.balance) const response = await fetchAPI('/v1/billing/transactions/balance/'); if (response && typeof response === 'object' && 'credits' in response) { return response as CreditBalance; } // Default if response is invalid return { credits: 0, plan_credits_per_month: 0, credits_used_this_month: 0, credits_remaining: 0, }; } catch (error: any) { console.debug('Failed to fetch credit balance, using defaults:', error?.message || error); // Return default balance on error so UI can still render return { credits: 0, plan_credits_per_month: 0, credits_used_this_month: 0, credits_remaining: 0, }; } } export async function fetchCreditUsage(filters?: { operation_type?: string; start_date?: string; end_date?: string; page?: number; }): Promise<CreditUsageResponse> { const params = new URLSearchParams(); if (filters?.operation_type) params.append('operation_type', filters.operation_type); if (filters?.start_date) params.append('start_date', filters.start_date); if (filters?.end_date) params.append('end_date', filters.end_date); if (filters?.page) params.append('page', filters.page.toString()); const queryString = params.toString(); return fetchAPI(`/v1/billing/credits/usage/${queryString ? `?${queryString}` : ''}`); } export async function fetchUsageSummary(startDate?: string, endDate?: string): Promise<UsageSummary> { const params = new URLSearchParams(); if (startDate) params.append('start_date', startDate); if (endDate) params.append('end_date', endDate); const queryString = params.toString(); // fetchAPI automatically extracts data field from unified format return fetchAPI(`/v1/billing/credits/usage/summary/${queryString ? `?${queryString}` : ''}`); } export interface LimitCard { title: string; limit: number; used: number; available: number; unit: string; category: 'planner' | 'writer' | 'images' | 'ai' | 'general'; percentage: number; } export interface UsageLimitsResponse { limits: LimitCard[]; } export async function fetchUsageLimits(): Promise<UsageLimitsResponse> { console.log('Fetching usage limits from:', '/v1/billing/credits/usage/limits/'); try { // fetchAPI extracts data from unified format {success: true, data: { limits: [...] }} // So response IS the data object const response = await fetchAPI('/v1/billing/credits/usage/limits/'); console.log('Usage limits API response:', response); return response; } catch (error) { console.error('Error fetching usage limits:', error); throw error; } } export interface CreditTransaction { id: number; transaction_type: string; transaction_type_display: string; amount: number; balance_after: number; description: string; metadata: Record<string, any>; created_at: string; } export interface CreditTransactionResponse { count: number; next: string | null; previous: string | null; results: CreditTransaction[]; } export async function fetchCreditTransactions(filters?: { transaction_type?: string; page?: number; }): Promise<CreditTransactionResponse> { const params = new URLSearchParams(); if (filters?.transaction_type) params.append('transaction_type', filters.transaction_type); if (filters?.page) params.append('page', filters.page.toString()); const queryString = params.toString(); return fetchAPI(`/v1/billing/credits/transactions/${queryString ? `?${queryString}` : ''}`); } // Seed Keywords API export interface SeedKeyword { id: number; keyword: string; industry: number; industry_name: string; industry_slug: string; sector: number; sector_name: string; sector_slug: string; volume: number; difficulty: number; country: string; country_display: string; is_active: boolean; created_at: string; updated_at: string; } export interface SeedKeywordResponse { count: number; next: string | null; previous: string | null; results: SeedKeyword[]; } export async function fetchSeedKeywords(filters?: { industry?: number; sector?: number; country?: string; search?: string; page?: number; page_size?: number; }): Promise<SeedKeywordResponse> { const params = new URLSearchParams(); // Use industry_id and sector_id as per backend get_queryset, but also try industry/sector for filterset_fields if (filters?.industry) { params.append('industry', filters.industry.toString()); params.append('industry_id', filters.industry.toString()); // Also send industry_id for get_queryset } if (filters?.sector) { params.append('sector', filters.sector.toString()); params.append('sector_id', filters.sector.toString()); // Also send sector_id for get_queryset } if (filters?.country) params.append('country', filters.country); if (filters?.search) params.append('search', filters.search); if (filters?.page) params.append('page', filters.page.toString()); if (filters?.page_size) params.append('page_size', filters.page_size.toString()); const queryString = params.toString(); return fetchAPI(`/v1/auth/seed-keywords/${queryString ? `?${queryString}` : ''}`); } /** * Add SeedKeywords to workflow (create Keywords records) */ export async function addSeedKeywordsToWorkflow(seedKeywordIds: number[], siteId: number, sectorId: number): Promise<{ success: boolean; created: number; skipped?: number; errors?: string[] }> { try { // fetchAPI extracts data from unified format {success: true, data: {...}} // So response is already the data object: {created: X, skipped: X, errors: [...]} const response = await fetchAPI('/v1/planner/keywords/bulk_add_from_seed/', { method: 'POST', body: JSON.stringify({ seed_keyword_ids: seedKeywordIds, site_id: siteId, sector_id: sectorId, }), }); // Wrap extracted data with success: true for frontend compatibility if (response && typeof response === 'object') { return { success: true, ...response } as any; } return { success: true, ...response } as any; } catch (error: any) { // Error responses are thrown by fetchAPI - return as failed result // Extract clean user-friendly message (error.message is already cleaned in fetchAPI) const userMessage = error.message || 'Failed to add keywords'; return { success: false, created: 0, skipped: 0, errors: [userMessage] }; } } // Author Profiles API export interface AuthorProfile { id: number; name: string; description: string; tone: string; language: string; structure_template: Record<string, any>; is_active: boolean; created_at: string; updated_at: string; } export interface AuthorProfileResponse { count: number; next: string | null; previous: string | null; results: AuthorProfile[]; } export async function fetchAuthorProfiles(filters?: { is_active?: boolean; language?: string; search?: string; page?: number; }): Promise<AuthorProfileResponse> { const params = new URLSearchParams(); if (filters?.is_active !== undefined) params.append('is_active', filters.is_active.toString()); if (filters?.language) params.append('language', filters.language); if (filters?.search) params.append('search', filters.search); if (filters?.page) params.append('page', filters.page.toString()); const queryString = params.toString(); return fetchAPI(`/v1/system/author-profiles/${queryString ? `?${queryString}` : ''}`); } export async function createAuthorProfile(data: Partial<AuthorProfile>): Promise<AuthorProfile> { return fetchAPI('/v1/system/author-profiles/', { method: 'POST', body: JSON.stringify(data), }); } export async function updateAuthorProfile(id: number, data: Partial<AuthorProfile>): Promise<AuthorProfile> { return fetchAPI(`/v1/system/author-profiles/${id}/`, { method: 'PUT', body: JSON.stringify(data), }); } export async function deleteAuthorProfile(id: number): Promise<void> { return fetchAPI(`/v1/system/author-profiles/${id}/`, { method: 'DELETE', }); } // Content API export interface ContentFilters { search?: string; status?: string; content_type?: string; content_structure?: string; source?: string; cluster_id?: number; page?: number; page_size?: number; ordering?: string; site_id?: number; sector_id?: number; } export interface Content { id: number; // Core fields title: string; content_html: string; content_type: string; content_structure: string; status: 'draft' | 'published'; source: 'igny8' | 'wordpress'; // Relations cluster_id: number; cluster_name?: string | null; sector_name?: string | null; taxonomy_terms?: Array<{ id: number; name: string; taxonomy_type: string; }>; taxonomy_terms_data?: Array<{ id: number; name: string; taxonomy_type: string; }>; tags?: string[]; categories?: string[]; // WordPress integration external_id?: string | null; external_url?: string | null; wordpress_status?: 'publish' | 'draft' | 'pending' | 'future' | 'private' | 'trash' | null; // Timestamps created_at: string; updated_at: string; // Image support has_image_prompts?: boolean; has_generated_images?: boolean; // Additional fields used in Linker/Optimizer internal_links?: Array<{ anchor_text: string; target_content_id: number }>; linker_version?: number; optimization_scores?: { seo_score: number; readability_score: number; engagement_score: number; overall_score: number; }; } export interface ContentResponse { count: number; next: string | null; previous: string | null; results: Content[]; } export async function fetchContent(filters: ContentFilters = {}): Promise<ContentResponse> { const params = new URLSearchParams(); // Automatically add active site filter if not explicitly provided if (!filters.site_id) { const activeSiteId = getActiveSiteId(); if (activeSiteId) { filters.site_id = activeSiteId; } } // Automatically add active sector filter if not explicitly provided if (filters.sector_id === undefined) { const activeSectorId = getActiveSectorId(); if (activeSectorId !== null && activeSectorId !== undefined) { filters.sector_id = activeSectorId; } } if (filters.search) params.append('search', filters.search); if (filters.status) params.append('status', filters.status); if (filters.task_id) params.append('task_id', filters.task_id.toString()); if (filters.site_id) params.append('site_id', filters.site_id.toString()); if (filters.sector_id) params.append('sector_id', filters.sector_id.toString()); if (filters.page) params.append('page', filters.page.toString()); if (filters.page_size !== undefined && filters.page_size !== null) { params.append('page_size', filters.page_size.toString()); } if (filters.ordering) params.append('ordering', filters.ordering); const queryString = params.toString(); return fetchAPI(`/v1/writer/content/${queryString ? `?${queryString}` : ''}`); } export async function fetchContentById(id: number): Promise<Content> { return fetchAPI(`/v1/writer/content/${id}/`); } // Fetch WordPress status for published content export interface WordPressStatusResult { wordpress_status: 'publish' | 'draft' | 'pending' | 'future' | 'private' | 'trash' | null; external_id: string | null; external_url: string | null; post_title?: string; post_modified?: string; last_checked?: string; } export async function fetchWordPressStatus(contentId: number): Promise<WordPressStatusResult> { try { const response = await fetchAPI(`/v1/writer/content/${contentId}/wordpress_status/`); return response.data || response; } catch (error) { console.warn(`Failed to fetch WordPress status for content ${contentId}:`, error); return { wordpress_status: null, external_id: null, external_url: null, }; } } // Content Publishing API export interface PublishContentResult { content_id: number; status: string; external_id: string; external_url: string; message?: string; } export interface UnpublishContentResult { content_id: number; status: string; message?: string; } export async function publishContent(id: number, site_id?: number): Promise<PublishContentResult> { const body: { site_id?: number } = {}; if (site_id !== undefined) { body.site_id = site_id; } return fetchAPI(`/v1/writer/content/${id}/publish/`, { method: 'POST', body: JSON.stringify(body), }); } export async function unpublishContent(id: number): Promise<UnpublishContentResult> { return fetchAPI(`/v1/writer/content/${id}/unpublish/`, { method: 'POST', }); } export async function deleteContent(id: number): Promise<void> { return fetchAPI(`/v1/writer/content/${id}/`, { method: 'DELETE', }); } export async function bulkDeleteContent(ids: number[]): Promise<{ deleted_count: number }> { return fetchAPI(`/v1/writer/content/bulk_delete/`, { method: 'POST', body: JSON.stringify({ ids }), }); } // Stage 3: Content Validation API export interface ContentValidationResult { content_id: number; is_valid: boolean; ready_to_publish: boolean; validation_errors: Array<{ field: string; code: string; message: string; }>; publish_errors: Array<{ field: string; code: string; message: string; }>; metadata: { has_content_type: boolean; content_type: string | null; has_cluster_mapping: boolean; has_taxonomy_mapping: boolean; }; } export async function fetchContentValidation(id: number): Promise<ContentValidationResult> { return fetchAPI(`/v1/writer/content/${id}/validation/`); } export async function validateContent(id: number): Promise<{ content_id: number; is_valid: boolean; errors: Array<{ field: string; code: string; message: string; }>; }> { return fetchAPI(`/v1/writer/content/${id}/validate/`, { method: 'POST', }); } // Content Taxonomy API export interface ContentTaxonomy { id: number; name: string; slug: string; taxonomy_type: 'category' | 'tag' | 'product_category' | 'product_tag' | 'product_attribute' | 'cluster'; external_taxonomy?: string | null; external_id?: string | null; parent_id?: number | null; description?: string | null; count?: number; site_id: number; sector_id?: number | null; created_at: string; updated_at: string; } export interface ContentTaxonomyFilters { taxonomy_type?: string; search?: string; site_id?: number; sector_id?: number; page?: number; page_size?: number; ordering?: string; } export interface ContentTaxonomyResponse { count: number; next: string | null; previous: string | null; results: ContentTaxonomy[]; } export async function fetchTaxonomies(filters: ContentTaxonomyFilters = {}): Promise<ContentTaxonomyResponse> { const params = new URLSearchParams(); // Automatically add active site filter if not explicitly provided if (!filters.site_id) { const activeSiteId = getActiveSiteId(); if (activeSiteId) { filters.site_id = activeSiteId; } } // Automatically add active sector filter if not explicitly provided if (filters.sector_id === undefined) { const activeSectorId = getActiveSectorId(); if (activeSectorId !== null && activeSectorId !== undefined) { filters.sector_id = activeSectorId; } } if (filters.search) params.append('search', filters.search); if (filters.taxonomy_type) params.append('taxonomy_type', filters.taxonomy_type); if (filters.site_id) params.append('site_id', filters.site_id.toString()); if (filters.sector_id) params.append('sector_id', filters.sector_id.toString()); if (filters.page) params.append('page', filters.page.toString()); if (filters.page_size !== undefined && filters.page_size !== null) { params.append('page_size', filters.page_size.toString()); } if (filters.ordering) params.append('ordering', filters.ordering); const queryString = params.toString(); return fetchAPI(`/v1/writer/taxonomies/${queryString ? `?${queryString}` : ''}`); } export async function fetchTaxonomyById(id: number): Promise<ContentTaxonomy> { return fetchAPI(`/v1/writer/taxonomies/${id}/`); } export async function createTaxonomy(data: Partial<ContentTaxonomy>): Promise<ContentTaxonomy> { return fetchAPI('/v1/writer/taxonomies/', { method: 'POST', body: JSON.stringify(data), }); } export async function updateTaxonomy(id: number, data: Partial<ContentTaxonomy>): Promise<ContentTaxonomy> { return fetchAPI(`/v1/writer/taxonomies/${id}/`, { method: 'PATCH', body: JSON.stringify(data), }); } export async function deleteTaxonomy(id: number): Promise<void> { return fetchAPI(`/v1/writer/taxonomies/${id}/`, { method: 'DELETE', }); } // Legacy: Site Builder API removed // SiteBlueprint, PageBlueprint, and related functions deprecated // Stage 4: Sync Health API export interface SyncStatus { site_id: number; integrations: Array<{ id: number; platform: string; status: string; last_sync_at: string | null; sync_enabled: boolean; is_healthy: boolean; error: string | null; mismatch_count: number; }>; overall_status: 'healthy' | 'warning' | 'error'; last_sync_at: string | null; } export interface SyncMismatches { taxonomies: { missing_in_wordpress: Array<{ id: number; name: string; type: string; external_reference?: string; }>; missing_in_igny8: Array<{ name: string; slug: string; type: string; external_reference: string; }>; mismatched: Array<any>; }; products: { missing_in_wordpress: Array<any>; missing_in_igny8: Array<any>; }; posts: { missing_in_wordpress: Array<any>; missing_in_igny8: Array<any>; }; } export interface SyncLog { integration_id: number; platform: string; timestamp: string; status: string; error: string | null; duration: number | null; items_processed: number | null; } export async function fetchSyncStatus(siteId: number): Promise<SyncStatus> { return fetchAPI(`/v1/integration/integrations/sites/${siteId}/sync/status/`); } export async function runSync( siteId: number, direction: 'both' | 'to_external' | 'from_external' = 'both', contentTypes?: string[] ): Promise<{ site_id: number; sync_results: any[]; total_integrations: number }> { return fetchAPI(`/v1/integration/integrations/sites/${siteId}/sync/run/`, { method: 'POST', body: JSON.stringify({ direction, content_types: contentTypes }), }); } export async function fetchSyncMismatches(siteId: number): Promise<SyncMismatches> { return fetchAPI(`/v1/integration/integrations/sites/${siteId}/sync/mismatches/`); } export async function fetchSyncLogs( siteId: number, limit: number = 100, integrationId?: number ): Promise<{ site_id: number; logs: SyncLog[]; count: number }> { const params = new URLSearchParams(); params.append('limit', limit.toString()); if (integrationId) params.append('integration_id', integrationId.toString()); return fetchAPI(`/v1/integration/integrations/sites/${siteId}/sync/logs/?${params.toString()}`); } // Stage 4: Deployment Readiness API export interface DeploymentReadiness { ready: boolean; checks: { cluster_coverage: boolean; content_validation: boolean; sync_status: boolean; taxonomy_completeness: boolean; }; errors: string[]; warnings: string[]; details: { cluster_coverage: { ready: boolean; total_clusters: number; covered_clusters: number; incomplete_clusters: Array<any>; errors: string[]; warnings: string[]; }; content_validation: { ready: boolean; total_content: number; valid_content: number; invalid_content: Array<any>; errors: string[]; warnings: string[]; }; sync_status: { ready: boolean; has_integration: boolean; sync_status: string | null; mismatch_count: number; errors: string[]; warnings: string[]; }; taxonomy_completeness: { ready: boolean; total_taxonomies: number; required_taxonomies: string[]; missing_taxonomies: string[]; errors: string[]; warnings: string[]; }; }; } // Legacy: Site Builder API removed // SiteBlueprint, PageBlueprint, and related functions deprecated export async function generatePageContent( pageId: number, force?: boolean ): Promise<{ success: boolean; task_id?: string }> { return fetchAPI(`/v1/site-builder/pages/${pageId}/generate_content/`, { method: 'POST', body: JSON.stringify({ force: force || false }), }); }