1565 lines
49 KiB
TypeScript
1565 lines
49 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
|
|
const getAuthToken = (): string | null => {
|
|
try {
|
|
const authStorage = localStorage.getItem('auth-storage');
|
|
if (authStorage) {
|
|
const parsed = JSON.parse(authStorage);
|
|
return parsed?.state?.token || null;
|
|
}
|
|
} catch (e) {
|
|
// Ignore parsing errors
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Get refresh token from store
|
|
const getRefreshToken = (): string | null => {
|
|
try {
|
|
const authStorage = localStorage.getItem('auth-storage');
|
|
if (authStorage) {
|
|
const parsed = JSON.parse(authStorage);
|
|
return parsed?.state?.refreshToken || null;
|
|
}
|
|
} catch (e) {
|
|
// Ignore parsing errors
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Generic API fetch function with timeout
|
|
export async function fetchAPI(endpoint: string, options?: RequestInit & { timeout?: number }) {
|
|
const timeout = options?.timeout || 30000; // Default 30 second timeout
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
|
|
try {
|
|
const token = getAuthToken();
|
|
const headers: HeadersInit = {
|
|
'Content-Type': 'application/json',
|
|
...options?.headers,
|
|
};
|
|
|
|
// Add JWT token if available
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
|
headers,
|
|
credentials: 'include',
|
|
signal: controller.signal,
|
|
...options,
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
// Check content type and length before reading body
|
|
const contentType = response.headers.get('content-type') || '';
|
|
const contentLength = response.headers.get('content-length');
|
|
|
|
// Read response body once (can only be consumed once)
|
|
const text = await response.text();
|
|
|
|
// Handle 401 Unauthorized - try to refresh token
|
|
if (response.status === 401) {
|
|
const refreshToken = getRefreshToken();
|
|
if (refreshToken) {
|
|
try {
|
|
// Try to refresh the token
|
|
const refreshResponse = await fetch(`${API_BASE_URL}/v1/auth/refresh/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ refresh: refreshToken }),
|
|
credentials: 'include',
|
|
});
|
|
|
|
if (refreshResponse.ok) {
|
|
const refreshData = await refreshResponse.json();
|
|
if (refreshData.success && refreshData.access) {
|
|
// Update token in store
|
|
try {
|
|
const authStorage = localStorage.getItem('auth-storage');
|
|
if (authStorage) {
|
|
const parsed = JSON.parse(authStorage);
|
|
parsed.state.token = refreshData.access;
|
|
localStorage.setItem('auth-storage', JSON.stringify(parsed));
|
|
}
|
|
} catch (e) {
|
|
// Ignore storage errors
|
|
}
|
|
|
|
// Retry original request with new token
|
|
const newHeaders = {
|
|
...headers,
|
|
'Authorization': `Bearer ${refreshData.access}`,
|
|
};
|
|
|
|
const retryResponse = await fetch(`${API_BASE_URL}${endpoint}`, {
|
|
headers: newHeaders,
|
|
credentials: 'include',
|
|
...options,
|
|
});
|
|
|
|
// Process retry response
|
|
const retryText = await retryResponse.text();
|
|
if (retryResponse.ok) {
|
|
if (retryText && retryText.trim()) {
|
|
try {
|
|
return JSON.parse(retryText);
|
|
} catch {
|
|
return retryText;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
} catch (refreshError) {
|
|
// Refresh failed, continue with original error handling
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse error response - extract meaningful error information
|
|
if (!response.ok) {
|
|
let errorMessage = response.statusText;
|
|
let errorType = 'HTTP_ERROR';
|
|
let errorData = null;
|
|
|
|
try {
|
|
if (contentType.includes('application/json')) {
|
|
try {
|
|
errorData = JSON.parse(text);
|
|
errorMessage = errorData.error || errorData.message || errorData.detail || errorMessage;
|
|
|
|
// If the response has a success field set to false, return it as-is
|
|
// This allows callers to handle structured error responses
|
|
if (errorData.success === false && errorData.error) {
|
|
// Return the error response object instead of throwing
|
|
// This is a special case for structured error responses
|
|
return errorData;
|
|
}
|
|
|
|
// Classify error type
|
|
if (errorData.error?.includes('OperationalError')) errorType = 'DATABASE_ERROR';
|
|
else if (errorData.error?.includes('ValidationError')) errorType = 'VALIDATION_ERROR';
|
|
else if (errorData.error?.includes('PermissionDenied')) errorType = 'PERMISSION_ERROR';
|
|
else if (errorData.error?.includes('NotFound')) errorType = 'NOT_FOUND_ERROR';
|
|
else if (errorData.error?.includes('IntegrityError')) errorType = 'DATABASE_ERROR';
|
|
else if (errorData.error?.includes('RelatedObjectDoesNotExist')) errorType = 'RELATED_OBJECT_ERROR';
|
|
} catch (e) {
|
|
// JSON parse failed, use text
|
|
errorMessage = text.substring(0, 200);
|
|
}
|
|
} else {
|
|
// HTML or text response (Django debug page)
|
|
if (text.includes('<!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
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (e) {
|
|
// If JSON parsing fails, return text
|
|
return text;
|
|
}
|
|
} catch (error: any) {
|
|
clearTimeout(timeoutId);
|
|
|
|
if (error.name === 'AbortError') {
|
|
throw new Error(`Request timeout after ${timeout}ms: ${API_BASE_URL}${endpoint}`);
|
|
}
|
|
|
|
if (error instanceof TypeError && error.message.includes('Failed to fetch')) {
|
|
throw new Error(`Network Error: Unable to reach API at ${API_BASE_URL}${endpoint}. Check CORS and network connectivity.`);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Keywords-specific API functions
|
|
export interface KeywordFilters {
|
|
search?: string;
|
|
status?: string;
|
|
cluster_id?: string;
|
|
intent?: string;
|
|
difficulty_min?: number;
|
|
difficulty_max?: number;
|
|
volume_min?: number;
|
|
volume_max?: number;
|
|
page?: number;
|
|
page_size?: number;
|
|
ordering?: string;
|
|
site_id?: number; // Site filter - automatically added from siteStore
|
|
sector_id?: number; // Sector filter - for second-level filtering
|
|
}
|
|
|
|
export interface KeywordsResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: Keyword[];
|
|
}
|
|
|
|
export interface Keyword {
|
|
id: number;
|
|
seed_keyword_id: number;
|
|
seed_keyword?: SeedKeyword; // Populated by serializer
|
|
keyword: string; // Read-only property from seed_keyword
|
|
volume: number; // Read-only property from seed_keyword or volume_override
|
|
difficulty: number; // Read-only property from seed_keyword or difficulty_override
|
|
intent: string; // Read-only property from seed_keyword
|
|
volume_override?: number | null;
|
|
difficulty_override?: number | null;
|
|
cluster_id: number | null;
|
|
cluster_name?: string | null; // Optional: populated by serializer or frontend
|
|
sector_name?: string | null; // Optional: populated by serializer
|
|
status: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface KeywordCreateData {
|
|
seed_keyword_id: number;
|
|
volume_override?: number | null;
|
|
difficulty_override?: number | null;
|
|
cluster_id?: number | null;
|
|
status: string;
|
|
}
|
|
|
|
export interface KeywordUpdateData extends Partial<KeywordCreateData> {}
|
|
|
|
export async function fetchKeywords(filters: KeywordFilters = {}): Promise<KeywordsResponse> {
|
|
const params = new URLSearchParams();
|
|
|
|
// Automatically add active site filter if not explicitly provided
|
|
// Always add site_id if there's an active site (even for admin/developer)
|
|
// The backend will respect it appropriately - admin/developer can still see all sites
|
|
// but if a specific site is selected, filter by it
|
|
if (!filters.site_id) {
|
|
const activeSiteId = getActiveSiteId();
|
|
if (activeSiteId) {
|
|
filters.site_id = activeSiteId;
|
|
}
|
|
}
|
|
|
|
// Automatically add active sector filter if not explicitly provided
|
|
// Only add if activeSector is not null (null means "All Sectors")
|
|
// ADMIN/DEV OVERRIDE: Only inject if user is not admin/developer (handled by backend)
|
|
if (filters.sector_id === undefined) {
|
|
const activeSectorId = getActiveSectorId();
|
|
// Only add sector_id if it's not null (null means "All Sectors")
|
|
if (activeSectorId !== null && activeSectorId !== undefined) {
|
|
filters.sector_id = activeSectorId;
|
|
}
|
|
}
|
|
|
|
if (filters.search) params.append('search', filters.search);
|
|
if (filters.status) params.append('status', filters.status);
|
|
if (filters.cluster_id) params.append('cluster_id', filters.cluster_id);
|
|
if (filters.intent) params.append('intent', filters.intent);
|
|
if (filters.difficulty_min !== undefined) params.append('difficulty_min', filters.difficulty_min.toString());
|
|
if (filters.difficulty_max !== undefined) params.append('difficulty_max', filters.difficulty_max.toString());
|
|
if (filters.volume_min !== undefined) params.append('volume_min', filters.volume_min.toString());
|
|
if (filters.volume_max !== undefined) params.append('volume_max', filters.volume_max.toString());
|
|
if (filters.site_id) params.append('site_id', filters.site_id.toString());
|
|
if (filters.sector_id) params.append('sector_id', filters.sector_id.toString());
|
|
if (filters.page) params.append('page', filters.page.toString());
|
|
if (filters.page_size !== undefined && filters.page_size !== null) {
|
|
params.append('page_size', filters.page_size.toString());
|
|
}
|
|
if (filters.ordering) params.append('ordering', filters.ordering);
|
|
|
|
const queryString = params.toString();
|
|
const endpoint = `/v1/planner/keywords/${queryString ? `?${queryString}` : ''}`;
|
|
|
|
return fetchAPI(endpoint);
|
|
}
|
|
|
|
export async function fetchKeyword(id: number): Promise<Keyword> {
|
|
return fetchAPI(`/v1/planner/keywords/${id}/`);
|
|
}
|
|
|
|
export async function createKeyword(data: KeywordCreateData): Promise<Keyword> {
|
|
return fetchAPI('/v1/planner/keywords/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function updateKeyword(id: number, data: KeywordUpdateData): Promise<Keyword> {
|
|
return fetchAPI(`/v1/planner/keywords/${id}/`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function deleteKeyword(id: number): Promise<void> {
|
|
return fetchAPI(`/v1/planner/keywords/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
export async function bulkDeleteKeywords(ids: number[]): Promise<{ deleted_count: number }> {
|
|
return fetchAPI(`/v1/planner/keywords/bulk_delete/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids }),
|
|
});
|
|
}
|
|
|
|
export async function bulkUpdateKeywordsStatus(ids: number[], status: string): Promise<{ updated_count: number }> {
|
|
return fetchAPI(`/v1/planner/keywords/bulk_update/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids, status }),
|
|
});
|
|
}
|
|
|
|
// Clusters-specific API functions
|
|
export interface ClusterFilters {
|
|
search?: string;
|
|
status?: string;
|
|
difficulty_min?: number;
|
|
difficulty_max?: number;
|
|
volume_min?: number;
|
|
volume_max?: number;
|
|
page?: number;
|
|
page_size?: number;
|
|
ordering?: string;
|
|
site_id?: number; // Site filter - automatically added from siteStore
|
|
sector_id?: number; // Sector filter - for second-level filtering
|
|
}
|
|
|
|
export interface ClustersResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: Cluster[];
|
|
}
|
|
|
|
export interface Cluster {
|
|
id: number;
|
|
name: string;
|
|
description?: string | null;
|
|
keywords_count: number;
|
|
volume: number;
|
|
difficulty: number; // Average difficulty of keywords in cluster
|
|
mapped_pages: number;
|
|
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 {
|
|
const response = await fetchAPI(endpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
// Check if response indicates an error (success: false)
|
|
if (response && response.success === false) {
|
|
// Return error response as-is so caller can check result.success
|
|
return response;
|
|
}
|
|
|
|
return response;
|
|
} catch (error: any) {
|
|
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 {
|
|
const response = await fetchAPI(endpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
return response;
|
|
} catch (error: 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 {
|
|
const response = await fetchAPI(endpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
return response;
|
|
} catch (error: 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_structure: string;
|
|
content_type: string;
|
|
target_keywords?: string | null;
|
|
keyword_cluster_id?: number | null;
|
|
keyword_cluster_name?: string | null;
|
|
sector_name?: string | null; // Optional: populated by serializer
|
|
status: string;
|
|
estimated_word_count: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface ContentIdeaCreateData {
|
|
idea_title: string;
|
|
description?: string | null;
|
|
content_structure?: string;
|
|
content_type?: string;
|
|
target_keywords?: string | null;
|
|
keyword_cluster_id?: number | null;
|
|
status?: string;
|
|
estimated_word_count?: number;
|
|
}
|
|
|
|
export interface ContentIdeaUpdateData extends Partial<ContentIdeaCreateData> {}
|
|
|
|
export async function fetchContentIdeas(filters: ContentIdeasFilters = {}): Promise<ContentIdeasResponse> {
|
|
const params = new URLSearchParams();
|
|
|
|
// Automatically add active site filter if not explicitly provided
|
|
// Always add site_id if there's an active site (even for admin/developer)
|
|
// The backend will respect it appropriately - admin/developer can still see all sites
|
|
// but if a specific site is selected, filter by it
|
|
if (!filters.site_id) {
|
|
const activeSiteId = getActiveSiteId();
|
|
if (activeSiteId) {
|
|
filters.site_id = activeSiteId;
|
|
}
|
|
}
|
|
|
|
// Automatically add active sector filter if not explicitly provided
|
|
// Only add if activeSector is not null (null means "All Sectors")
|
|
// ADMIN/DEV OVERRIDE: Only inject if user is not admin/developer (handled by backend)
|
|
if (filters.sector_id === undefined) {
|
|
const activeSectorId = getActiveSectorId();
|
|
// Only add sector_id if it's not null (null means "All Sectors")
|
|
if (activeSectorId !== null && activeSectorId !== undefined) {
|
|
filters.sector_id = activeSectorId;
|
|
}
|
|
}
|
|
|
|
if (filters.search) params.append('search', filters.search);
|
|
if (filters.status) params.append('status', filters.status);
|
|
if (filters.keyword_cluster_id) params.append('keyword_cluster_id', filters.keyword_cluster_id);
|
|
if (filters.content_structure) params.append('content_structure', filters.content_structure);
|
|
if (filters.content_type) params.append('content_type', filters.content_type);
|
|
if (filters.site_id) params.append('site_id', filters.site_id.toString());
|
|
if (filters.sector_id) params.append('sector_id', filters.sector_id.toString());
|
|
if (filters.page) params.append('page', filters.page.toString());
|
|
if (filters.page_size !== undefined && filters.page_size !== null) {
|
|
params.append('page_size', filters.page_size.toString());
|
|
}
|
|
if (filters.ordering) params.append('ordering', filters.ordering);
|
|
|
|
const queryString = params.toString();
|
|
const endpoint = `/v1/planner/ideas/${queryString ? `?${queryString}` : ''}`;
|
|
return fetchAPI(endpoint);
|
|
}
|
|
|
|
export async function fetchContentIdea(id: number): Promise<ContentIdea> {
|
|
return fetchAPI(`/v1/planner/ideas/${id}/`);
|
|
}
|
|
|
|
export async function createContentIdea(data: ContentIdeaCreateData): Promise<ContentIdea> {
|
|
return fetchAPI('/v1/planner/ideas/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function updateContentIdea(id: number, data: ContentIdeaUpdateData): Promise<ContentIdea> {
|
|
return fetchAPI(`/v1/planner/ideas/${id}/`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function deleteContentIdea(id: number): Promise<void> {
|
|
return fetchAPI(`/v1/planner/ideas/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
export async function bulkDeleteContentIdeas(ids: number[]): Promise<{ deleted_count: number }> {
|
|
return fetchAPI(`/v1/planner/ideas/bulk_delete/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids }),
|
|
});
|
|
}
|
|
|
|
export async function bulkUpdateContentIdeasStatus(ids: number[], status: string): Promise<{ updated_count: number }> {
|
|
return fetchAPI(`/v1/planner/ideas/bulk_update/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids, status }),
|
|
});
|
|
}
|
|
|
|
export async function bulkQueueIdeasToWriter(ids: number[]): Promise<{ created_count: number; task_ids: number[] }> {
|
|
return fetchAPI(`/v1/planner/ideas/bulk_queue_to_writer/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids }),
|
|
});
|
|
}
|
|
|
|
// Tasks API functions
|
|
export interface TasksFilters {
|
|
search?: string;
|
|
status?: string;
|
|
cluster_id?: string;
|
|
content_type?: string;
|
|
content_structure?: string;
|
|
page?: number;
|
|
page_size?: number;
|
|
ordering?: string;
|
|
site_id?: number; // Site filter - automatically added from siteStore
|
|
sector_id?: number; // Sector filter - for second-level filtering
|
|
}
|
|
|
|
export interface TasksResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: Task[];
|
|
}
|
|
|
|
export interface Task {
|
|
id: number;
|
|
title: string;
|
|
description?: string | null;
|
|
keywords?: string | null;
|
|
cluster_id?: number | null;
|
|
cluster_name?: string | null;
|
|
sector_name?: string | null; // Optional: populated by serializer
|
|
idea_id?: number | null;
|
|
idea_title?: string | null;
|
|
content_structure: string;
|
|
content_type: string;
|
|
status: string;
|
|
content?: string | null;
|
|
word_count: number;
|
|
meta_title?: string | null;
|
|
meta_description?: string | null;
|
|
content_html?: string | null;
|
|
content_primary_keyword?: string | null;
|
|
content_secondary_keywords?: string[];
|
|
content_tags?: string[];
|
|
content_categories?: string[];
|
|
assigned_post_id?: number | null;
|
|
post_url?: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface TaskCreateData {
|
|
title: string;
|
|
description?: string | null;
|
|
keywords?: string | null;
|
|
cluster_id?: number | null;
|
|
idea_id?: number | null;
|
|
content_structure?: string;
|
|
content_type?: string;
|
|
status?: string;
|
|
content?: string | null;
|
|
word_count?: number;
|
|
meta_title?: string | null;
|
|
meta_description?: string | null;
|
|
}
|
|
|
|
export interface TaskUpdateData extends Partial<TaskCreateData> {}
|
|
|
|
export async function fetchTasks(filters: TasksFilters = {}): Promise<TasksResponse> {
|
|
const params = new URLSearchParams();
|
|
|
|
// Automatically add active site filter if not explicitly provided
|
|
// Always add site_id if there's an active site (even for admin/developer)
|
|
// The backend will respect it appropriately - admin/developer can still see all sites
|
|
// but if a specific site is selected, filter by it
|
|
if (!filters.site_id) {
|
|
const activeSiteId = getActiveSiteId();
|
|
if (activeSiteId) {
|
|
filters.site_id = activeSiteId;
|
|
}
|
|
}
|
|
|
|
// Automatically add active sector filter if not explicitly provided
|
|
// Only add if activeSector is not null (null means "All Sectors")
|
|
// ADMIN/DEV OVERRIDE: Only inject if user is not admin/developer (handled by backend)
|
|
if (filters.sector_id === undefined) {
|
|
const activeSectorId = getActiveSectorId();
|
|
// Only add sector_id if it's not null (null means "All Sectors")
|
|
if (activeSectorId !== null && activeSectorId !== undefined) {
|
|
filters.sector_id = activeSectorId;
|
|
}
|
|
}
|
|
|
|
if (filters.search) params.append('search', filters.search);
|
|
if (filters.status) params.append('status', filters.status);
|
|
if (filters.cluster_id) params.append('cluster_id', filters.cluster_id);
|
|
if (filters.content_type) params.append('content_type', filters.content_type);
|
|
if (filters.content_structure) params.append('content_structure', filters.content_structure);
|
|
if (filters.site_id) params.append('site_id', filters.site_id.toString());
|
|
if (filters.sector_id) params.append('sector_id', filters.sector_id.toString());
|
|
if (filters.page) params.append('page', filters.page.toString());
|
|
if (filters.page_size !== undefined && filters.page_size !== null) {
|
|
params.append('page_size', filters.page_size.toString());
|
|
}
|
|
if (filters.ordering) params.append('ordering', filters.ordering);
|
|
|
|
const queryString = params.toString();
|
|
const endpoint = `/v1/writer/tasks/${queryString ? `?${queryString}` : ''}`;
|
|
return fetchAPI(endpoint);
|
|
}
|
|
|
|
export async function fetchTask(id: number): Promise<Task> {
|
|
return fetchAPI(`/v1/writer/tasks/${id}/`);
|
|
}
|
|
|
|
export async function createTask(data: TaskCreateData): Promise<Task> {
|
|
return fetchAPI('/v1/writer/tasks/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function updateTask(id: number, data: TaskUpdateData): Promise<Task> {
|
|
return fetchAPI(`/v1/writer/tasks/${id}/`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function deleteTask(id: number): Promise<void> {
|
|
return fetchAPI(`/v1/writer/tasks/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
export async function bulkDeleteTasks(ids: number[]): Promise<{ deleted_count: number }> {
|
|
return fetchAPI(`/v1/writer/tasks/bulk_delete/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids }),
|
|
});
|
|
}
|
|
|
|
export async function bulkUpdateTasksStatus(ids: number[], status: string): Promise<{ updated_count: number }> {
|
|
return fetchAPI(`/v1/writer/tasks/bulk_update/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids, status }),
|
|
});
|
|
}
|
|
|
|
export async function autoGenerateContent(ids: number[]): Promise<{ success: boolean; task_id?: string; tasks_updated?: number; message?: string; error?: string }> {
|
|
const endpoint = `/v1/writer/tasks/auto_generate_content/`;
|
|
const requestBody = { ids };
|
|
|
|
try {
|
|
const response = await fetchAPI(endpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
return response;
|
|
} catch (error: 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 {
|
|
const response = await fetchAPI(endpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
return response;
|
|
} catch (error: any) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function generateImagePrompts(contentIds: number[]): Promise<any> {
|
|
return fetchAPI('/v1/writer/content/generate_image_prompts/', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids: contentIds }),
|
|
});
|
|
}
|
|
|
|
// 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;
|
|
featured_image: ContentImage | null;
|
|
in_article_images: ContentImage[];
|
|
overall_status: 'pending' | 'partial' | 'complete' | 'failed';
|
|
}
|
|
|
|
export interface ContentImagesResponse {
|
|
count: number;
|
|
results: ContentImagesGroup[];
|
|
}
|
|
|
|
export async function fetchContentImages(): Promise<ContentImagesResponse> {
|
|
return fetchAPI('/v1/writer/images/content_images/');
|
|
}
|
|
|
|
export async function deleteTaskImage(id: number): Promise<void> {
|
|
return fetchAPI(`/v1/writer/images/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
// Sites API functions
|
|
export interface Site {
|
|
id: number;
|
|
name: string;
|
|
slug: string;
|
|
domain?: string | null;
|
|
description?: string | null;
|
|
industry?: number | null;
|
|
industry_name?: string | null;
|
|
industry_slug?: string | null;
|
|
is_active: boolean;
|
|
status: string;
|
|
wp_url?: string | null;
|
|
wp_username?: string | null;
|
|
sectors_count: number;
|
|
active_sectors_count: number;
|
|
selected_sectors: number[];
|
|
can_add_sectors: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface SiteCreateData {
|
|
name: string;
|
|
slug?: string;
|
|
domain?: string;
|
|
description?: string;
|
|
is_active?: boolean;
|
|
status?: string;
|
|
wp_url?: string;
|
|
wp_username?: string;
|
|
wp_app_password?: string;
|
|
}
|
|
|
|
export interface SitesResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: Site[];
|
|
}
|
|
|
|
export async function fetchSites(): Promise<SitesResponse> {
|
|
return fetchAPI('/v1/auth/sites/');
|
|
}
|
|
|
|
export async function fetchSite(id: number): Promise<Site> {
|
|
return fetchAPI(`/v1/auth/sites/${id}/`);
|
|
}
|
|
|
|
export async function createSite(data: SiteCreateData): Promise<Site> {
|
|
return fetchAPI('/v1/auth/sites/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function updateSite(id: number, data: Partial<SiteCreateData>): Promise<Site> {
|
|
return fetchAPI(`/v1/auth/sites/${id}/`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function deleteSite(id: number): Promise<void> {
|
|
return fetchAPI(`/v1/auth/sites/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
export async function setActiveSite(id: number): Promise<{ success: boolean; message: string; site: Site }> {
|
|
return fetchAPI(`/v1/auth/sites/${id}/set_active/`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
export async function selectSectorsForSite(
|
|
siteId: number,
|
|
industrySlug: string,
|
|
sectorSlugs: string[]
|
|
): Promise<{ success: boolean; message: string; created_count: number; updated_count: number; sectors: any[] }> {
|
|
return fetchAPI(`/v1/auth/sites/${siteId}/select_sectors/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
industry_slug: industrySlug,
|
|
sector_slugs: sectorSlugs,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export async function fetchSiteSectors(siteId: number): Promise<any[]> {
|
|
return fetchAPI(`/v1/auth/sites/${siteId}/sectors/`);
|
|
}
|
|
|
|
// Industries API functions
|
|
export interface Industry {
|
|
name: string;
|
|
slug: string;
|
|
description: string;
|
|
sectors: Sector[];
|
|
}
|
|
|
|
export interface Sector {
|
|
name: string;
|
|
slug: string;
|
|
description: string;
|
|
}
|
|
|
|
export interface IndustriesResponse {
|
|
success: boolean;
|
|
industries: Industry[];
|
|
}
|
|
|
|
export async function fetchIndustries(): Promise<IndustriesResponse> {
|
|
return fetchAPI('/v1/auth/industries/');
|
|
}
|
|
|
|
// Sectors API functions
|
|
|
|
// Settings API functions
|
|
export interface AccountSetting {
|
|
id: number;
|
|
key: string;
|
|
config: Record<string, any>;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface AccountSettingsResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: AccountSetting[];
|
|
}
|
|
|
|
export async function fetchAccountSettings(): Promise<AccountSettingsResponse> {
|
|
return fetchAPI('/v1/system/settings/account/');
|
|
}
|
|
|
|
export async function fetchAccountSetting(key: string): Promise<AccountSetting> {
|
|
return fetchAPI(`/v1/system/settings/account/${key}/`);
|
|
}
|
|
|
|
export async function createAccountSetting(data: { key: string; config: Record<string, any>; is_active?: boolean }): Promise<AccountSetting> {
|
|
return fetchAPI('/v1/system/settings/account/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function updateAccountSetting(key: string, data: Partial<{ config: Record<string, any>; is_active: boolean }>): Promise<AccountSetting> {
|
|
return fetchAPI(`/v1/system/settings/account/${key}/`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function deleteAccountSetting(key: string): Promise<void> {
|
|
return fetchAPI(`/v1/system/settings/account/${key}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
// Module Settings
|
|
export interface ModuleSetting {
|
|
id: number;
|
|
module_name: string;
|
|
key: string;
|
|
config: Record<string, any>;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export async function fetchModuleSettings(moduleName: string): Promise<ModuleSetting[]> {
|
|
const response = await fetchAPI(`/v1/system/settings/modules/module/${moduleName}/`);
|
|
return response.results || [];
|
|
}
|
|
|
|
export async function createModuleSetting(data: { module_name: string; key: string; config: Record<string, any>; is_active?: boolean }): Promise<ModuleSetting> {
|
|
return fetchAPI('/v1/system/settings/modules/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function updateModuleSetting(moduleName: string, key: string, data: Partial<{ config: Record<string, any>; is_active: boolean }>): Promise<ModuleSetting> {
|
|
return fetchAPI(`/v1/system/settings/modules/${key}/?module_name=${moduleName}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
// Billing API functions
|
|
export interface CreditBalance {
|
|
credits: number;
|
|
plan_credits_per_month: number;
|
|
credits_used_this_month: number;
|
|
credits_remaining: number;
|
|
}
|
|
|
|
export interface CreditUsageLog {
|
|
id: number;
|
|
operation_type: string;
|
|
operation_type_display: string;
|
|
credits_used: number;
|
|
cost_usd: string | null;
|
|
model_used: string;
|
|
tokens_input: number | null;
|
|
tokens_output: number | null;
|
|
related_object_type: string;
|
|
related_object_id: number | null;
|
|
metadata: Record<string, any>;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface CreditUsageResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: CreditUsageLog[];
|
|
}
|
|
|
|
export interface UsageSummary {
|
|
period: {
|
|
start: string;
|
|
end: string;
|
|
};
|
|
total_credits_used: number;
|
|
total_cost_usd: number;
|
|
by_operation: Record<string, {
|
|
credits: number;
|
|
cost: number;
|
|
count: number;
|
|
}>;
|
|
by_model: Record<string, {
|
|
credits: number;
|
|
cost: number;
|
|
}>;
|
|
}
|
|
|
|
export async function fetchCreditBalance(): Promise<CreditBalance> {
|
|
return fetchAPI('/v1/billing/credits/balance/balance/');
|
|
}
|
|
|
|
export async function fetchCreditUsage(filters?: {
|
|
operation_type?: string;
|
|
start_date?: string;
|
|
end_date?: string;
|
|
page?: number;
|
|
}): Promise<CreditUsageResponse> {
|
|
const params = new URLSearchParams();
|
|
if (filters?.operation_type) params.append('operation_type', filters.operation_type);
|
|
if (filters?.start_date) params.append('start_date', filters.start_date);
|
|
if (filters?.end_date) params.append('end_date', filters.end_date);
|
|
if (filters?.page) params.append('page', filters.page.toString());
|
|
|
|
const queryString = params.toString();
|
|
return fetchAPI(`/v1/billing/credits/usage/${queryString ? `?${queryString}` : ''}`);
|
|
}
|
|
|
|
export async function fetchUsageSummary(startDate?: string, endDate?: string): Promise<UsageSummary> {
|
|
const params = new URLSearchParams();
|
|
if (startDate) params.append('start_date', startDate);
|
|
if (endDate) params.append('end_date', endDate);
|
|
|
|
const queryString = params.toString();
|
|
return fetchAPI(`/v1/billing/credits/usage/summary/${queryString ? `?${queryString}` : ''}`);
|
|
}
|
|
|
|
export interface LimitCard {
|
|
title: string;
|
|
limit: number;
|
|
used: number;
|
|
available: number;
|
|
unit: string;
|
|
category: 'planner' | 'writer' | 'images' | 'ai' | 'general';
|
|
percentage: number;
|
|
}
|
|
|
|
export interface UsageLimitsResponse {
|
|
limits: LimitCard[];
|
|
}
|
|
|
|
export async function fetchUsageLimits(): Promise<UsageLimitsResponse> {
|
|
console.log('Fetching usage limits from:', '/v1/billing/credits/usage/limits/');
|
|
try {
|
|
const response = await fetchAPI('/v1/billing/credits/usage/limits/');
|
|
console.log('Usage limits API response:', response);
|
|
return response;
|
|
} catch (error) {
|
|
console.error('Error fetching usage limits:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export interface CreditTransaction {
|
|
id: number;
|
|
transaction_type: string;
|
|
transaction_type_display: string;
|
|
amount: number;
|
|
balance_after: number;
|
|
description: string;
|
|
metadata: Record<string, any>;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface CreditTransactionResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: CreditTransaction[];
|
|
}
|
|
|
|
export async function fetchCreditTransactions(filters?: {
|
|
transaction_type?: string;
|
|
page?: number;
|
|
}): Promise<CreditTransactionResponse> {
|
|
const params = new URLSearchParams();
|
|
if (filters?.transaction_type) params.append('transaction_type', filters.transaction_type);
|
|
if (filters?.page) params.append('page', filters.page.toString());
|
|
|
|
const queryString = params.toString();
|
|
return fetchAPI(`/v1/billing/credits/transactions/${queryString ? `?${queryString}` : ''}`);
|
|
}
|
|
|
|
// Seed Keywords API
|
|
export interface SeedKeyword {
|
|
id: number;
|
|
keyword: string;
|
|
industry: number;
|
|
industry_name: string;
|
|
industry_slug: string;
|
|
sector: number;
|
|
sector_name: string;
|
|
sector_slug: string;
|
|
volume: number;
|
|
difficulty: number;
|
|
intent: string;
|
|
intent_display: string;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface SeedKeywordResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: SeedKeyword[];
|
|
}
|
|
|
|
export async function fetchSeedKeywords(filters?: {
|
|
industry?: number;
|
|
sector?: number;
|
|
intent?: string;
|
|
search?: string;
|
|
page?: number;
|
|
page_size?: number;
|
|
}): Promise<SeedKeywordResponse> {
|
|
const params = new URLSearchParams();
|
|
// Use industry_id and sector_id as per backend get_queryset, but also try industry/sector for filterset_fields
|
|
if (filters?.industry) {
|
|
params.append('industry', filters.industry.toString());
|
|
params.append('industry_id', filters.industry.toString()); // Also send industry_id for get_queryset
|
|
}
|
|
if (filters?.sector) {
|
|
params.append('sector', filters.sector.toString());
|
|
params.append('sector_id', filters.sector.toString()); // Also send sector_id for get_queryset
|
|
}
|
|
if (filters?.intent) params.append('intent', filters.intent);
|
|
if (filters?.search) params.append('search', filters.search);
|
|
if (filters?.page) params.append('page', filters.page.toString());
|
|
if (filters?.page_size) params.append('page_size', filters.page_size.toString());
|
|
|
|
const queryString = params.toString();
|
|
return fetchAPI(`/v1/auth/seed-keywords/${queryString ? `?${queryString}` : ''}`);
|
|
}
|
|
|
|
/**
|
|
* Add SeedKeywords to workflow (create Keywords records)
|
|
*/
|
|
export async function addSeedKeywordsToWorkflow(seedKeywordIds: number[], siteId: number, sectorId: number): Promise<{ success: boolean; created: number; errors?: string[] }> {
|
|
return fetchAPI('/v1/planner/keywords/bulk_add_from_seed/', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
seed_keyword_ids: seedKeywordIds,
|
|
site_id: siteId,
|
|
sector_id: sectorId,
|
|
}),
|
|
});
|
|
}
|
|
|
|
// Author Profiles API
|
|
export interface AuthorProfile {
|
|
id: number;
|
|
name: string;
|
|
description: string;
|
|
tone: string;
|
|
language: string;
|
|
structure_template: Record<string, any>;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface AuthorProfileResponse {
|
|
count: number;
|
|
next: string | null;
|
|
previous: string | null;
|
|
results: AuthorProfile[];
|
|
}
|
|
|
|
export async function fetchAuthorProfiles(filters?: {
|
|
is_active?: boolean;
|
|
language?: string;
|
|
search?: string;
|
|
page?: number;
|
|
}): Promise<AuthorProfileResponse> {
|
|
const params = new URLSearchParams();
|
|
if (filters?.is_active !== undefined) params.append('is_active', filters.is_active.toString());
|
|
if (filters?.language) params.append('language', filters.language);
|
|
if (filters?.search) params.append('search', filters.search);
|
|
if (filters?.page) params.append('page', filters.page.toString());
|
|
|
|
const queryString = params.toString();
|
|
return fetchAPI(`/v1/system/author-profiles/${queryString ? `?${queryString}` : ''}`);
|
|
}
|
|
|
|
export async function createAuthorProfile(data: Partial<AuthorProfile>): Promise<AuthorProfile> {
|
|
return fetchAPI('/v1/system/author-profiles/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function updateAuthorProfile(id: number, data: Partial<AuthorProfile>): Promise<AuthorProfile> {
|
|
return fetchAPI(`/v1/system/author-profiles/${id}/`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export async function deleteAuthorProfile(id: number): Promise<void> {
|
|
return fetchAPI(`/v1/system/author-profiles/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
// Content API
|
|
export interface ContentFilters {
|
|
search?: string;
|
|
status?: string;
|
|
task_id?: number;
|
|
page?: number;
|
|
page_size?: number;
|
|
ordering?: string;
|
|
site_id?: number;
|
|
sector_id?: number;
|
|
}
|
|
|
|
export interface Content {
|
|
id: number;
|
|
task_id: number;
|
|
task_title?: string | null;
|
|
sector_name?: string | null;
|
|
title?: string | null;
|
|
meta_title?: string | null;
|
|
meta_description?: string | null;
|
|
primary_keyword?: string | null;
|
|
secondary_keywords?: string[];
|
|
tags?: string[];
|
|
categories?: string[];
|
|
status: string;
|
|
html_content: string;
|
|
word_count: number;
|
|
metadata: Record<string, any>;
|
|
generated_at: string;
|
|
updated_at: string;
|
|
has_image_prompts?: boolean;
|
|
has_generated_images?: boolean;
|
|
}
|
|
|
|
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}` : ''}`);
|
|
}
|
|
|