// Centralized API configuration and functions // Auto-detect API URL based on current origin (supports both IP and subdomain access) import { useAuthStore } from '../store/authStore'; import { useAIRequestLogsStore } from '../store/aiRequestLogsStore'; 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 const getAuthToken = (): string | null => { try { const authStorage = localStorage.getItem('auth-storage'); if (authStorage) { const parsed = JSON.parse(authStorage); return parsed?.state?.token || null; } } catch (e) { // Ignore parsing errors } return null; }; // Get refresh token from store const getRefreshToken = (): string | null => { try { const authStorage = localStorage.getItem('auth-storage'); if (authStorage) { const parsed = JSON.parse(authStorage); return parsed?.state?.refreshToken || null; } } catch (e) { // Ignore parsing errors } 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 401 Unauthorized - try to refresh token if (response.status === 401) { 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(); if (refreshData.success && refreshData.access) { // Update token in store try { const authStorage = localStorage.getItem('auth-storage'); if (authStorage) { const parsed = JSON.parse(authStorage); parsed.state.token = refreshData.access; localStorage.setItem('auth-storage', JSON.stringify(parsed)); } } catch (e) { // Ignore storage errors } // Retry original request with new token const newHeaders = { ...headers, 'Authorization': `Bearer ${refreshData.access}`, }; 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; } } } } catch (refreshError) { // Refresh failed, continue with original error handling } } } // 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); errorMessage = errorData.error || errorData.message || errorData.detail || errorMessage; // If the response has a success field set to false, return it as-is // This allows callers to handle structured error responses if (errorData.success === false && errorData.error) { // Return the error response object instead of throwing // This is a special case for structured error responses return errorData; } // 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 const apiError = new Error(`API Error (${response.status}): ${errorType} - ${errorMessage}`); (apiError as any).response = errorData; (apiError as any).status = response.status; 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 try { return JSON.parse(text); } catch (e) { // If JSON parsing fails, return text return text; } } 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; intent?: 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 intent: 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 { seed_keyword_id: number; 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 // Always add site_id if there's an active site (even for admin/developer) // The backend will respect it appropriately - admin/developer can still see all sites // but if a specific site is selected, filter by it if (!filters.site_id) { const activeSiteId = getActiveSiteId(); if (activeSiteId) { filters.site_id = activeSiteId; } } // Automatically add active sector filter if not explicitly provided // Only add if activeSector is not null (null means "All Sectors") // ADMIN/DEV OVERRIDE: Only inject if user is not admin/developer (handled by backend) if (filters.sector_id === undefined) { const activeSectorId = getActiveSectorId(); // Only add sector_id if it's not null (null means "All Sectors") 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.intent) params.append('intent', filters.intent); 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> { return fetchAPI('/v1/planner/keywords/', { method: 'POST', body: JSON.stringify(data), }); } 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; 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 // Always add site_id if there's an active site (even for admin/developer) // The backend will respect it appropriately - admin/developer can still see all sites // but if a specific site is selected, filter by it if (!filters.site_id) { const activeSiteId = getActiveSiteId(); if (activeSiteId) { filters.site_id = activeSiteId; } } // Automatically add active sector filter if not explicitly provided // Only add if activeSector is not null (null means "All Sectors") // ADMIN/DEV OVERRIDE: Only inject if user is not admin/developer (handled by backend) if (filters.sector_id === undefined) { const activeSectorId = getActiveSectorId(); // Only add sector_id if it's not null (null means "All Sectors") 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 startTime = Date.now(); const addLog = useAIRequestLogsStore.getState().addLog; const endpoint = `/v1/planner/keywords/auto_cluster/`; const requestBody = { ids: keywordIds, sector_id: sectorId }; const pendingLogId = addLog({ function: 'autoClusterKeywords', endpoint, request: { method: 'POST', body: requestBody, }, status: 'pending', }); try { const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); const duration = Date.now() - startTime; const updateLog = useAIRequestLogsStore.getState().updateLog; // Update log with response data (including task_id for progress tracking) if (pendingLogId && response) { updateLog(pendingLogId, { response: { status: 200, data: response, }, status: response.success === false ? 'error' : 'success', duration, }); } // Check if response indicates an error (success: false) if (response && response.success === false) { // Return error response as-is so caller can check result.success return response; } return response; } catch (error: any) { const duration = Date.now() - startTime; // Try to extract error response data if available let errorResponseData = null; let errorRequestSteps = null; // Check if error has response data (from fetchAPI) if (error.response || error.data) { errorResponseData = error.response || error.data; errorRequestSteps = errorResponseData?.request_steps; } else if ((error as any).response) { // Error object from fetchAPI has response attached errorResponseData = (error as any).response; errorRequestSteps = errorResponseData?.request_steps; } // Parse error message to extract error type let errorType = 'UNKNOWN_ERROR'; let errorMessage = error.message || 'Unknown error'; // Check if error response contains JSON with error field if (error.message && error.message.includes('API Error')) { // Try to extract structured error from API response const apiErrorMatch = error.message.match(/API Error \(\d+\): ([^-]+) - (.+)/); if (apiErrorMatch) { errorType = apiErrorMatch[1].trim(); errorMessage = apiErrorMatch[2].trim(); } } if (errorMessage.includes('OperationalError')) { errorType = 'DATABASE_ERROR'; errorMessage = errorMessage.replace(/API Error \(\d+\): /, '').replace(/ - .*OperationalError.*/, ' - Database operation failed'); } else if (errorMessage.includes('ValidationError')) { errorType = 'VALIDATION_ERROR'; } else if (errorMessage.includes('PermissionDenied')) { errorType = 'PERMISSION_ERROR'; } else if (errorMessage.includes('NotFound')) { errorType = 'NOT_FOUND_ERROR'; } else if (errorMessage.includes('IntegrityError')) { errorType = 'DATABASE_ERROR'; } else if (errorMessage.includes('RelatedObjectDoesNotExist')) { errorType = 'RELATED_OBJECT_ERROR'; // Extract clean error message errorMessage = errorMessage.replace(/API Error \(\d+\): [^-]+ - /, '').trim(); } // Update existing log or create new one const updateLog = useAIRequestLogsStore.getState().updateLog; const addRequestStep = useAIRequestLogsStore.getState().addRequestStep; if (pendingLogId) { updateLog(pendingLogId, { response: { status: errorResponseData?.status || 500, error: errorMessage, errorType, data: errorResponseData, }, status: 'error', duration, }); // Add request steps from error response if available if (errorRequestSteps && Array.isArray(errorRequestSteps)) { errorRequestSteps.forEach((step: any) => { addRequestStep(pendingLogId, step); }); } } else { // Create new log if pendingLogId doesn't exist const errorLogId = addLog({ function: 'autoClusterKeywords', endpoint, request: { method: 'POST', body: requestBody, }, response: { status: errorResponseData?.status || 500, error: errorMessage, errorType, data: errorResponseData, }, status: 'error', duration, }); if (errorLogId && errorRequestSteps && Array.isArray(errorRequestSteps)) { errorRequestSteps.forEach((step: any) => { addRequestStep(errorLogId, step); }); } } // Return error response in same format as successful response // This allows the caller to check result.success === false return { success: false, error: errorMessage, errorType, }; } } export async function autoGenerateIdeas(clusterIds: number[]): Promise<{ success: boolean; task_id?: string; ideas_created?: number; message?: string; error?: string }> { const startTime = Date.now(); const { useAIRequestLogsStore } = await import('../store/aiRequestLogsStore').catch(() => ({ useAIRequestLogsStore: null })); const addLog = useAIRequestLogsStore?.getState().addLog; const endpoint = `/v1/planner/clusters/auto_generate_ideas/`; const requestBody = { ids: clusterIds }; addLog?.({ function: 'autoGenerateIdeas', endpoint, request: { method: 'POST', body: requestBody, }, status: 'pending', }); try { const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); const duration = Date.now() - startTime; addLog({ function: 'autoGenerateIdeas', endpoint, request: { method: 'POST', body: requestBody, }, response: { status: 200, data: response, }, status: 'success', duration, }); return response; } catch (error: any) { const duration = Date.now() - startTime; // Parse error message to extract error type let errorType = 'UNKNOWN_ERROR'; let errorMessage = error.message || 'Unknown error'; if (errorMessage.includes('OperationalError')) { errorType = 'DATABASE_ERROR'; errorMessage = errorMessage.replace(/API Error \(\d+\): /, '').replace(/ - .*OperationalError.*/, ' - Database operation failed'); } else if (errorMessage.includes('ValidationError')) { errorType = 'VALIDATION_ERROR'; } else if (errorMessage.includes('PermissionDenied')) { errorType = 'PERMISSION_ERROR'; } else if (errorMessage.match(/API Error \(\d+\): ([^-]+)/)) { const match = errorMessage.match(/API Error \(\d+\): ([^-]+)/); if (match) { errorType = match[1].trim(); errorMessage = errorMessage.replace(/API Error \(\d+\): [^-]+ - /, ''); } } addLog?.({ function: 'autoGenerateIdeas', endpoint, request: { method: 'POST', body: requestBody, }, response: { status: 500, error: errorMessage, errorType, }, status: 'error', duration, }); 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 startTime = Date.now(); const { useAIRequestLogsStore } = await import('../store/aiRequestLogsStore').catch(() => ({ useAIRequestLogsStore: null })); const addLog = useAIRequestLogsStore?.getState().addLog; const endpoint = `/v1/planner/ideas/${ideaId}/generate_idea/`; const requestBody = { cluster_id: clusterId }; addLog?.({ function: 'generateSingleIdea', endpoint, request: { method: 'POST', body: requestBody, }, status: 'pending', }); try { const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); const duration = Date.now() - startTime; addLog?.({ function: 'generateSingleIdea', endpoint, request: { method: 'POST', body: requestBody, }, response: { status: 200, data: response, }, status: 'success', duration, }); return response; } catch (error: any) { const duration = Date.now() - startTime; // Parse error message to extract error type let errorType = 'UNKNOWN_ERROR'; let errorMessage = error.message || 'Unknown error'; if (errorMessage.includes('OperationalError')) { errorType = 'DATABASE_ERROR'; errorMessage = errorMessage.replace(/API Error \(\d+\): /, '').replace(/ - .*OperationalError.*/, ' - Database operation failed'); } else if (errorMessage.includes('ValidationError')) { errorType = 'VALIDATION_ERROR'; } else if (errorMessage.includes('PermissionDenied')) { errorType = 'PERMISSION_ERROR'; } else if (errorMessage.match(/API Error \(\d+\): ([^-]+)/)) { const match = errorMessage.match(/API Error \(\d+\): ([^-]+)/); if (match) { errorType = match[1].trim(); errorMessage = errorMessage.replace(/API Error \(\d+\): [^-]+ - /, ''); } } addLog?.({ function: 'generateSingleIdea', endpoint, request: { method: 'POST', body: requestBody, }, response: { status: 500, error: errorMessage, errorType, }, status: 'error', duration, }); 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_structure: string; content_type: string; 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; } export interface ContentIdeaCreateData { idea_title: string; description?: string | null; content_structure?: string; content_type?: 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 // Always add site_id if there's an active site (even for admin/developer) // The backend will respect it appropriately - admin/developer can still see all sites // but if a specific site is selected, filter by it if (!filters.site_id) { const activeSiteId = getActiveSiteId(); if (activeSiteId) { filters.site_id = activeSiteId; } } // Automatically add active sector filter if not explicitly provided // Only add if activeSector is not null (null means "All Sectors") // ADMIN/DEV OVERRIDE: Only inject if user is not admin/developer (handled by backend) if (filters.sector_id === undefined) { const activeSectorId = getActiveSectorId(); // Only add sector_id if it's not null (null means "All Sectors") 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; } 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 // Always add site_id if there's an active site (even for admin/developer) // The backend will respect it appropriately - admin/developer can still see all sites // but if a specific site is selected, filter by it if (!filters.site_id) { const activeSiteId = getActiveSiteId(); if (activeSiteId) { filters.site_id = activeSiteId; } } // Automatically add active sector filter if not explicitly provided // Only add if activeSector is not null (null means "All Sectors") // ADMIN/DEV OVERRIDE: Only inject if user is not admin/developer (handled by backend) if (filters.sector_id === undefined) { const activeSectorId = getActiveSectorId(); // Only add sector_id if it's not null (null means "All Sectors") 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 startTime = Date.now(); const { useAIRequestLogsStore } = await import('../store/aiRequestLogsStore').catch(() => ({ useAIRequestLogsStore: null })); const addLog = useAIRequestLogsStore?.getState().addLog; const endpoint = `/v1/writer/tasks/auto_generate_content/`; const requestBody = { ids }; addLog?.({ function: 'autoGenerateContent', endpoint, request: { method: 'POST', body: requestBody, }, status: 'pending', }); try { const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); const duration = Date.now() - startTime; addLog({ function: 'autoGenerateContent', endpoint, request: { method: 'POST', body: requestBody, }, response: { status: 200, data: response, }, status: 'success', duration, }); return response; } catch (error: any) { const duration = Date.now() - startTime; // Parse error message to extract error type let errorType = 'UNKNOWN_ERROR'; let errorMessage = error.message || 'Unknown error'; if (errorMessage.includes('OperationalError')) { errorType = 'DATABASE_ERROR'; errorMessage = errorMessage.replace(/API Error \(\d+\): /, '').replace(/ - .*OperationalError.*/, ' - Database operation failed'); } else if (errorMessage.includes('ValidationError')) { errorType = 'VALIDATION_ERROR'; } else if (errorMessage.match(/API Error \(\d+\): ([^-]+)/)) { const match = errorMessage.match(/API Error \(\d+\): ([^-]+)/); if (match) { errorType = match[1].trim(); errorMessage = errorMessage.replace(/API Error \(\d+\): [^-]+ - /, ''); } } addLog?.({ function: 'autoGenerateContent', endpoint, request: { method: 'POST', body: requestBody, }, response: { status: 500, error: errorMessage, errorType, }, status: 'error', duration, }); throw error; } } export async function autoGenerateImages(taskIds: number[]): Promise<{ success: boolean; task_id?: string; images_created?: number; message?: string; error?: string }> { const startTime = Date.now(); const { useAIRequestLogsStore } = await import('../store/aiRequestLogsStore').catch(() => ({ useAIRequestLogsStore: null })); const addLog = useAIRequestLogsStore?.getState().addLog; const endpoint = `/v1/writer/tasks/auto_generate_images/`; const requestBody = { task_ids: taskIds }; addLog?.({ function: 'autoGenerateImages', endpoint, request: { method: 'POST', body: requestBody, }, status: 'pending', }); try { const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); const duration = Date.now() - startTime; addLog({ function: 'autoGenerateImages', endpoint, request: { method: 'POST', body: requestBody, }, response: { status: 200, data: response, }, status: 'success', duration, }); return response; } catch (error: any) { const duration = Date.now() - startTime; // Parse error message to extract error type let errorType = 'UNKNOWN_ERROR'; let errorMessage = error.message || 'Unknown error'; if (errorMessage.includes('OperationalError')) { errorType = 'DATABASE_ERROR'; errorMessage = errorMessage.replace(/API Error \(\d+\): /, '').replace(/ - .*OperationalError.*/, ' - Database operation failed'); } else if (errorMessage.includes('ValidationError')) { errorType = 'VALIDATION_ERROR'; } else if (errorMessage.match(/API Error \(\d+\): ([^-]+)/)) { const match = errorMessage.match(/API Error \(\d+\): ([^-]+)/); if (match) { errorType = match[1].trim(); errorMessage = errorMessage.replace(/API Error \(\d+\): [^-]+ - /, ''); } } addLog?.({ function: 'autoGenerateImages', endpoint, request: { method: 'POST', body: requestBody, }, response: { status: 500, error: errorMessage, errorType, }, status: 'error', duration, }); 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); } 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; slug?: string; domain?: string; description?: string; is_active?: boolean; status?: string; wp_url?: string; wp_username?: string; wp_app_password?: 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[]> { return fetchAPI(`/v1/auth/sites/${siteId}/sectors/`); } // Industries API functions export interface Industry { name: string; slug: string; description: string; sectors: Sector[]; } export interface Sector { name: string; slug: string; description: string; } export interface IndustriesResponse { success: boolean; industries: Industry[]; } export async function fetchIndustries(): Promise<IndustriesResponse> { return fetchAPI('/v1/auth/industries/'); } // 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 async function fetchAccountSettings(): Promise<AccountSettingsResponse> { return fetchAPI('/v1/system/settings/account/'); } export async function fetchAccountSetting(key: string): Promise<AccountSetting> { return fetchAPI(`/v1/system/settings/account/${key}/`); } export async function createAccountSetting(data: { key: string; config: Record<string, any>; is_active?: boolean }): Promise<AccountSetting> { return fetchAPI('/v1/system/settings/account/', { method: 'POST', body: JSON.stringify(data), }); } export async function updateAccountSetting(key: string, data: Partial<{ config: Record<string, any>; is_active: boolean }>): Promise<AccountSetting> { return fetchAPI(`/v1/system/settings/account/${key}/`, { method: 'PUT', body: JSON.stringify(data), }); } export async function deleteAccountSetting(key: string): Promise<void> { return fetchAPI(`/v1/system/settings/account/${key}/`, { method: 'DELETE', }); } // Module Settings export interface ModuleSetting { id: number; module_name: string; key: string; config: Record<string, any>; is_active: boolean; created_at: string; updated_at: string; } export async function fetchModuleSettings(moduleName: string): Promise<ModuleSetting[]> { const response = await fetchAPI(`/v1/system/settings/modules/module/${moduleName}/`); return response.results || []; } 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 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> { return fetchAPI('/v1/billing/credits/balance/balance/'); } 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(); 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 { 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; intent: string; intent_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; intent?: 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?.intent) params.append('intent', filters.intent); 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; errors?: string[] }> { return fetchAPI('/v1/planner/keywords/bulk_add_from_seed/', { method: 'POST', body: JSON.stringify({ seed_keyword_ids: seedKeywordIds, site_id: siteId, sector_id: sectorId, }), }); } // 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 Content { id: number; task: number; task_title?: string | null; title?: string | null; meta_title?: string | null; meta_description?: string | null; primary_keyword?: string | null; secondary_keywords?: string[]; tags?: string[]; categories?: string[]; status: string; html_content: string; word_count: number; metadata: Record<string, any>; generated_at: string; updated_at: string; } export interface ContentResponse { count: number; next: string | null; previous: string | null; results: Content[]; } export async function fetchContent(filters?: { task_id?: number; page?: number; }): Promise<ContentResponse> { const params = new URLSearchParams(); if (filters?.task_id) params.append('task_id', filters.task_id.toString()); if (filters?.page) params.append('page', filters.page.toString()); const queryString = params.toString(); return fetchAPI(`/v1/writer/content/${queryString ? `?${queryString}` : ''}`); }