From 330c42c6ff9d09f6291d81fa1d4f93e8ecfdd395 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Thu, 12 Feb 2026 10:51:06 +0100 Subject: [PATCH] feat(analytics): resilient Graph API calls, CRON sync every 6h, polling frontend, engagement+views metrics --- .../analytics/AnalyticsDashboard.tsx | 60 ++++++++-- server/cron.ts | 14 ++- server/migrate.ts | 5 + server/services/analytics.ts | 105 +++++++++++------- shared/schema.ts | 1 + 5 files changed, 130 insertions(+), 55 deletions(-) diff --git a/client/src/components/analytics/AnalyticsDashboard.tsx b/client/src/components/analytics/AnalyticsDashboard.tsx index f8eddfa..1fe4319 100644 --- a/client/src/components/analytics/AnalyticsDashboard.tsx +++ b/client/src/components/analytics/AnalyticsDashboard.tsx @@ -1,19 +1,23 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { MetricsCard } from './MetricsCard'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Button } from '@/components/ui/button'; -import { RefreshCw, Users, BarChart3, Eye } from 'lucide-react'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { RefreshCw, Users, BarChart3, Eye, Heart } from 'lucide-react'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; import { format } from 'date-fns'; import { apiRequest } from '@/lib/queryClient'; import { useToast } from '@/hooks/use-toast'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +/** Maximum staleness before triggering an auto-refresh (6 hours in ms). */ +const STALE_THRESHOLD_MS = 6 * 60 * 60 * 1000; + export function AnalyticsDashboard() { const [selectedPageId, setSelectedPageId] = useState(null); const queryClient = useQueryClient(); const { toast } = useToast(); + const autoRefreshDone = useRef(false); // Fetch Pages const { data: pages = [], isLoading: isLoadingPages } = useQuery({ @@ -27,10 +31,11 @@ export function AnalyticsDashboard() { } }, [pages, selectedPageId]); - // Fetch History for selected page + // Fetch History for selected page (with polling every 30s) const { data: history = [], isLoading: isLoadingHistory } = useQuery({ queryKey: [`/api/analytics/pages/${selectedPageId}/history`], enabled: !!selectedPageId, + refetchInterval: 30_000, // Poll every 30 seconds }); // Refresh Mutation @@ -56,20 +61,35 @@ export function AnalyticsDashboard() { } }); + // Auto-refresh on first load if data is stale or empty + useEffect(() => { + if (!selectedPageId || autoRefreshDone.current || refreshMutation.isPending) return; + + const isStale = history.length === 0 + || (history[0]?.date && (Date.now() - new Date(history[0].date).getTime()) > STALE_THRESHOLD_MS); + + if (isStale) { + autoRefreshDone.current = true; + refreshMutation.mutate(); + } + }, [selectedPageId, history, refreshMutation]); + const selectedPage = pages.find((p: any) => p.id === selectedPageId); - // Prepare chart data (reverse to show chronological) + // Prepare chart data (reverse to show chronological order) const chartData = [...history].reverse().map((entry: any) => ({ - date: format(new Date(entry.date), 'MM/dd'), + date: format(new Date(entry.date), 'dd/MM'), followers: entry.followersCount, - reach: entry.pageReach + reach: entry.pageReach, + engagement: entry.pageEngagement || 0, + views: entry.pageViews || 0, })); - // Calculate trends (simple comparison with previous entry) + // Calculate trends (comparison between latest and previous entry) const latest = history[0]; const previous = history[1]; - const getTrend = (current: number, prev: number) => { + const getTrend = (current: number, prev: number): number => { if (!prev) return 0; return Math.round(((current - prev) / prev) * 100); }; @@ -126,14 +146,27 @@ export function AnalyticsDashboard() { value={latest?.pageReach?.toLocaleString() || 0} icon={} trend={latest && previous ? getTrend(latest.pageReach, previous.pageReach) : undefined} - description="depuis la dernière mise à jour" + description="impressions uniques (jour)" + /> + } + trend={latest && previous ? getTrend(latest.pageEngagement || 0, previous.pageEngagement || 0) : undefined} + description="interactions totales (jour)" + /> + } + trend={latest && previous ? getTrend(latest.pageViews || 0, previous.pageViews || 0) : undefined} + description="vues totales (jour)" /> - {/* Add more metrics if available */} - Historique de Croissance & Portée + Historique de Croissance & Portée
@@ -144,8 +177,11 @@ export function AnalyticsDashboard() { + + +
diff --git a/server/cron.ts b/server/cron.ts index 9f17f0c..27e0f08 100644 --- a/server/cron.ts +++ b/server/cron.ts @@ -1,11 +1,23 @@ import cron from 'node-cron'; import { TokenManager } from './services/token_manager'; +import { AnalyticsService } from './services/analytics'; export function startTokenCron() { - // Run every day at midnight (00:00) + // Run every day at midnight (00:00) — Token health check cron.schedule('0 0 * * *', async () => { console.log('[Cron] Running daily token check...'); await TokenManager.checkAndRefreshTokens(); }); console.log('[Cron] Token refresh job scheduled (0 0 * * *).'); + + // Run every 6 hours — Analytics sync for all pages + cron.schedule('0 */6 * * *', async () => { + console.log('[Cron] Running analytics sync for all pages...'); + try { + await AnalyticsService.syncAllPages(); + } catch (error) { + console.error('[Cron] Analytics sync failed:', error); + } + }); + console.log('[Cron] Analytics sync job scheduled (0 */6 * * *).'); } diff --git a/server/migrate.ts b/server/migrate.ts index c5ea319..5fb1a84 100644 --- a/server/migrate.ts +++ b/server/migrate.ts @@ -111,6 +111,11 @@ export async function migrate() { ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "generation_error" text; `); + // page_analytics_history.page_engagement + await client.query(` + ALTER TABLE "page_analytics_history" + ADD COLUMN IF NOT EXISTS "page_engagement" integer DEFAULT 0; + `); console.log("[Migration] Safe migration completed."); } catch (error) { diff --git a/server/services/analytics.ts b/server/services/analytics.ts index 9d026b8..627b467 100644 --- a/server/services/analytics.ts +++ b/server/services/analytics.ts @@ -138,7 +138,8 @@ export class AnalyticsService { } /** - * Syncs page-level analytics (Followers, Reach, etc.) + * Syncs page-level analytics (Followers, Reach, Engagement, Views) + * Each metric is fetched independently for maximum resilience. */ static async syncPageAnalytics(pageId: string) { console.log(`[AnalyticsService] Syncing page ${pageId}`); @@ -154,8 +155,8 @@ export class AnalyticsService { try { accessToken = TokenManager.decrypt(accessToken); } catch { } } - // 1. Fetch Basic Page Info (Followers/Fans) - Essential - // If this fails, the whole sync fails (because token might be bad) + // 1. Fetch Basic Page Info (Followers/Fans) — Essential + // If this fails, the token is likely invalid → abort let followers = page.followersCount || 0; try { const basicInfo = await GraphAPIClient.get(page.pageId, { @@ -168,13 +169,10 @@ export class AnalyticsService { } catch (error: any) { console.error(`[AnalyticsService] Failed to fetch basic info for ${pageId}:`, error); - // DEBUG: Log key details to diagnose token issues const tokenPreview = accessToken ? `${accessToken.substring(0, 10)}...` : 'undefined'; console.log(`[AnalyticsService] DEBUG: Token used: ${tokenPreview}`); - console.log(`[AnalyticsService] DEBUG: Full Error Object:`, JSON.stringify(error, null, 2)); - // Check if it's an Auth Error (Token invalid/expired) - // Codes: 190 (Invalid OAuth), 102 (API Session), 10 (Permission), 463 (Expired), 467 (Invalid) + // Auth errors → mark token as expired and abort if (error.code === 190 || error.code === 463 || error.code === 467) { console.log(`[AnalyticsService] Marking token as EXPIRED for page ${pageId}`); await db.update(socialPages) @@ -183,63 +181,86 @@ export class AnalyticsService { lastTokenCheck: new Date() }) .where(eq(socialPages.id, pageId)); - - // Return gracefully so the UI sees the updated status return; } throw new Error(`Failed to fetch Basic Info: ${error.message || error}`); } - // 2. Fetch Insights (Reach, Engagement) - Optional/Secondary - // If this fails, we catch it but still save the follower count - let pageReach = 0; - let pageViews = 0; - - try { - const fields = 'insights.metric(page_impressions,page_post_engagements).period(day)'; - const result = await GraphAPIClient.get(page.pageId, { - accessToken, - params: { fields } - }); - - const insights = result.insights?.data || []; - const findMetric = (name: string) => { - const m = insights.find((i: any) => i.name === name); - // period(day) returns values array. We take the latest (yesterday usually) - return m ? (m.values[m.values.length - 1]?.value || 0) : 0; - }; - - pageReach = findMetric('page_impressions'); - } catch (error: any) { - // Code 100: Invalid Parameter (often means metric not available for this page type) - if (error.code === 100) { - console.warn(`[AnalyticsService] Metrics unavailable for ${pageId} (Code 100). This is likely due to page type restrictions. Skipping insights.`); - } else { - console.warn(`[AnalyticsService] Failed to fetch insights for ${pageId}:`, error.message); + // Helper: fetch a single page insight metric safely + const fetchInsight = async (metricName: string): Promise => { + try { + const result = await GraphAPIClient.get( + `${page.pageId}/insights/${metricName}`, + { + accessToken, + params: { period: 'day' } + } + ); + const data = result.data || []; + if (data.length > 0) { + const values = data[0].values || []; + // Take the most recent value (last entry = yesterday) + return values.length > 0 + ? (values[values.length - 1]?.value || 0) + : 0; + } + return 0; + } catch (error: any) { + if (error.code === 100) { + console.warn(`[AnalyticsService] Metric "${metricName}" unavailable for ${pageId} (Code 100). Skipping.`); + } else { + console.warn(`[AnalyticsService] Failed to fetch "${metricName}" for ${pageId}:`, error.message || error); + } + return 0; } - // Continue execution to save at least the follower count - } + }; - // Update Page - Auto-heal status if sync works + // 2. Fetch each insight independently (resilient) + const pageReach = await fetchInsight('page_impressions_unique'); + const pageEngagement = await fetchInsight('page_post_engagements'); + const pageViews = await fetchInsight('page_views_total'); + + console.log(`[AnalyticsService] Metrics: followers=${followers}, reach=${pageReach}, engagement=${pageEngagement}, views=${pageViews}`); + + // 3. Update Page — auto-heal token status await db.update(socialPages) .set({ followersCount: followers, - tokenStatus: 'valid', // If we got here, the token worked! + tokenStatus: 'valid', lastTokenCheck: new Date() }) .where(eq(socialPages.id, pageId)); - // Insert History + // 4. Insert History snapshot await db.insert(pageAnalyticsHistory).values({ pageId, date: new Date(), followersCount: followers, pageReach, - pageViews + pageViews, + pageEngagement, }); - console.log(`[AnalyticsService] Synced page ${pageId}: ${followers} followers, ${pageReach} reach`); + console.log(`[AnalyticsService] Synced page ${pageId}: ${followers} followers, ${pageReach} reach, ${pageEngagement} engagement, ${pageViews} views`); + } + /** + * Syncs analytics for ALL active pages. + * Called by the CRON job. + */ + static async syncAllPages(): Promise { + console.log('[AnalyticsService] Starting sync for all pages...'); + const allPages = await db.query.socialPages.findMany(); + + for (const page of allPages) { + try { + await this.syncPageAnalytics(page.id); + } catch (error: any) { + console.error(`[AnalyticsService] Failed to sync page ${page.id}:`, error.message); + } + } + + console.log(`[AnalyticsService] Sync complete for ${allPages.length} pages.`); } } diff --git a/shared/schema.ts b/shared/schema.ts index ea48801..8561737 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -69,6 +69,7 @@ export const pageAnalyticsHistory = pgTable("page_analytics_history", { followersCount: integer("followers_count").default(0), pageViews: integer("page_views").default(0), pageReach: integer("page_reach").default(0), + pageEngagement: integer("page_engagement").default(0), createdAt: timestamp("created_at").defaultNow(), });