// 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>/);
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;
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 {
keyword?: string; // For creating new custom keywords
volume?: number | null; // For custom keywords
difficulty?: number | null; // For custom keywords
intent?: 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 {}
export async function fetchKeywords(filters: KeywordFilters = {}): Promise {
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('seed_keyword__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 {
return fetchAPI(`/v1/planner/keywords/${id}/`);
}
export async function createKeyword(data: KeywordCreateData): Promise {
// 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_intent = data.intent || 'informational';
// Remove the frontend-only fields
delete requestData.keyword;
delete requestData.volume;
delete requestData.difficulty;
delete requestData.intent;
}
return fetchAPI('/v1/planner/keywords/', {
method: 'POST',
body: JSON.stringify(requestData),
});
}
export async function updateKeyword(id: number, data: KeywordUpdateData): Promise {
return fetchAPI(`/v1/planner/keywords/${id}/`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
export async function deleteKeyword(id: number): Promise {
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 {}
export async function fetchClusters(filters: ClusterFilters = {}): Promise {
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 {
return fetchAPI(`/v1/planner/clusters/${id}/`);
}
export async function createCluster(data: ClusterCreateData): Promise {
return fetchAPI('/v1/planner/clusters/', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function updateCluster(id: number, data: ClusterUpdateData): Promise {
return fetchAPI(`/v1/planner/clusters/${id}/`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
export async function deleteCluster(id: number): Promise {
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 {
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 {}
export async function fetchContentIdeas(filters: ContentIdeasFilters = {}): Promise {
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 {
return fetchAPI(`/v1/planner/ideas/${id}/`);
}
export async function createContentIdea(data: ContentIdeaCreateData): Promise {
return fetchAPI('/v1/planner/ideas/', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function updateContentIdea(id: number, data: ContentIdeaUpdateData): Promise {
return fetchAPI(`/v1/planner/ideas/${id}/`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
export async function deleteContentIdea(id: number): Promise {
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 {}
export async function fetchTasks(filters: TasksFilters = {}): Promise {
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 {
return fetchAPI(`/v1/writer/tasks/${id}/`);
}
export async function createTask(data: TaskCreateData): Promise {
return fetchAPI('/v1/writer/tasks/', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function updateTask(id: number, data: TaskUpdateData): Promise {
return fetchAPI(`/v1/writer/tasks/${id}/`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
export async function deleteTask(id: number): Promise {
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 {
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 {
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 {
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 {
return fetchAPI('/v1/system/integrations/image_generation/');
}
export async function deleteTaskImage(id: number): Promise {
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 {
return fetchAPI('/v1/auth/sites/');
}
export async function fetchSite(id: number): Promise {
return fetchAPI(`/v1/auth/sites/${id}/`);
}
export async function createSite(data: SiteCreateData): Promise {
return fetchAPI('/v1/auth/sites/', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function updateSite(id: number, data: Partial): Promise {
return fetchAPI(`/v1/auth/sites/${id}/`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
export async function deleteSite(id: number): Promise {
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 {
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 {
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;
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 {
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 {
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; is_active?: boolean }): Promise {
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; is_active: boolean }>): Promise {
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 {
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;
created_at: string;
updated_at: string;
}
export interface UserSettingsResponse {
count: number;
next: string | null;
previous: string | null;
results: UserSetting[];
}
export async function fetchUserSettings(): Promise {
return fetchAPI('/v1/system/settings/user/');
}
export async function fetchUserSetting(key: string): Promise {
return fetchAPI(`/v1/system/settings/user/${key}/`);
}
export async function createUserSetting(data: { key: string; value: Record }): Promise {
return fetchAPI('/v1/system/settings/user/', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function updateUserSetting(key: string, data: { value: Record }): Promise {
return fetchAPI(`/v1/system/settings/user/${key}/`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
export async function deleteUserSetting(key: string): Promise {
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;
is_active: boolean;
created_at: string;
updated_at: string;
}
// Deduplicate module-enable fetches to prevent 429s for normal users
let moduleEnableSettingsInFlight: Promise | null = null;
export async function fetchModuleSettings(moduleName: string): Promise {
// 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; is_active?: boolean }): Promise {
return fetchAPI('/v1/system/settings/modules/', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function fetchModuleEnableSettings(): Promise {
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): Promise {
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; is_active: boolean }>): Promise {
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;
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;
by_model: Record;
}
export async function fetchCreditBalance(): Promise {
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 {
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 {
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 {
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;
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 {
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 {
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; 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;
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 {
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): Promise {
return fetchAPI('/v1/system/author-profiles/', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function updateAuthorProfile(id: number, data: Partial): Promise {
return fetchAPI(`/v1/system/author-profiles/${id}/`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
export async function deleteAuthorProfile(id: number): Promise {
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 {
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 {
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 {
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 {
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 {
return fetchAPI(`/v1/writer/content/${id}/unpublish/`, {
method: 'POST',
});
}
export async function deleteContent(id: number): Promise {
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 {
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 {
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 {
return fetchAPI(`/v1/writer/taxonomies/${id}/`);
}
export async function createTaxonomy(data: Partial): Promise {
return fetchAPI('/v1/writer/taxonomies/', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function updateTaxonomy(id: number, data: Partial): Promise {
return fetchAPI(`/v1/writer/taxonomies/${id}/`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
export async function deleteTaxonomy(id: number): Promise {
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;
};
products: {
missing_in_wordpress: Array;
missing_in_igny8: Array;
};
posts: {
missing_in_wordpress: Array;
missing_in_igny8: Array;
};
}
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 {
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 {
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;
errors: string[];
warnings: string[];
};
content_validation: {
ready: boolean;
total_content: number;
valid_content: number;
invalid_content: Array;
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 }),
});
}