Scheduled research persona generation
This commit is contained in:
199
frontend/src/hooks/useOAuthTokenAlerts.ts
Normal file
199
frontend/src/hooks/useOAuthTokenAlerts.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Hook for polling OAuth token alerts and showing toast notifications
|
||||
*
|
||||
* This hook periodically checks for new OAuth token failure alerts
|
||||
* and displays toast notifications when detected.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { billingService } from '../services/billingService';
|
||||
import { UsageAlert } from '../types/billing';
|
||||
|
||||
interface UseOAuthTokenAlertsOptions {
|
||||
/**
|
||||
* Polling interval in milliseconds
|
||||
* @default 60000 (1 minute)
|
||||
*/
|
||||
interval?: number;
|
||||
|
||||
/**
|
||||
* Whether to enable polling
|
||||
* @default true
|
||||
*/
|
||||
enabled?: boolean;
|
||||
|
||||
/**
|
||||
* User ID - if not provided, will use localStorage or skip polling
|
||||
*/
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to poll for OAuth token alerts and show toast notifications
|
||||
*
|
||||
* Polls the UsageAlert API for new OAuth token alerts (oauth_token_failure, oauth_token_warning)
|
||||
* and displays toast notifications when new unread alerts are detected.
|
||||
*
|
||||
* @param options Polling configuration options
|
||||
* @returns Object with polling state and controls
|
||||
*/
|
||||
export function useOAuthTokenAlerts(options: UseOAuthTokenAlertsOptions = {}) {
|
||||
const {
|
||||
interval = 60000, // 1 minute default
|
||||
enabled = true,
|
||||
userId
|
||||
} = options;
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastAlertIdsRef = useRef<Set<number>>(new Set());
|
||||
const isPollingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actualUserId = userId || localStorage.getItem('user_id');
|
||||
if (!actualUserId) {
|
||||
console.debug('useOAuthTokenAlerts: No user ID available, skipping polling');
|
||||
return;
|
||||
}
|
||||
|
||||
const pollAlerts = async () => {
|
||||
// Prevent concurrent polls
|
||||
if (isPollingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isPollingRef.current = true;
|
||||
|
||||
// Fetch unread alerts only
|
||||
const alerts = await billingService.getUsageAlerts(actualUserId, true);
|
||||
|
||||
// Filter for OAuth token alerts
|
||||
const oauthAlerts = alerts.filter(
|
||||
(alert: UsageAlert) =>
|
||||
alert.type === 'oauth_token_failure' ||
|
||||
alert.type === 'oauth_token_warning'
|
||||
);
|
||||
|
||||
// Find new alerts (not in our tracked set)
|
||||
const newAlerts = oauthAlerts.filter(
|
||||
(alert: UsageAlert) => !lastAlertIdsRef.current.has(alert.id)
|
||||
);
|
||||
|
||||
// Show toast notifications for new alerts
|
||||
for (const alert of newAlerts) {
|
||||
// Map severity to notification type
|
||||
const notificationType =
|
||||
alert.severity === 'error' ? 'error' :
|
||||
alert.severity === 'warning' ? 'warning' :
|
||||
'info';
|
||||
|
||||
// Show toast notification
|
||||
showToastNotification(alert.message, notificationType);
|
||||
|
||||
// Track this alert ID
|
||||
lastAlertIdsRef.current.add(alert.id);
|
||||
|
||||
console.log(`OAuth token alert notification: ${alert.title}`, {
|
||||
type: alert.type,
|
||||
severity: alert.severity,
|
||||
platform: extractPlatformFromTitle(alert.title)
|
||||
});
|
||||
}
|
||||
|
||||
// Update tracked alert IDs (keep only current alerts to handle deletions)
|
||||
lastAlertIdsRef.current = new Set(oauthAlerts.map((a: UsageAlert) => a.id));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error polling OAuth token alerts:', error);
|
||||
// Don't show error to user - this is background polling
|
||||
} finally {
|
||||
isPollingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Poll immediately on mount
|
||||
pollAlerts();
|
||||
|
||||
// Set up periodic polling
|
||||
intervalRef.current = setInterval(pollAlerts, interval);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, interval, userId]);
|
||||
|
||||
return {
|
||||
isPolling: isPollingRef.current
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification using DOM-based approach
|
||||
* Works globally across the app, regardless of which component is mounted
|
||||
*/
|
||||
function showToastNotification(message: string, type: 'error' | 'warning' | 'info' = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
|
||||
// Determine background color based on type
|
||||
const bgColors = {
|
||||
error: '#f44336',
|
||||
warning: '#ff9800',
|
||||
info: '#2196f3',
|
||||
success: '#4caf50'
|
||||
};
|
||||
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
z-index: 10000;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
background-color: ${bgColors[type] || bgColors.info};
|
||||
word-wrap: break-word;
|
||||
`;
|
||||
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Remove after 5 seconds (longer for important alerts)
|
||||
const duration = type === 'error' ? 7000 : 5000;
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(toast)) {
|
||||
document.body.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract platform name from alert title
|
||||
* Used for logging/debugging
|
||||
*/
|
||||
function extractPlatformFromTitle(title: string): string {
|
||||
const match = title.match(/^(Google Search Console|Bing Webmaster Tools|WordPress|Wix)/);
|
||||
return match ? match[1] : 'Unknown';
|
||||
}
|
||||
|
||||
@@ -37,9 +37,20 @@ export function usePolling(
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Debug state changes
|
||||
// Debug state changes only in development and when state actually changes meaningfully
|
||||
const prevStateRef = useRef({ isPolling: false, currentStatus: 'idle', progressCount: 0 });
|
||||
useEffect(() => {
|
||||
console.log('Polling state changed:', { isPolling, currentStatus, progressCount: progressMessages.length });
|
||||
const currentState = { isPolling, currentStatus, progressCount: progressMessages.length };
|
||||
const prevState = prevStateRef.current;
|
||||
|
||||
// Only log if state meaningfully changed (not just a re-render)
|
||||
if (process.env.NODE_ENV === 'development' &&
|
||||
(prevState.isPolling !== currentState.isPolling ||
|
||||
prevState.currentStatus !== currentState.currentStatus ||
|
||||
(prevState.isPolling && currentState.progressCount !== prevState.progressCount))) {
|
||||
console.log('Polling state changed:', currentState);
|
||||
prevStateRef.current = currentState;
|
||||
}
|
||||
}, [isPolling, currentStatus, progressMessages.length]);
|
||||
|
||||
|
||||
@@ -48,26 +59,34 @@ export function usePolling(
|
||||
const currentTaskIdRef = useRef<string | null>(null);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
console.log('stopPolling called');
|
||||
// Only log and clear if actually polling (not just cleanup on unmount when idle)
|
||||
const wasPolling = intervalRef.current !== null || isPolling;
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
console.log('Setting isPolling to false');
|
||||
setIsPolling(false);
|
||||
attemptsRef.current = 0;
|
||||
currentTaskIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Only update state if actually was polling (prevents unnecessary state updates)
|
||||
if (wasPolling) {
|
||||
setIsPolling(false);
|
||||
attemptsRef.current = 0;
|
||||
currentTaskIdRef.current = null;
|
||||
|
||||
// Only log meaningful stops (when actually stopping active polling)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('stopPolling: Stopped active polling');
|
||||
}
|
||||
}
|
||||
// Silently handle cleanup when not polling (common on unmount/re-render)
|
||||
}, [isPolling]);
|
||||
|
||||
const startPolling = useCallback((taskId: string) => {
|
||||
console.log('startPolling called with taskId:', taskId);
|
||||
if (isPolling) {
|
||||
console.log('Already polling, stopping first');
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
currentTaskIdRef.current = taskId;
|
||||
console.log('Setting isPolling to true');
|
||||
setIsPolling(true);
|
||||
setCurrentStatus('pending');
|
||||
setProgressMessages([]);
|
||||
@@ -83,30 +102,25 @@ export function usePolling(
|
||||
|
||||
try {
|
||||
const status = await pollFunction(currentTaskIdRef.current);
|
||||
console.log('Polling status update:', status);
|
||||
setCurrentStatus(status.status);
|
||||
|
||||
// Update progress messages
|
||||
if (status.progress_messages && status.progress_messages.length > 0) {
|
||||
console.log('Progress messages received:', status.progress_messages);
|
||||
console.log('Previous progress messages count:', progressMessages.length);
|
||||
setProgressMessages(status.progress_messages);
|
||||
console.log('Progress messages state updated to:', status.progress_messages.length, 'messages');
|
||||
|
||||
// Call onProgress with the latest message for backward compatibility
|
||||
const latestMessage = status.progress_messages[status.progress_messages.length - 1];
|
||||
console.log('Latest progress message:', latestMessage.message);
|
||||
onProgress?.(latestMessage.message);
|
||||
}
|
||||
|
||||
if (status.status === 'completed') {
|
||||
console.log('✅ Task completed - stopping polling immediately');
|
||||
console.info('[usePolling] ✅ Task completed', { taskId: currentTaskIdRef.current });
|
||||
setResult(status.result);
|
||||
onComplete?.(status.result);
|
||||
stopPolling();
|
||||
return; // Exit early to prevent further processing
|
||||
} else if (status.status === 'failed') {
|
||||
console.log('❌ Task failed - stopping polling immediately');
|
||||
console.error('[usePolling] ❌ Task failed:', status.error);
|
||||
setError(status.error || 'Task failed');
|
||||
onError?.(status.error || 'Task failed');
|
||||
|
||||
@@ -139,7 +153,7 @@ export function usePolling(
|
||||
};
|
||||
|
||||
console.log('usePolling: Triggering subscription error handler with:', mockError);
|
||||
const handled = triggerSubscriptionError(mockError);
|
||||
const handled = await triggerSubscriptionError(mockError);
|
||||
|
||||
if (!handled) {
|
||||
console.warn('usePolling: Subscription error handler did not handle the error');
|
||||
@@ -159,19 +173,11 @@ export function usePolling(
|
||||
// This is a fallback in case the interceptor doesn't catch it
|
||||
const axiosError = err as any;
|
||||
if (axiosError?.response?.status === 429 || axiosError?.response?.status === 402) {
|
||||
console.log('usePolling: Detected subscription error in axios error response', {
|
||||
status: axiosError.response.status,
|
||||
data: axiosError.response.data,
|
||||
errorDataKeys: axiosError.response.data ? Object.keys(axiosError.response.data) : null
|
||||
});
|
||||
|
||||
// Trigger subscription error handler (modal will show)
|
||||
// Note: The interceptor may have already called this, but we call it again to be safe
|
||||
const handled = triggerSubscriptionError(axiosError);
|
||||
console.log('usePolling: triggerSubscriptionError returned', handled);
|
||||
const handled = await triggerSubscriptionError(axiosError);
|
||||
|
||||
if (handled) {
|
||||
console.log('usePolling: Subscription error handled, stopping polling - modal should be visible');
|
||||
const errorMsg = axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
'Subscription limit exceeded';
|
||||
@@ -180,7 +186,7 @@ export function usePolling(
|
||||
stopPolling();
|
||||
return; // Exit early - don't continue processing
|
||||
} else {
|
||||
console.warn('usePolling: Subscription error not handled by global handler, dispatching fallback event');
|
||||
console.warn('[usePolling] Subscription error not handled by global handler');
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('subscription-error', { detail: axiosError }));
|
||||
} catch (eventError) {
|
||||
@@ -210,10 +216,14 @@ export function usePolling(
|
||||
intervalRef.current = setInterval(poll, interval);
|
||||
}, [isPolling, interval, onProgress, onComplete, onError, pollFunction, stopPolling, progressMessages.length]);
|
||||
|
||||
// Cleanup on unmount
|
||||
// Cleanup on unmount - only if actually polling
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
// Only call stopPolling if we have an active interval (actually polling)
|
||||
// This prevents unnecessary cleanup calls when component unmounts while idle
|
||||
if (intervalRef.current) {
|
||||
stopPolling();
|
||||
}
|
||||
};
|
||||
}, [stopPolling]);
|
||||
|
||||
|
||||
@@ -49,11 +49,22 @@ export const useRealTimeData = (options: RealTimeDataOptions) => {
|
||||
setState(prev => ({ ...prev, isConnecting: true, error: null }));
|
||||
|
||||
try {
|
||||
// For development, use a mock WebSocket connection
|
||||
// In production, this would be the actual WebSocket URL
|
||||
const wsUrl = process.env.NODE_ENV === 'development'
|
||||
? `ws://localhost:8000/ws/strategy/${strategyId}/live`
|
||||
: `wss://api.alwrity.com/ws/strategy/${strategyId}/live`;
|
||||
// Build WebSocket URL from environment variables
|
||||
// Consistent with API URL pattern - no hardcoded localhost
|
||||
const apiUrl = process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL || '';
|
||||
|
||||
// In development, use proxy (empty string means use same origin)
|
||||
// In production, derive WebSocket URL from API URL
|
||||
let wsUrl: string;
|
||||
if (!apiUrl || apiUrl === '') {
|
||||
// Development: use proxy (same origin WebSocket)
|
||||
wsUrl = `ws://${window.location.host}/ws/strategy/${strategyId}/live`;
|
||||
} else {
|
||||
// Production: derive from API URL
|
||||
const wsProtocol = apiUrl.startsWith('https://') ? 'wss://' : 'ws://';
|
||||
const wsHost = apiUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
wsUrl = `${wsProtocol}${wsHost}/ws/strategy/${strategyId}/live`;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
Reference in New Issue
Block a user