2475 lines
78 KiB
TypeScript
2475 lines
78 KiB
TypeScript
// 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)
|
|
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 - 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)
|
|
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 402/403 for plan/limits gracefully by tagging error
|
|
if (response.status === 402 || response.status === 403) {
|
|
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 403 Forbidden with authentication error - clear invalid tokens
|
|
if (response.status === 403) {
|
|
try {
|
|
const errorData = text ? JSON.parse(text) : null;
|
|
// Check if it's an authentication credentials error
|
|
if (errorData?.detail?.includes('Authentication credentials') ||
|
|
errorData?.message?.includes('Authentication credentials') ||
|
|
errorData?.error?.includes('Authentication credentials')) {
|
|
// Only logout if we actually have a token stored (means it's invalid)
|
|
// If no token, it might be a race condition after login - don't logout
|
|
const authState = useAuthStore.getState();
|
|
if (authState?.token || authState?.isAuthenticated) {
|
|
// Token exists but is invalid - clear auth state and force re-login
|
|
const { logout } = useAuthStore.getState();
|
|
logout();
|
|
}
|
|
// Don't throw here - let the error handling below show the error
|
|
}
|
|
} catch (e) {
|
|
// If parsing fails, continue with normal error handling
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
const accessToken = refreshData.data?.access || refreshData.access;
|
|
if (refreshData.success && accessToken) {
|
|
// Update token in store
|
|
try {
|
|
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) {
|
|
// Ignore storage errors
|
|
}
|
|
|
|
// 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 { logout } = useAuthStore.getState();
|
|
logout();
|
|
throw refreshError;
|
|
}
|
|
} else {
|
|
// No refresh token available, clear auth state
|
|
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('<!DOCTYPE html>')) {
|
|
// Extract error title from HTML
|
|
const titleMatch = text.match(/<title>([^<]+) 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
|
|
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 {
|
|
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;
|
|
ideas_count: number;
|
|
content_count: number;
|
|
status: string;
|
|
sector_name?: string | null; // Optional: populated by serializer
|
|
created_at: string;
|
|
}
|
|
|
|
export interface ClusterCreateData {
|
|
name: string;
|
|
description?: string | null;
|
|
status?: string;
|
|
}
|
|
|
|
export interface ClusterUpdateData extends Partial<ClusterCreateData> {}
|
|
|
|
export async function fetchClusters(filters: ClusterFilters = {}): Promise<ClustersResponse> {
|
|
const params = new URLSearchParams();
|
|
|
|
// Automatically add active site filter if not explicitly provided
|
|
// 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 endpoint = `/v1/planner/keywords/auto_cluster/`;
|
|
const requestBody = { ids: keywordIds, sector_id: sectorId };
|
|
|
|
try {
|
|
// fetchAPI extracts data from unified format {success: true, data: {...}}
|
|
// So response is already the data object: {task_id: "...", ...}
|
|
const response = await fetchAPI(endpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
// Wrap extracted data with success: true for frontend compatibility
|
|
if (response && typeof response === 'object') {
|
|
return { success: true, ...response } as any;
|
|
}
|
|
|
|
return { success: true, ...response } as any;
|
|
} catch (error: any) {
|
|
// Error responses are thrown by fetchAPI, but wrap them for consistency
|
|
if (error.response && typeof error.response === 'object') {
|
|
return { success: false, error: error.message, ...error.response } as any;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function autoGenerateIdeas(clusterIds: number[]): Promise<{ success: boolean; task_id?: string; ideas_created?: number; message?: string; error?: string }> {
|
|
const endpoint = `/v1/planner/clusters/auto_generate_ideas/`;
|
|
const requestBody = { ids: clusterIds };
|
|
|
|
try {
|
|
// fetchAPI extracts data from unified format {success: true, data: {...}}
|
|
// So response is already the data object: {task_id: "...", ...}
|
|
const response = await fetchAPI(endpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
// Wrap extracted data with success: true for frontend compatibility
|
|
if (response && typeof response === 'object') {
|
|
return { success: true, ...response } as any;
|
|
}
|
|
|
|
return { success: true, ...response } as any;
|
|
} catch (error: any) {
|
|
// Error responses are thrown by fetchAPI, but wrap them for consistency
|
|
if (error.response && typeof error.response === 'object') {
|
|
return { success: false, error: error.message, ...error.response } as any;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function generateSingleIdea(ideaId: string | number, clusterId: number): Promise<{ success: boolean; task_id?: string; idea_created?: number; message?: string; error?: string }> {
|
|
const endpoint = `/v1/planner/ideas/${ideaId}/generate_idea/`;
|
|
const requestBody = { cluster_id: clusterId };
|
|
|
|
try {
|
|
// fetchAPI extracts data from unified format {success: true, data: {...}}
|
|
// So response is already the data object: {task_id: "...", ...}
|
|
const response = await fetchAPI(endpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
// Wrap extracted data with success: true for frontend compatibility
|
|
if (response && typeof response === 'object') {
|
|
return { success: true, ...response } as any;
|
|
}
|
|
|
|
return { success: true, ...response } as any;
|
|
} catch (error: any) {
|
|
// Error responses are thrown by fetchAPI, but wrap them for consistency
|
|
if (error.response && typeof error.response === 'object') {
|
|
return { success: false, error: error.message, ...error.response } as any;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Helper function to fetch all clusters (for dropdowns)
|
|
export async function fetchAllClusters(): Promise<Cluster[]> {
|
|
const response = await fetchClusters({ ordering: 'name' });
|
|
return response.results;
|
|
}
|
|
|
|
// ContentIdeas API functions
|
|
export interface ContentIdeasFilters {
|
|
search?: string;
|
|
status?: string;
|
|
keyword_cluster_id?: string;
|
|
content_structure?: string;
|
|
content_type?: string;
|
|
page?: number;
|
|
page_size?: number;
|
|
ordering?: string;
|
|
site_id?: number; // Site filter - automatically added from siteStore
|
|
sector_id?: number; // Sector filter - for second-level filtering
|
|
}
|
|
|
|
export interface ContentIdeasResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: ContentIdea[];
|
|
}
|
|
|
|
export interface ContentIdea {
|
|
id: number;
|
|
idea_title: string;
|
|
description?: string | null;
|
|
content_type: string; // post, page, product, taxonomy
|
|
content_structure: string; // article, guide, comparison, review, etc.
|
|
target_keywords?: string | null;
|
|
keyword_cluster_id?: number | null;
|
|
keyword_cluster_name?: string | null;
|
|
sector_name?: string | null; // Optional: populated by serializer
|
|
status: string;
|
|
estimated_word_count: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
// Taxonomy fields
|
|
taxonomy_id?: number | null;
|
|
taxonomy_name?: string | null;
|
|
}
|
|
|
|
export interface ContentIdeaCreateData {
|
|
idea_title: string;
|
|
description?: string | null;
|
|
content_type?: string;
|
|
content_structure?: string;
|
|
target_keywords?: string | null;
|
|
keyword_cluster_id?: number | null;
|
|
status?: string;
|
|
estimated_word_count?: number;
|
|
}
|
|
|
|
export interface ContentIdeaUpdateData extends Partial<ContentIdeaCreateData> {}
|
|
|
|
export async function fetchContentIdeas(filters: ContentIdeasFilters = {}): Promise<ContentIdeasResponse> {
|
|
const params = new URLSearchParams();
|
|
|
|
// Automatically add active site filter if not explicitly provided
|
|
// 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;
|
|
// Taxonomy fields
|
|
taxonomy_term_id?: number | null;
|
|
taxonomy_id?: number | null;
|
|
taxonomy_name?: string | null;
|
|
}
|
|
|
|
export interface TaskCreateData {
|
|
title: string;
|
|
description?: string | null;
|
|
keywords?: string | null;
|
|
cluster_id?: number | null;
|
|
idea_id?: number | null;
|
|
content_structure?: string;
|
|
content_type?: string;
|
|
status?: string;
|
|
content?: string | null;
|
|
word_count?: number;
|
|
meta_title?: string | null;
|
|
meta_description?: string | null;
|
|
}
|
|
|
|
export interface TaskUpdateData extends Partial<TaskCreateData> {}
|
|
|
|
export async function fetchTasks(filters: TasksFilters = {}): Promise<TasksResponse> {
|
|
const params = new URLSearchParams();
|
|
|
|
// Automatically add active site filter if not explicitly provided
|
|
// 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 endpoint = `/v1/writer/tasks/auto_generate_content/`;
|
|
const requestBody = { ids };
|
|
|
|
try {
|
|
// fetchAPI extracts data from unified format {success: true, data: {...}}
|
|
// So response is already the data object: {task_id: "...", ...}
|
|
const response = await fetchAPI(endpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
// Wrap extracted data with success: true for frontend compatibility
|
|
if (response && typeof response === 'object') {
|
|
return { success: true, ...response } as any;
|
|
}
|
|
|
|
return { success: true, ...response } as any;
|
|
} catch (error: any) {
|
|
// Error responses are thrown by fetchAPI, but wrap them for consistency
|
|
if (error.response && typeof error.response === 'object') {
|
|
return { success: false, error: error.message, ...error.response } as any;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function autoGenerateImages(taskIds: number[]): Promise<{ success: boolean; task_id?: string; images_created?: number; message?: string; error?: string }> {
|
|
const endpoint = `/v1/writer/tasks/auto_generate_images/`;
|
|
const requestBody = { task_ids: taskIds };
|
|
|
|
try {
|
|
// fetchAPI extracts data from unified format {success: true, data: {...}}
|
|
// So response is already the data object: {task_id: "...", ...}
|
|
const response = await fetchAPI(endpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
// Wrap extracted data with success: true for frontend compatibility
|
|
if (response && typeof response === 'object') {
|
|
return { success: true, ...response } as any;
|
|
}
|
|
|
|
return { success: true, ...response } as any;
|
|
} catch (error: any) {
|
|
// Error responses are thrown by fetchAPI, but wrap them for consistency
|
|
if (error.response && typeof error.response === 'object') {
|
|
return { success: false, error: error.message, ...error.response } as any;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function generateImagePrompts(contentIds: number[]): Promise<{ success: boolean; task_id?: string; message?: string; error?: string }> {
|
|
try {
|
|
// fetchAPI extracts data from unified format {success: true, data: {...}}
|
|
// So response is already the data object: {task_id: "...", ...}
|
|
const response = await fetchAPI('/v1/writer/content/generate_image_prompts/', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids: contentIds }),
|
|
});
|
|
|
|
// Wrap extracted data with success: true for frontend compatibility
|
|
if (response && typeof response === 'object') {
|
|
return { success: true, ...response } as any;
|
|
}
|
|
|
|
return { success: true, ...response } as any;
|
|
} catch (error: any) {
|
|
// Error responses are thrown by fetchAPI, but wrap them for consistency
|
|
if (error.response && typeof error.response === 'object') {
|
|
return { success: false, error: error.message, ...error.response } as any;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// TaskImages API functions
|
|
export interface TaskImage {
|
|
id: number;
|
|
task_id: number;
|
|
task_title?: string | null;
|
|
image_type: string;
|
|
image_url?: string | null;
|
|
image_path?: string | null;
|
|
prompt?: string | null;
|
|
status: string;
|
|
position: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface TaskImageFilters {
|
|
task_id?: string;
|
|
image_type?: string;
|
|
status?: string;
|
|
page?: number;
|
|
ordering?: string;
|
|
}
|
|
|
|
export interface TaskImagesResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: TaskImage[];
|
|
}
|
|
|
|
export async function fetchTaskImages(filters: TaskImageFilters = {}): Promise<TaskImagesResponse> {
|
|
const params = new URLSearchParams();
|
|
if (filters.task_id) params.append('task_id', filters.task_id);
|
|
if (filters.image_type) params.append('image_type', filters.image_type);
|
|
if (filters.status) params.append('status', filters.status);
|
|
if (filters.page) params.append('page', filters.page.toString());
|
|
if (filters.ordering) params.append('ordering', filters.ordering || 'task,position,-created_at');
|
|
|
|
const queryString = params.toString();
|
|
const endpoint = `/v1/writer/images/${queryString ? `?${queryString}` : ''}`;
|
|
return fetchAPI(endpoint);
|
|
}
|
|
|
|
// Content Images (grouped by content)
|
|
export interface ContentImage {
|
|
id: number;
|
|
image_type: string;
|
|
image_url?: string | null;
|
|
image_path?: string | null;
|
|
prompt?: string | null;
|
|
status: string;
|
|
position: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface ContentImagesGroup {
|
|
content_id: number;
|
|
content_title: string;
|
|
content_status: 'draft' | 'review' | 'publish';
|
|
featured_image: ContentImage | null;
|
|
in_article_images: ContentImage[];
|
|
overall_status: 'pending' | 'partial' | 'complete' | 'failed';
|
|
}
|
|
|
|
export interface ContentImagesResponse {
|
|
count: number;
|
|
results: ContentImagesGroup[];
|
|
}
|
|
|
|
export interface ImageRecord {
|
|
id: number;
|
|
task_id?: number | null;
|
|
task_title?: string | null;
|
|
content_id?: number | null;
|
|
content_title?: string | null;
|
|
image_type: string;
|
|
image_url?: string | null;
|
|
image_path?: string | null;
|
|
prompt?: string | null;
|
|
status: string;
|
|
position: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
account_id?: number | null;
|
|
}
|
|
|
|
export interface ImageListResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: ImageRecord[];
|
|
}
|
|
|
|
export interface ImageFilters {
|
|
content_id?: number;
|
|
task_id?: number;
|
|
image_type?: string;
|
|
status?: string;
|
|
ordering?: string;
|
|
page?: number;
|
|
page_size?: number;
|
|
}
|
|
|
|
export interface ContentImagesFilters {
|
|
site_id?: number;
|
|
sector_id?: number;
|
|
}
|
|
|
|
export async function fetchContentImages(filters: ContentImagesFilters = {}): Promise<ContentImagesResponse> {
|
|
const params = new URLSearchParams();
|
|
|
|
// Automatically add active site filter if not explicitly provided
|
|
if (!filters.site_id) {
|
|
const activeSiteId = getActiveSiteId();
|
|
if (activeSiteId) {
|
|
filters.site_id = activeSiteId;
|
|
}
|
|
}
|
|
|
|
// Automatically add active sector filter if not explicitly provided
|
|
if (filters.sector_id === undefined) {
|
|
const activeSectorId = getActiveSectorId();
|
|
if (activeSectorId !== null && activeSectorId !== undefined) {
|
|
filters.sector_id = activeSectorId;
|
|
}
|
|
}
|
|
|
|
if (filters.site_id) params.append('site_id', filters.site_id.toString());
|
|
if (filters.sector_id) params.append('sector_id', filters.sector_id.toString());
|
|
|
|
const queryString = params.toString();
|
|
// fetchAPI automatically extracts data field from unified format
|
|
return fetchAPI(`/v1/writer/images/content_images/${queryString ? `?${queryString}` : ''}`);
|
|
}
|
|
|
|
export async function bulkUpdateImagesStatus(contentId: number, status: string): Promise<{ updated_count: number }> {
|
|
return fetchAPI(`/v1/writer/images/bulk_update/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ content_id: contentId, status }),
|
|
});
|
|
}
|
|
|
|
export async function generateImages(imageIds: number[], contentId?: number): Promise<{ success: boolean; task_id?: string; message?: string; error?: string }> {
|
|
try {
|
|
// fetchAPI extracts data from unified format {success: true, data: {...}}
|
|
// So response is already the data object: {task_id: "...", ...}
|
|
const response = await fetchAPI('/v1/writer/images/generate_images/', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
ids: imageIds,
|
|
content_id: contentId
|
|
}),
|
|
});
|
|
|
|
// Wrap extracted data with success: true for frontend compatibility
|
|
if (response && typeof response === 'object') {
|
|
return { success: true, ...response } as any;
|
|
}
|
|
|
|
return { success: true, ...response } as any;
|
|
} catch (error: any) {
|
|
// Error responses are thrown by fetchAPI, but wrap them for consistency
|
|
if (error.response && typeof error.response === 'object') {
|
|
return { success: false, error: error.message, ...error.response } as any;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function fetchImages(filters: ImageFilters = {}): Promise<ImageListResponse> {
|
|
const params = new URLSearchParams();
|
|
if (filters.content_id) params.append('content_id', filters.content_id.toString());
|
|
if (filters.task_id) params.append('task_id', filters.task_id.toString());
|
|
if (filters.image_type) params.append('image_type', filters.image_type);
|
|
if (filters.status) params.append('status', filters.status);
|
|
if (filters.ordering) params.append('ordering', filters.ordering);
|
|
if (filters.page) params.append('page', filters.page.toString());
|
|
if (filters.page_size) params.append('page_size', filters.page_size.toString());
|
|
|
|
const queryString = params.toString();
|
|
return fetchAPI(`/v1/writer/images/${queryString ? `?${queryString}` : ''}`);
|
|
}
|
|
|
|
export interface ImageGenerationSettings {
|
|
success: boolean;
|
|
config: {
|
|
provider: string;
|
|
model: string;
|
|
image_type: string;
|
|
max_in_article_images: number;
|
|
image_format: string;
|
|
desktop_enabled: boolean;
|
|
mobile_enabled: boolean;
|
|
};
|
|
}
|
|
|
|
export async function fetchImageGenerationSettings(): Promise<ImageGenerationSettings> {
|
|
return fetchAPI('/v1/system/integrations/image_generation/');
|
|
}
|
|
|
|
export async function deleteTaskImage(id: number): Promise<void> {
|
|
return fetchAPI(`/v1/writer/images/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
// Sites API functions
|
|
export interface Site {
|
|
id: number;
|
|
name: string;
|
|
slug: string;
|
|
domain?: string | null;
|
|
description?: string | null;
|
|
industry?: number | null;
|
|
industry_name?: string | null;
|
|
industry_slug?: string | null;
|
|
is_active: boolean;
|
|
status: string;
|
|
wp_url?: string | null;
|
|
wp_username?: string | null;
|
|
sectors_count: number;
|
|
active_sectors_count: number;
|
|
selected_sectors: number[];
|
|
can_add_sectors: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface SiteCreateData {
|
|
name: string;
|
|
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[]> {
|
|
const response = await fetchAPI(`/v1/auth/sites/${siteId}/sectors/`);
|
|
// fetchAPI automatically extracts data field from unified format
|
|
return Array.isArray(response) ? response : [];
|
|
}
|
|
|
|
// Industries API functions
|
|
export interface Industry {
|
|
id?: number;
|
|
name: string;
|
|
slug: string;
|
|
description: string;
|
|
sectors: Sector[];
|
|
sectors_count?: number;
|
|
keywords_count?: number;
|
|
is_active?: boolean;
|
|
}
|
|
|
|
export interface Sector {
|
|
name: string;
|
|
slug: string;
|
|
description: string;
|
|
}
|
|
|
|
export interface IndustriesResponse {
|
|
success: boolean;
|
|
industries: Industry[];
|
|
}
|
|
|
|
export async function fetchIndustries(): Promise<IndustriesResponse> {
|
|
const response = await fetchAPI('/v1/auth/industries/');
|
|
// fetchAPI automatically extracts data field, but industries endpoint returns {industries: [...]}
|
|
// So we need to handle the nested structure
|
|
if (response && typeof response === 'object' && 'industries' in response) {
|
|
return {
|
|
success: true,
|
|
industries: response.industries || []
|
|
};
|
|
}
|
|
// If response is already an array or different format
|
|
return {
|
|
success: true,
|
|
industries: Array.isArray(response) ? response : []
|
|
};
|
|
}
|
|
|
|
// Sectors API functions
|
|
|
|
// Settings API functions
|
|
export interface AccountSetting {
|
|
id: number;
|
|
key: string;
|
|
config: Record<string, any>;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface AccountSettingsResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: AccountSetting[];
|
|
}
|
|
|
|
export type AccountSettingsErrorType =
|
|
| 'ACCOUNT_SETTINGS_API_ERROR'
|
|
| 'ACCOUNT_SETTINGS_NOT_FOUND'
|
|
| 'ACCOUNT_SETTINGS_VALIDATION_ERROR';
|
|
|
|
export class AccountSettingsError extends Error {
|
|
type: AccountSettingsErrorType;
|
|
status?: number;
|
|
details?: unknown;
|
|
|
|
constructor(type: AccountSettingsErrorType, message: string, status?: number, details?: unknown) {
|
|
super(message);
|
|
this.name = 'AccountSettingsError';
|
|
this.type = type;
|
|
this.status = status;
|
|
this.details = details;
|
|
}
|
|
}
|
|
|
|
function buildAccountSettingsError(error: any, fallbackMessage: string): AccountSettingsError {
|
|
const status = error?.status;
|
|
const response = error?.response;
|
|
const details = response || error;
|
|
|
|
if (status === 404) {
|
|
return new AccountSettingsError(
|
|
'ACCOUNT_SETTINGS_NOT_FOUND',
|
|
'No account settings were found for this account yet.',
|
|
status,
|
|
details
|
|
);
|
|
}
|
|
|
|
if (status === 400 || response?.errors) {
|
|
const validationMessage =
|
|
response?.error ||
|
|
response?.message ||
|
|
response?.detail ||
|
|
'The account settings request is invalid. Please review the submitted data.';
|
|
|
|
return new AccountSettingsError(
|
|
'ACCOUNT_SETTINGS_VALIDATION_ERROR',
|
|
validationMessage,
|
|
status,
|
|
details
|
|
);
|
|
}
|
|
|
|
return new AccountSettingsError(
|
|
'ACCOUNT_SETTINGS_API_ERROR',
|
|
error?.message || fallbackMessage,
|
|
status,
|
|
details
|
|
);
|
|
}
|
|
|
|
export async function fetchAccountSettings(): Promise<AccountSettingsResponse> {
|
|
try {
|
|
return await fetchAPI('/v1/system/settings/account/');
|
|
} catch (error: any) {
|
|
throw buildAccountSettingsError(error, 'Unable to load account settings right now.');
|
|
}
|
|
}
|
|
|
|
export async function fetchAccountSetting(key: string): Promise<AccountSetting> {
|
|
try {
|
|
return await fetchAPI(`/v1/system/settings/account/${key}/`);
|
|
} catch (error: any) {
|
|
throw buildAccountSettingsError(error, `Account setting "${key}" is not available.`);
|
|
}
|
|
}
|
|
|
|
export async function createAccountSetting(data: { key: string; config: Record<string, any>; is_active?: boolean }): Promise<AccountSetting> {
|
|
try {
|
|
return await fetchAPI('/v1/system/settings/account/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
} catch (error: any) {
|
|
throw buildAccountSettingsError(error, 'Unable to create the account setting.');
|
|
}
|
|
}
|
|
|
|
export async function updateAccountSetting(key: string, data: Partial<{ config: Record<string, any>; is_active: boolean }>): Promise<AccountSetting> {
|
|
try {
|
|
return await fetchAPI(`/v1/system/settings/account/${key}/`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
} catch (error: any) {
|
|
throw buildAccountSettingsError(error, `Unable to update account setting "${key}".`);
|
|
}
|
|
}
|
|
|
|
export async function deleteAccountSetting(key: string): Promise<void> {
|
|
try {
|
|
await fetchAPI(`/v1/system/settings/account/${key}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
} catch (error: any) {
|
|
throw buildAccountSettingsError(error, `Unable to delete account setting "${key}".`);
|
|
}
|
|
}
|
|
|
|
// User Settings API functions
|
|
export interface UserSetting {
|
|
id: number;
|
|
key: string;
|
|
value: Record<string, any>;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface UserSettingsResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: UserSetting[];
|
|
}
|
|
|
|
export async function fetchUserSettings(): Promise<UserSettingsResponse> {
|
|
return fetchAPI('/v1/system/settings/user/');
|
|
}
|
|
|
|
export async function fetchUserSetting(key: string): Promise<UserSetting> {
|
|
return fetchAPI(`/v1/system/settings/user/${key}/`);
|
|
}
|
|
|
|
export async function createUserSetting(data: { key: string; value: Record<string, any> }): Promise<UserSetting> {
|
|
return fetchAPI('/v1/system/settings/user/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function updateUserSetting(key: string, data: { value: Record<string, any> }): Promise<UserSetting> {
|
|
return fetchAPI(`/v1/system/settings/user/${key}/`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function deleteUserSetting(key: string): Promise<void> {
|
|
await fetchAPI(`/v1/system/settings/user/${key}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
// Module Settings
|
|
export interface ModuleEnableSettings {
|
|
id: number;
|
|
planner_enabled: boolean;
|
|
writer_enabled: boolean;
|
|
thinker_enabled: boolean;
|
|
automation_enabled: boolean;
|
|
site_builder_enabled: boolean;
|
|
linker_enabled: boolean;
|
|
optimizer_enabled: boolean;
|
|
publisher_enabled: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface ModuleSetting {
|
|
id: number;
|
|
module_name: string;
|
|
key: string;
|
|
config: Record<string, any>;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
// Deduplicate module-enable fetches to prevent 429s for normal users
|
|
let moduleEnableSettingsInFlight: Promise<ModuleEnableSettings> | null = null;
|
|
|
|
export async function fetchModuleSettings(moduleName: string): Promise<ModuleSetting[]> {
|
|
// fetchAPI extracts data from unified format {success: true, data: [...]}
|
|
// So response IS the array, not an object with results
|
|
const response = await fetchAPI(`/v1/system/settings/modules/module/${moduleName}/`);
|
|
return Array.isArray(response) ? response : [];
|
|
}
|
|
|
|
export async function createModuleSetting(data: { module_name: string; key: string; config: Record<string, any>; is_active?: boolean }): Promise<ModuleSetting> {
|
|
return fetchAPI('/v1/system/settings/modules/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function fetchModuleEnableSettings(): Promise<ModuleEnableSettings> {
|
|
if (moduleEnableSettingsInFlight) {
|
|
return moduleEnableSettingsInFlight;
|
|
}
|
|
|
|
moduleEnableSettingsInFlight = fetchAPI('/v1/system/settings/modules/enable/');
|
|
try {
|
|
const response = await moduleEnableSettingsInFlight;
|
|
return response;
|
|
} finally {
|
|
moduleEnableSettingsInFlight = null;
|
|
}
|
|
}
|
|
|
|
export async function updateModuleEnableSettings(data: Partial<ModuleEnableSettings>): Promise<ModuleEnableSettings> {
|
|
const response = await fetchAPI('/v1/system/settings/modules/enable/', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
return response;
|
|
}
|
|
|
|
export async function updateModuleSetting(moduleName: string, key: string, data: Partial<{ config: Record<string, any>; is_active: boolean }>): Promise<ModuleSetting> {
|
|
return fetchAPI(`/v1/system/settings/modules/${key}/?module_name=${moduleName}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
// Billing API functions
|
|
export interface CreditBalance {
|
|
credits: number;
|
|
plan_credits_per_month: number;
|
|
credits_used_this_month: number;
|
|
credits_remaining: number;
|
|
}
|
|
|
|
export interface CreditUsageLog {
|
|
id: number;
|
|
operation_type: string;
|
|
operation_type_display: string;
|
|
credits_used: number;
|
|
cost_usd: string | null;
|
|
model_used: string;
|
|
tokens_input: number | null;
|
|
tokens_output: number | null;
|
|
related_object_type: string;
|
|
related_object_id: number | null;
|
|
metadata: Record<string, any>;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface CreditUsageResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: CreditUsageLog[];
|
|
}
|
|
|
|
export interface UsageSummary {
|
|
period: {
|
|
start: string;
|
|
end: string;
|
|
};
|
|
total_credits_used: number;
|
|
total_cost_usd: number;
|
|
by_operation: Record<string, {
|
|
credits: number;
|
|
cost: number;
|
|
count: number;
|
|
}>;
|
|
by_model: Record<string, {
|
|
credits: number;
|
|
cost: number;
|
|
}>;
|
|
}
|
|
|
|
export async function fetchCreditBalance(): Promise<CreditBalance> {
|
|
try {
|
|
// Canonical balance endpoint (business billing CreditTransactionViewSet.balance)
|
|
const response = await fetchAPI('/v1/billing/transactions/balance/');
|
|
if (response && typeof response === 'object' && 'credits' in response) {
|
|
return response as CreditBalance;
|
|
}
|
|
// Default if response is invalid
|
|
return {
|
|
credits: 0,
|
|
plan_credits_per_month: 0,
|
|
credits_used_this_month: 0,
|
|
credits_remaining: 0,
|
|
};
|
|
} catch (error: any) {
|
|
console.debug('Failed to fetch credit balance, using defaults:', error?.message || error);
|
|
// Return default balance on error so UI can still render
|
|
return {
|
|
credits: 0,
|
|
plan_credits_per_month: 0,
|
|
credits_used_this_month: 0,
|
|
credits_remaining: 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function fetchCreditUsage(filters?: {
|
|
operation_type?: string;
|
|
start_date?: string;
|
|
end_date?: string;
|
|
page?: number;
|
|
}): Promise<CreditUsageResponse> {
|
|
const params = new URLSearchParams();
|
|
if (filters?.operation_type) params.append('operation_type', filters.operation_type);
|
|
if (filters?.start_date) params.append('start_date', filters.start_date);
|
|
if (filters?.end_date) params.append('end_date', filters.end_date);
|
|
if (filters?.page) params.append('page', filters.page.toString());
|
|
|
|
const queryString = params.toString();
|
|
return fetchAPI(`/v1/billing/credits/usage/${queryString ? `?${queryString}` : ''}`);
|
|
}
|
|
|
|
export async function fetchUsageSummary(startDate?: string, endDate?: string): Promise<UsageSummary> {
|
|
const params = new URLSearchParams();
|
|
if (startDate) params.append('start_date', startDate);
|
|
if (endDate) params.append('end_date', endDate);
|
|
|
|
const queryString = params.toString();
|
|
// fetchAPI automatically extracts data field from unified format
|
|
return fetchAPI(`/v1/billing/credits/usage/summary/${queryString ? `?${queryString}` : ''}`);
|
|
}
|
|
|
|
export interface LimitCard {
|
|
title: string;
|
|
limit: number;
|
|
used: number;
|
|
available: number;
|
|
unit: string;
|
|
category: 'planner' | 'writer' | 'images' | 'ai' | 'general';
|
|
percentage: number;
|
|
}
|
|
|
|
export interface UsageLimitsResponse {
|
|
limits: LimitCard[];
|
|
}
|
|
|
|
export async function fetchUsageLimits(): Promise<UsageLimitsResponse> {
|
|
console.log('Fetching usage limits from:', '/v1/billing/credits/usage/limits/');
|
|
try {
|
|
// fetchAPI extracts data from unified format {success: true, data: { limits: [...] }}
|
|
// So response IS the data object
|
|
const response = await fetchAPI('/v1/billing/credits/usage/limits/');
|
|
console.log('Usage limits API response:', response);
|
|
return response;
|
|
} catch (error) {
|
|
console.error('Error fetching usage limits:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export interface CreditTransaction {
|
|
id: number;
|
|
transaction_type: string;
|
|
transaction_type_display: string;
|
|
amount: number;
|
|
balance_after: number;
|
|
description: string;
|
|
metadata: Record<string, any>;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface CreditTransactionResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: CreditTransaction[];
|
|
}
|
|
|
|
export async function fetchCreditTransactions(filters?: {
|
|
transaction_type?: string;
|
|
page?: number;
|
|
}): Promise<CreditTransactionResponse> {
|
|
const params = new URLSearchParams();
|
|
if (filters?.transaction_type) params.append('transaction_type', filters.transaction_type);
|
|
if (filters?.page) params.append('page', filters.page.toString());
|
|
|
|
const queryString = params.toString();
|
|
return fetchAPI(`/v1/billing/credits/transactions/${queryString ? `?${queryString}` : ''}`);
|
|
}
|
|
|
|
// Seed Keywords API
|
|
export interface SeedKeyword {
|
|
id: number;
|
|
keyword: string;
|
|
industry: number;
|
|
industry_name: string;
|
|
industry_slug: string;
|
|
sector: number;
|
|
sector_name: string;
|
|
sector_slug: string;
|
|
volume: number;
|
|
difficulty: number;
|
|
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; 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, but wrap them for consistency
|
|
if (error.response && typeof error.response === 'object') {
|
|
return { success: false, error: error.message, ...error.response } as any;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Author Profiles API
|
|
export interface AuthorProfile {
|
|
id: number;
|
|
name: string;
|
|
description: string;
|
|
tone: string;
|
|
language: string;
|
|
structure_template: Record<string, any>;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface AuthorProfileResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: AuthorProfile[];
|
|
}
|
|
|
|
export async function fetchAuthorProfiles(filters?: {
|
|
is_active?: boolean;
|
|
language?: string;
|
|
search?: string;
|
|
page?: number;
|
|
}): Promise<AuthorProfileResponse> {
|
|
const params = new URLSearchParams();
|
|
if (filters?.is_active !== undefined) params.append('is_active', filters.is_active.toString());
|
|
if (filters?.language) params.append('language', filters.language);
|
|
if (filters?.search) params.append('search', filters.search);
|
|
if (filters?.page) params.append('page', filters.page.toString());
|
|
|
|
const queryString = params.toString();
|
|
return fetchAPI(`/v1/system/author-profiles/${queryString ? `?${queryString}` : ''}`);
|
|
}
|
|
|
|
export async function createAuthorProfile(data: Partial<AuthorProfile>): Promise<AuthorProfile> {
|
|
return fetchAPI('/v1/system/author-profiles/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function updateAuthorProfile(id: number, data: Partial<AuthorProfile>): Promise<AuthorProfile> {
|
|
return fetchAPI(`/v1/system/author-profiles/${id}/`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function deleteAuthorProfile(id: number): Promise<void> {
|
|
return fetchAPI(`/v1/system/author-profiles/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
// Content API
|
|
export interface ContentFilters {
|
|
search?: string;
|
|
status?: string;
|
|
content_type?: string;
|
|
content_structure?: string;
|
|
source?: string;
|
|
cluster_id?: number;
|
|
page?: number;
|
|
page_size?: number;
|
|
ordering?: string;
|
|
site_id?: number;
|
|
sector_id?: number;
|
|
}
|
|
|
|
export interface Content {
|
|
id: number;
|
|
// Core fields
|
|
title: string;
|
|
content_html: string;
|
|
content_type: string;
|
|
content_structure: string;
|
|
status: 'draft' | 'published';
|
|
source: 'igny8' | 'wordpress';
|
|
// Relations
|
|
cluster_id: number;
|
|
cluster_name?: string | null;
|
|
sector_name?: string | null;
|
|
taxonomy_terms?: Array<{
|
|
id: number;
|
|
name: string;
|
|
taxonomy_type: string;
|
|
}>;
|
|
taxonomy_terms_data?: Array<{
|
|
id: number;
|
|
name: string;
|
|
taxonomy_type: string;
|
|
}>;
|
|
tags?: string[];
|
|
categories?: string[];
|
|
// WordPress integration
|
|
external_id?: string | null;
|
|
external_url?: string | null;
|
|
wordpress_status?: 'publish' | 'draft' | 'pending' | 'future' | 'private' | 'trash' | null;
|
|
// Timestamps
|
|
created_at: string;
|
|
updated_at: string;
|
|
// Image support
|
|
has_image_prompts?: boolean;
|
|
has_generated_images?: boolean;
|
|
// Additional fields used in Linker/Optimizer
|
|
internal_links?: Array<{ anchor_text: string; target_content_id: number }>;
|
|
linker_version?: number;
|
|
optimization_scores?: {
|
|
seo_score: number;
|
|
readability_score: number;
|
|
engagement_score: number;
|
|
overall_score: number;
|
|
};
|
|
}
|
|
|
|
export interface ContentResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: Content[];
|
|
}
|
|
|
|
export async function fetchContent(filters: ContentFilters = {}): Promise<ContentResponse> {
|
|
const params = new URLSearchParams();
|
|
|
|
// Automatically add active site filter if not explicitly provided
|
|
if (!filters.site_id) {
|
|
const activeSiteId = getActiveSiteId();
|
|
if (activeSiteId) {
|
|
filters.site_id = activeSiteId;
|
|
}
|
|
}
|
|
|
|
// Automatically add active sector filter if not explicitly provided
|
|
if (filters.sector_id === undefined) {
|
|
const activeSectorId = getActiveSectorId();
|
|
if (activeSectorId !== null && activeSectorId !== undefined) {
|
|
filters.sector_id = activeSectorId;
|
|
}
|
|
}
|
|
|
|
if (filters.search) params.append('search', filters.search);
|
|
if (filters.status) params.append('status', filters.status);
|
|
if (filters.task_id) params.append('task_id', filters.task_id.toString());
|
|
if (filters.site_id) params.append('site_id', filters.site_id.toString());
|
|
if (filters.sector_id) params.append('sector_id', filters.sector_id.toString());
|
|
if (filters.page) params.append('page', filters.page.toString());
|
|
if (filters.page_size !== undefined && filters.page_size !== null) {
|
|
params.append('page_size', filters.page_size.toString());
|
|
}
|
|
if (filters.ordering) params.append('ordering', filters.ordering);
|
|
|
|
const queryString = params.toString();
|
|
return fetchAPI(`/v1/writer/content/${queryString ? `?${queryString}` : ''}`);
|
|
}
|
|
|
|
export async function fetchContentById(id: number): Promise<Content> {
|
|
return fetchAPI(`/v1/writer/content/${id}/`);
|
|
}
|
|
|
|
// Fetch WordPress status for published content
|
|
export interface WordPressStatusResult {
|
|
wordpress_status: 'publish' | 'draft' | 'pending' | 'future' | 'private' | 'trash' | null;
|
|
external_id: string | null;
|
|
external_url: string | null;
|
|
post_title?: string;
|
|
post_modified?: string;
|
|
last_checked?: string;
|
|
}
|
|
|
|
export async function fetchWordPressStatus(contentId: number): Promise<WordPressStatusResult> {
|
|
try {
|
|
const response = await fetchAPI(`/v1/writer/content/${contentId}/wordpress_status/`);
|
|
return response.data || response;
|
|
} catch (error) {
|
|
console.warn(`Failed to fetch WordPress status for content ${contentId}:`, error);
|
|
return {
|
|
wordpress_status: null,
|
|
external_id: null,
|
|
external_url: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Content Publishing API
|
|
export interface PublishContentResult {
|
|
content_id: number;
|
|
status: string;
|
|
external_id: string;
|
|
external_url: string;
|
|
message?: string;
|
|
}
|
|
|
|
export interface UnpublishContentResult {
|
|
content_id: number;
|
|
status: string;
|
|
message?: string;
|
|
}
|
|
|
|
export async function publishContent(id: number, site_id?: number): Promise<PublishContentResult> {
|
|
const body: { site_id?: number } = {};
|
|
if (site_id !== undefined) {
|
|
body.site_id = site_id;
|
|
}
|
|
return fetchAPI(`/v1/writer/content/${id}/publish/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
export async function unpublishContent(id: number): Promise<UnpublishContentResult> {
|
|
return fetchAPI(`/v1/writer/content/${id}/unpublish/`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
export async function deleteContent(id: number): Promise<void> {
|
|
return fetchAPI(`/v1/writer/content/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
export async function bulkDeleteContent(ids: number[]): Promise<{ deleted_count: number }> {
|
|
return fetchAPI(`/v1/writer/content/bulk_delete/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids }),
|
|
});
|
|
}
|
|
|
|
// Stage 3: Content Validation API
|
|
export interface ContentValidationResult {
|
|
content_id: number;
|
|
is_valid: boolean;
|
|
ready_to_publish: boolean;
|
|
validation_errors: Array<{
|
|
field: string;
|
|
code: string;
|
|
message: string;
|
|
}>;
|
|
publish_errors: Array<{
|
|
field: string;
|
|
code: string;
|
|
message: string;
|
|
}>;
|
|
metadata: {
|
|
has_content_type: boolean;
|
|
content_type: string | null;
|
|
has_cluster_mapping: boolean;
|
|
has_taxonomy_mapping: boolean;
|
|
};
|
|
}
|
|
|
|
export async function fetchContentValidation(id: number): Promise<ContentValidationResult> {
|
|
return fetchAPI(`/v1/writer/content/${id}/validation/`);
|
|
}
|
|
|
|
export async function validateContent(id: number): Promise<{
|
|
content_id: number;
|
|
is_valid: boolean;
|
|
errors: Array<{
|
|
field: string;
|
|
code: string;
|
|
message: string;
|
|
}>;
|
|
}> {
|
|
return fetchAPI(`/v1/writer/content/${id}/validate/`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
// Content Taxonomy API
|
|
export interface ContentTaxonomy {
|
|
id: number;
|
|
name: string;
|
|
slug: string;
|
|
taxonomy_type: 'category' | 'tag' | 'product_category' | 'product_tag' | 'product_attribute' | 'cluster';
|
|
external_taxonomy?: string | null;
|
|
external_id?: string | null;
|
|
parent_id?: number | null;
|
|
description?: string | null;
|
|
count?: number;
|
|
site_id: number;
|
|
sector_id?: number | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface ContentTaxonomyFilters {
|
|
taxonomy_type?: string;
|
|
search?: string;
|
|
site_id?: number;
|
|
sector_id?: number;
|
|
page?: number;
|
|
page_size?: number;
|
|
ordering?: string;
|
|
}
|
|
|
|
export interface ContentTaxonomyResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: ContentTaxonomy[];
|
|
}
|
|
|
|
export async function fetchTaxonomies(filters: ContentTaxonomyFilters = {}): Promise<ContentTaxonomyResponse> {
|
|
const params = new URLSearchParams();
|
|
|
|
// Automatically add active site filter if not explicitly provided
|
|
if (!filters.site_id) {
|
|
const activeSiteId = getActiveSiteId();
|
|
if (activeSiteId) {
|
|
filters.site_id = activeSiteId;
|
|
}
|
|
}
|
|
|
|
// Automatically add active sector filter if not explicitly provided
|
|
if (filters.sector_id === undefined) {
|
|
const activeSectorId = getActiveSectorId();
|
|
if (activeSectorId !== null && activeSectorId !== undefined) {
|
|
filters.sector_id = activeSectorId;
|
|
}
|
|
}
|
|
|
|
if (filters.search) params.append('search', filters.search);
|
|
if (filters.taxonomy_type) params.append('taxonomy_type', filters.taxonomy_type);
|
|
if (filters.site_id) params.append('site_id', filters.site_id.toString());
|
|
if (filters.sector_id) params.append('sector_id', filters.sector_id.toString());
|
|
if (filters.page) params.append('page', filters.page.toString());
|
|
if (filters.page_size !== undefined && filters.page_size !== null) {
|
|
params.append('page_size', filters.page_size.toString());
|
|
}
|
|
if (filters.ordering) params.append('ordering', filters.ordering);
|
|
|
|
const queryString = params.toString();
|
|
return fetchAPI(`/v1/writer/taxonomies/${queryString ? `?${queryString}` : ''}`);
|
|
}
|
|
|
|
export async function fetchTaxonomyById(id: number): Promise<ContentTaxonomy> {
|
|
return fetchAPI(`/v1/writer/taxonomies/${id}/`);
|
|
}
|
|
|
|
export async function createTaxonomy(data: Partial<ContentTaxonomy>): Promise<ContentTaxonomy> {
|
|
return fetchAPI('/v1/writer/taxonomies/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function updateTaxonomy(id: number, data: Partial<ContentTaxonomy>): Promise<ContentTaxonomy> {
|
|
return fetchAPI(`/v1/writer/taxonomies/${id}/`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function deleteTaxonomy(id: number): Promise<void> {
|
|
return fetchAPI(`/v1/writer/taxonomies/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
// Legacy: Site Builder API removed
|
|
// SiteBlueprint, PageBlueprint, and related functions deprecated
|
|
|
|
// Stage 4: Sync Health API
|
|
export interface SyncStatus {
|
|
site_id: number;
|
|
integrations: Array<{
|
|
id: number;
|
|
platform: string;
|
|
status: string;
|
|
last_sync_at: string | null;
|
|
sync_enabled: boolean;
|
|
is_healthy: boolean;
|
|
error: string | null;
|
|
mismatch_count: number;
|
|
}>;
|
|
overall_status: 'healthy' | 'warning' | 'error';
|
|
last_sync_at: string | null;
|
|
}
|
|
|
|
export interface SyncMismatches {
|
|
taxonomies: {
|
|
missing_in_wordpress: Array<{
|
|
id: number;
|
|
name: string;
|
|
type: string;
|
|
external_reference?: string;
|
|
}>;
|
|
missing_in_igny8: Array<{
|
|
name: string;
|
|
slug: string;
|
|
type: string;
|
|
external_reference: string;
|
|
}>;
|
|
mismatched: Array<any>;
|
|
};
|
|
products: {
|
|
missing_in_wordpress: Array<any>;
|
|
missing_in_igny8: Array<any>;
|
|
};
|
|
posts: {
|
|
missing_in_wordpress: Array<any>;
|
|
missing_in_igny8: Array<any>;
|
|
};
|
|
}
|
|
|
|
export interface SyncLog {
|
|
integration_id: number;
|
|
platform: string;
|
|
timestamp: string;
|
|
status: string;
|
|
error: string | null;
|
|
duration: number | null;
|
|
items_processed: number | null;
|
|
}
|
|
|
|
export async function fetchSyncStatus(siteId: number): Promise<SyncStatus> {
|
|
return fetchAPI(`/v1/integration/integrations/sites/${siteId}/sync/status/`);
|
|
}
|
|
|
|
export async function runSync(
|
|
siteId: number,
|
|
direction: 'both' | 'to_external' | 'from_external' = 'both',
|
|
contentTypes?: string[]
|
|
): Promise<{ site_id: number; sync_results: any[]; total_integrations: number }> {
|
|
return fetchAPI(`/v1/integration/integrations/sites/${siteId}/sync/run/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ direction, content_types: contentTypes }),
|
|
});
|
|
}
|
|
|
|
export async function fetchSyncMismatches(siteId: number): Promise<SyncMismatches> {
|
|
return fetchAPI(`/v1/integration/integrations/sites/${siteId}/sync/mismatches/`);
|
|
}
|
|
|
|
export async function fetchSyncLogs(
|
|
siteId: number,
|
|
limit: number = 100,
|
|
integrationId?: number
|
|
): Promise<{ site_id: number; logs: SyncLog[]; count: number }> {
|
|
const params = new URLSearchParams();
|
|
params.append('limit', limit.toString());
|
|
if (integrationId) params.append('integration_id', integrationId.toString());
|
|
|
|
return fetchAPI(`/v1/integration/integrations/sites/${siteId}/sync/logs/?${params.toString()}`);
|
|
}
|
|
|
|
// Stage 4: Deployment Readiness API
|
|
export interface DeploymentReadiness {
|
|
ready: boolean;
|
|
checks: {
|
|
cluster_coverage: boolean;
|
|
content_validation: boolean;
|
|
sync_status: boolean;
|
|
taxonomy_completeness: boolean;
|
|
};
|
|
errors: string[];
|
|
warnings: string[];
|
|
details: {
|
|
cluster_coverage: {
|
|
ready: boolean;
|
|
total_clusters: number;
|
|
covered_clusters: number;
|
|
incomplete_clusters: Array<any>;
|
|
errors: string[];
|
|
warnings: string[];
|
|
};
|
|
content_validation: {
|
|
ready: boolean;
|
|
total_content: number;
|
|
valid_content: number;
|
|
invalid_content: Array<any>;
|
|
errors: string[];
|
|
warnings: string[];
|
|
};
|
|
sync_status: {
|
|
ready: boolean;
|
|
has_integration: boolean;
|
|
sync_status: string | null;
|
|
mismatch_count: number;
|
|
errors: string[];
|
|
warnings: string[];
|
|
};
|
|
taxonomy_completeness: {
|
|
ready: boolean;
|
|
total_taxonomies: number;
|
|
required_taxonomies: string[];
|
|
missing_taxonomies: string[];
|
|
errors: string[];
|
|
warnings: string[];
|
|
};
|
|
};
|
|
}
|
|
|
|
// Legacy: Site Builder API removed
|
|
// SiteBlueprint, PageBlueprint, and related functions deprecated
|
|
|
|
|
|
export async function generatePageContent(
|
|
pageId: number,
|
|
force?: boolean
|
|
): Promise<{ success: boolean; task_id?: string }> {
|
|
return fetchAPI(`/v1/site-builder/pages/${pageId}/generate_content/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ force: force || false }),
|
|
});
|
|
}
|
|
|