AI Analysis and Content Strategy fixes. Enhanced Strategy Routes refactoring.
This commit is contained in:
@@ -42,13 +42,29 @@ export const setAuthTokenGetter = (getter: () => Promise<string | null>) => {
|
||||
authTokenGetter = getter;
|
||||
};
|
||||
|
||||
export const getAuthTokenGetter = (): (() => Promise<string | null>) | null => {
|
||||
return authTokenGetter;
|
||||
};
|
||||
|
||||
// Get API URL from environment variables
|
||||
export const getApiUrl = () => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// In production, use the environment variable or fallback
|
||||
return process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL;
|
||||
}
|
||||
return ''; // Use proxy in development
|
||||
// In development, prefer the local backend to avoid CORS/proxy header stripping.
|
||||
// If an ngrok URL is set in env but we're on localhost, override to localhost:8000.
|
||||
const envUrl = process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL;
|
||||
const isLocalhost = typeof window !== 'undefined' && window.location.hostname === 'localhost';
|
||||
const isNgrok = envUrl && envUrl.includes('ngrok');
|
||||
if (isLocalhost) {
|
||||
if (isNgrok) {
|
||||
console.warn('[apiClient] ⚠️ Overriding ngrok API URL in dev; using http://localhost:8000 to avoid CORS.');
|
||||
}
|
||||
return 'http://localhost:8000';
|
||||
}
|
||||
// Non-localhost dev (rare): use env if provided, otherwise localhost
|
||||
return envUrl || 'http://localhost:8000';
|
||||
};
|
||||
|
||||
// Create a shared axios instance for all API calls
|
||||
@@ -89,32 +105,41 @@ export const pollingApiClient = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Add request interceptor for logging (optional)
|
||||
// Add request interceptor for logging and authentication
|
||||
apiClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
console.log(`Making ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
try {
|
||||
if (!authTokenGetter) {
|
||||
console.warn(`[apiClient] ⚠️ authTokenGetter not set for ${config.url} - request may fail authentication`);
|
||||
console.warn(`[apiClient] This usually means TokenInstaller hasn't run yet. Request will likely fail with 401.`);
|
||||
} else {
|
||||
try {
|
||||
const token = await authTokenGetter();
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
console.log(`[apiClient] ✅ Added auth token to request: ${config.url}`);
|
||||
} else {
|
||||
console.warn(`[apiClient] ⚠️ authTokenGetter returned null for ${config.url} - user may not be signed in`);
|
||||
console.warn(`[apiClient] User ID from localStorage: ${localStorage.getItem('user_id') || 'none'}`);
|
||||
}
|
||||
} catch (tokenError) {
|
||||
console.error(`[apiClient] ❌ Error getting auth token for ${config.url}:`, tokenError);
|
||||
// If authTokenGetter is not set, reject the request to prevent 401 errors
|
||||
// This usually means TokenInstaller hasn't run yet or Clerk isn't ready
|
||||
console.error(`[apiClient] ❌ authTokenGetter not set for ${config.url} - rejecting request`);
|
||||
console.error(`[apiClient] This usually means TokenInstaller hasn't run yet. Please wait for authentication to initialize.`);
|
||||
return Promise.reject(new Error('Authentication not ready. Please wait for sign-in to complete.'));
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await authTokenGetter();
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
console.log(`[apiClient] ✅ Added auth token to request: ${config.url}`);
|
||||
} else {
|
||||
// Token getter returned null - reject request to prevent 401 errors
|
||||
// ProtectedRoute should ensure user is authenticated before components render
|
||||
console.error(`[apiClient] ❌ authTokenGetter returned null for ${config.url} - rejecting request`);
|
||||
console.error(`[apiClient] User ID from localStorage: ${localStorage.getItem('user_id') || 'none'}`);
|
||||
console.error(`[apiClient] This usually means user is not signed in or token expired. ProtectedRoute should prevent this.`);
|
||||
return Promise.reject(new Error('Authentication token not available. Please sign in to continue.'));
|
||||
}
|
||||
} catch (tokenError) {
|
||||
console.error(`[apiClient] ❌ Error getting auth token for ${config.url}:`, tokenError);
|
||||
// Reject request if token getter throws an error
|
||||
return Promise.reject(new Error('Failed to get authentication token. Please try signing in again.'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[apiClient] ❌ Unexpected error in request interceptor for ${config.url}:`, e);
|
||||
// non-fatal - let the request proceed, backend will return 401 if needed
|
||||
return Promise.reject(e);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
@@ -185,9 +210,11 @@ apiClient.interceptors.response.use(
|
||||
// If retry failed, token is expired - sign out user and redirect to sign in
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
const isContentPlanningRoute = window.location.pathname.includes('/content-planning');
|
||||
|
||||
// Don't redirect from root route during app initialization - allow InitialRouteHandler to work
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
// Don't redirect from root route or content-planning during app initialization
|
||||
// ProtectedRoute should handle authentication state
|
||||
if (!isRootRoute && !isOnboardingRoute && !isContentPlanningRoute) {
|
||||
// Token expired - sign out user and redirect to landing/sign-in
|
||||
console.warn('401 Unauthorized - token expired, signing out user');
|
||||
|
||||
@@ -211,6 +238,9 @@ apiClient.interceptors.response.use(
|
||||
// Fallback: redirect to landing (will show sign-in if Clerk handles it)
|
||||
window.location.assign('/');
|
||||
}
|
||||
} else if (isContentPlanningRoute) {
|
||||
// For content-planning, just log the error - ProtectedRoute will handle redirect if needed
|
||||
console.warn('401 Unauthorized for content-planning route - ProtectedRoute should handle this');
|
||||
} else {
|
||||
console.warn('401 Unauthorized - token refresh failed (during initialization, not redirecting)');
|
||||
}
|
||||
@@ -220,8 +250,11 @@ apiClient.interceptors.response.use(
|
||||
if (error?.response?.status === 401 && (originalRequest._retry || !authTokenGetter)) {
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
const isContentPlanningRoute = window.location.pathname.includes('/content-planning');
|
||||
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
// Don't redirect for content-planning during initial load - let ProtectedRoute handle it
|
||||
// This prevents redirect loops when requests are made before auth is fully ready
|
||||
if (!isRootRoute && !isOnboardingRoute && !isContentPlanningRoute) {
|
||||
// Token expired - sign out user and redirect
|
||||
console.warn('401 Unauthorized - token expired (not retried), signing out user');
|
||||
localStorage.removeItem('user_id');
|
||||
@@ -234,6 +267,9 @@ apiClient.interceptors.response.use(
|
||||
} else {
|
||||
window.location.assign('/');
|
||||
}
|
||||
} else if (isContentPlanningRoute) {
|
||||
// For content-planning, just log the error - ProtectedRoute will handle redirect if needed
|
||||
console.warn('401 Unauthorized for content-planning route - ProtectedRoute should handle this');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,10 +473,18 @@ pollingApiClient.interceptors.response.use(
|
||||
}
|
||||
// Check if it's a subscription-related error and handle it globally
|
||||
if (error.response?.status === 429 || error.response?.status === 402) {
|
||||
console.log('Polling API Client: Detected subscription error', {
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
hasHandler: !!globalSubscriptionErrorHandler
|
||||
});
|
||||
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
const wasHandled = result instanceof Promise ? await result : result;
|
||||
if (!wasHandled) {
|
||||
if (wasHandled) {
|
||||
console.log('Polling API Client: Subscription error handled by global handler - modal should be shown');
|
||||
} else {
|
||||
console.warn('Polling API Client: Subscription error not handled by global handler');
|
||||
}
|
||||
// Always reject so the polling hook can also handle it
|
||||
|
||||
@@ -6,13 +6,16 @@
|
||||
* - /api/research/intent/research - Execute intent-driven research
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import { apiClient, aiApiClient } from './client';
|
||||
import {
|
||||
AnalyzeIntentRequest,
|
||||
AnalyzeIntentResponse,
|
||||
IntentDrivenResearchRequest,
|
||||
IntentDrivenResearchResponse,
|
||||
ResearchIntent,
|
||||
} from '../components/Research/types/intent.types';
|
||||
import { WizardState } from '../components/Research/types/research.types';
|
||||
import { BlogResearchResponse } from '../services/blogWriterApi';
|
||||
|
||||
/**
|
||||
* Analyze user input to understand research intent.
|
||||
@@ -43,6 +46,7 @@ export const analyzeIntent = async (
|
||||
expected_deliverables: ['key_statistics'],
|
||||
depth: 'detailed',
|
||||
focus_areas: [],
|
||||
also_answering: [],
|
||||
perspective: null,
|
||||
time_sensitivity: null,
|
||||
input_type: 'keywords',
|
||||
@@ -202,10 +206,292 @@ export const quickIntentResearch = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save research project to Asset Library.
|
||||
*
|
||||
* Saves the complete research project state so users can resume later.
|
||||
*/
|
||||
export const saveResearchProject = async (
|
||||
state: WizardState,
|
||||
options?: {
|
||||
intentAnalysis?: AnalyzeIntentResponse | null;
|
||||
confirmedIntent?: ResearchIntent | null;
|
||||
intentResult?: IntentDrivenResearchResponse | null;
|
||||
legacyResult?: BlogResearchResponse | null;
|
||||
title?: string;
|
||||
description?: string;
|
||||
projectId?: string; // Project ID for updates (optional)
|
||||
}
|
||||
): Promise<{ success: boolean; asset_id?: number; project_id?: string; message: string }> => {
|
||||
try {
|
||||
// Generate project title from keywords if not provided
|
||||
const projectTitle = options?.title ||
|
||||
(state.keywords.length > 0
|
||||
? `Research: ${state.keywords.slice(0, 3).join(', ')}`
|
||||
: 'Research Project');
|
||||
|
||||
// Generate description if not provided
|
||||
const projectDescription = options?.description ||
|
||||
`Research project on ${state.keywords.join(', ')}. ` +
|
||||
`Industry: ${state.industry}, Target Audience: ${state.targetAudience}`;
|
||||
|
||||
const request = {
|
||||
project_id: options?.projectId || undefined, // Include project_id for updates
|
||||
title: projectTitle,
|
||||
keywords: state.keywords,
|
||||
industry: state.industry,
|
||||
target_audience: state.targetAudience,
|
||||
research_mode: state.researchMode,
|
||||
config: state.config,
|
||||
intent_analysis: options?.intentAnalysis ? {
|
||||
success: options.intentAnalysis.success,
|
||||
intent: options.intentAnalysis.intent,
|
||||
analysis_summary: options.intentAnalysis.analysis_summary,
|
||||
suggested_queries: options.intentAnalysis.suggested_queries,
|
||||
suggested_keywords: options.intentAnalysis.suggested_keywords,
|
||||
suggested_angles: options.intentAnalysis.suggested_angles,
|
||||
quick_options: options.intentAnalysis.quick_options,
|
||||
trends_config: options.intentAnalysis.trends_config,
|
||||
} : null,
|
||||
confirmed_intent: options?.confirmedIntent || null,
|
||||
intent_result: options?.intentResult ? {
|
||||
success: options.intentResult.success,
|
||||
primary_answer: options.intentResult.primary_answer,
|
||||
secondary_answers: options.intentResult.secondary_answers,
|
||||
statistics: options.intentResult.statistics,
|
||||
expert_quotes: options.intentResult.expert_quotes,
|
||||
case_studies: options.intentResult.case_studies,
|
||||
trends: options.intentResult.trends,
|
||||
comparisons: options.intentResult.comparisons,
|
||||
best_practices: options.intentResult.best_practices,
|
||||
step_by_step: options.intentResult.step_by_step,
|
||||
pros_cons: options.intentResult.pros_cons,
|
||||
definitions: options.intentResult.definitions,
|
||||
examples: options.intentResult.examples,
|
||||
predictions: options.intentResult.predictions,
|
||||
executive_summary: options.intentResult.executive_summary,
|
||||
key_takeaways: options.intentResult.key_takeaways,
|
||||
suggested_outline: options.intentResult.suggested_outline,
|
||||
sources: options.intentResult.sources,
|
||||
confidence: options.intentResult.confidence,
|
||||
gaps_identified: options.intentResult.gaps_identified,
|
||||
follow_up_queries: options.intentResult.follow_up_queries,
|
||||
intent: options.intentResult.intent,
|
||||
google_trends_data: options.intentResult.google_trends_data,
|
||||
} : null,
|
||||
legacy_result: options?.legacyResult || null,
|
||||
current_step: state.currentStep,
|
||||
description: projectDescription,
|
||||
};
|
||||
|
||||
const { data } = await apiClient.post<{ success: boolean; asset_id?: number; project_id?: string; message: string }>(
|
||||
'/api/research/projects/save',
|
||||
request
|
||||
);
|
||||
|
||||
// After saving project, also save to ContentAsset library (following podcast maker pattern)
|
||||
if (data.success && data.project_id) {
|
||||
try {
|
||||
await saveResearchProjectToAssetLibrary({
|
||||
projectId: data.project_id,
|
||||
title: projectTitle,
|
||||
description: projectDescription,
|
||||
keywords: state.keywords,
|
||||
industry: state.industry,
|
||||
targetAudience: state.targetAudience,
|
||||
researchMode: state.researchMode,
|
||||
config: state.config,
|
||||
status: (options?.intentResult || options?.legacyResult) ? 'completed' : (options?.intentAnalysis ? 'draft' : 'draft'),
|
||||
currentStep: state.currentStep,
|
||||
});
|
||||
console.log(`[intentResearchApi] ✅ Research project saved to asset library: project_id=${data.project_id}, status=${(options?.intentResult || options?.legacyResult) ? 'completed' : 'draft'}`);
|
||||
} catch (error) {
|
||||
console.warn('[intentResearchApi] Failed to save research project to asset library:', error);
|
||||
// Don't fail the whole operation if asset creation fails
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error('[intentResearchApi] saveResearchProject failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || 'Failed to save research project',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save research project to Asset Library (ContentAsset).
|
||||
* Following podcast maker pattern: podcastApi.saveAudioToAssetLibrary()
|
||||
*/
|
||||
/**
|
||||
* Save research project to Asset Library (ContentAsset).
|
||||
* Following podcast maker pattern: podcastApi.saveAudioToAssetLibrary()
|
||||
*
|
||||
* Checks for existing asset with same project_id and updates it, otherwise creates new one.
|
||||
*/
|
||||
export const saveResearchProjectToAssetLibrary = async (params: {
|
||||
projectId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
keywords: string[];
|
||||
industry?: string;
|
||||
targetAudience?: string;
|
||||
researchMode?: string;
|
||||
config?: any;
|
||||
status?: string;
|
||||
currentStep?: number;
|
||||
}): Promise<{ assetId: number }> => {
|
||||
try {
|
||||
const fileUrl = `/api/research/projects/${params.projectId}/export`;
|
||||
const assetMetadata = {
|
||||
project_type: 'research_project',
|
||||
project_id: params.projectId,
|
||||
status: params.status || 'draft',
|
||||
keywords: params.keywords,
|
||||
industry: params.industry,
|
||||
target_audience: params.targetAudience,
|
||||
research_mode: params.researchMode,
|
||||
current_step: params.currentStep || 1,
|
||||
};
|
||||
|
||||
// Check if asset already exists for this project_id
|
||||
try {
|
||||
const searchResponse = await aiApiClient.get('/api/content-assets/', {
|
||||
params: {
|
||||
asset_type: 'text',
|
||||
source_module: 'research_tools',
|
||||
search: params.projectId,
|
||||
limit: 100,
|
||||
},
|
||||
});
|
||||
|
||||
// Find existing asset with matching project_id in metadata
|
||||
const existingAsset = searchResponse.data.assets?.find(
|
||||
(asset: any) =>
|
||||
asset.asset_metadata?.project_type === 'research_project' &&
|
||||
asset.asset_metadata?.project_id === params.projectId
|
||||
);
|
||||
|
||||
if (existingAsset) {
|
||||
// Update existing asset
|
||||
const updateResponse = await aiApiClient.put(`/api/content-assets/${existingAsset.id}`, {
|
||||
title: params.title,
|
||||
description: params.description || `Research project on ${params.keywords.slice(0, 3).join(', ')}`,
|
||||
tags: ['research', 'research_project', params.projectId, ...params.keywords.slice(0, 5)],
|
||||
asset_metadata: assetMetadata,
|
||||
});
|
||||
console.log(`[intentResearchApi] Updated existing ContentAsset for project ${params.projectId}: asset_id=${existingAsset.id}`);
|
||||
return { assetId: updateResponse.data.id };
|
||||
}
|
||||
} catch (searchError) {
|
||||
console.warn('[intentResearchApi] Failed to search for existing asset, creating new one:', searchError);
|
||||
// Continue to create new asset if search fails
|
||||
}
|
||||
|
||||
// Create new asset if none exists
|
||||
const response = await aiApiClient.post('/api/content-assets/', {
|
||||
asset_type: 'text',
|
||||
source_module: 'research_tools',
|
||||
filename: `research_${params.projectId}.json`,
|
||||
file_url: fileUrl,
|
||||
title: params.title,
|
||||
description: params.description || `Research project on ${params.keywords.slice(0, 3).join(', ')}`,
|
||||
tags: ['research', 'research_project', params.projectId, ...params.keywords.slice(0, 5)],
|
||||
asset_metadata: assetMetadata,
|
||||
cost: 0,
|
||||
mime_type: 'application/json',
|
||||
});
|
||||
console.log(`[intentResearchApi] ✅ Created new ContentAsset for project ${params.projectId}: asset_id=${response.data.id}, status=${params.status || 'draft'}, source_module=research_tools, asset_type=text`);
|
||||
return { assetId: response.data.id };
|
||||
} catch (error: any) {
|
||||
console.error('[intentResearchApi] saveResearchProjectToAssetLibrary failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* List research projects.
|
||||
*/
|
||||
export const listResearchProjects = async (params?: {
|
||||
status?: string;
|
||||
is_favorite?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ projects: any[]; total: number; limit: number; offset: number }> => {
|
||||
try {
|
||||
const { data } = await apiClient.get<{ projects: any[]; total: number; limit: number; offset: number }>(
|
||||
'/api/research/projects',
|
||||
{ params }
|
||||
);
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error('[intentResearchApi] listResearchProjects failed:', error);
|
||||
return {
|
||||
projects: [],
|
||||
total: 0,
|
||||
limit: params?.limit || 50,
|
||||
offset: params?.offset || 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single research project by ID.
|
||||
*/
|
||||
export const getResearchProject = async (projectId: string): Promise<any> => {
|
||||
try {
|
||||
const { data } = await apiClient.get(`/api/research/projects/${projectId}`);
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error('[intentResearchApi] getResearchProject failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a research project.
|
||||
*/
|
||||
export const deleteResearchProject = async (projectId: string): Promise<void> => {
|
||||
try {
|
||||
await apiClient.delete(`/api/research/projects/${projectId}`);
|
||||
} catch (error: any) {
|
||||
console.error('[intentResearchApi] deleteResearchProject failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle favorite status of a research project.
|
||||
*/
|
||||
export const toggleResearchProjectFavorite = async (projectId: string): Promise<any> => {
|
||||
try {
|
||||
// First get the project to check current favorite status
|
||||
const project = await getResearchProject(projectId);
|
||||
const newFavoriteStatus = !project.is_favorite;
|
||||
|
||||
// Update the project with new favorite status
|
||||
const { data } = await apiClient.put(`/api/research/projects/${projectId}`, {
|
||||
is_favorite: newFavoriteStatus,
|
||||
});
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error('[intentResearchApi] toggleResearchProjectFavorite failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const intentResearchApi = {
|
||||
analyzeIntent,
|
||||
executeIntentResearch,
|
||||
quickIntentResearch,
|
||||
saveResearchProject,
|
||||
saveResearchProjectToAssetLibrary,
|
||||
listResearchProjects,
|
||||
getResearchProject,
|
||||
deleteResearchProject,
|
||||
toggleResearchProjectFavorite,
|
||||
};
|
||||
|
||||
export default intentResearchApi;
|
||||
|
||||
Reference in New Issue
Block a user