mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-20 05:28:13 +02:00
feat(analytics): resilient Graph API calls, CRON sync every 6h, polling frontend, engagement+views metrics
This commit is contained in:
1 parent
942e33df62
commit
330c42c6ff
5 files changed
+130
-55
No files matched your search
@@ -1,19 +1,23 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { MetricsCard } from './MetricsCard';
|
import { MetricsCard } from './MetricsCard';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RefreshCw, Users, BarChart3, Eye } from 'lucide-react';
|
import { RefreshCw, Users, BarChart3, Eye, Heart } from 'lucide-react';
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { apiRequest } from '@/lib/queryClient';
|
import { apiRequest } from '@/lib/queryClient';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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() {
|
export function AnalyticsDashboard() {
|
||||||
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const autoRefreshDone = useRef(false);
|
||||||
|
|
||||||
// Fetch Pages
|
// Fetch Pages
|
||||||
const { data: pages = [], isLoading: isLoadingPages } = useQuery<any[]>({
|
const { data: pages = [], isLoading: isLoadingPages } = useQuery<any[]>({
|
||||||
@@ -27,10 +31,11 @@ export function AnalyticsDashboard() {
|
|||||||
}
|
}
|
||||||
}, [pages, selectedPageId]);
|
}, [pages, selectedPageId]);
|
||||||
|
|
||||||
// Fetch History for selected page
|
// Fetch History for selected page (with polling every 30s)
|
||||||
const { data: history = [], isLoading: isLoadingHistory } = useQuery<any[]>({
|
const { data: history = [], isLoading: isLoadingHistory } = useQuery<any[]>({
|
||||||
queryKey: [`/api/analytics/pages/${selectedPageId}/history`],
|
queryKey: [`/api/analytics/pages/${selectedPageId}/history`],
|
||||||
enabled: !!selectedPageId,
|
enabled: !!selectedPageId,
|
||||||
|
refetchInterval: 30_000, // Poll every 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh Mutation
|
// 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);
|
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) => ({
|
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,
|
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 latest = history[0];
|
||||||
const previous = history[1];
|
const previous = history[1];
|
||||||
|
|
||||||
const getTrend = (current: number, prev: number) => {
|
const getTrend = (current: number, prev: number): number => {
|
||||||
if (!prev) return 0;
|
if (!prev) return 0;
|
||||||
return Math.round(((current - prev) / prev) * 100);
|
return Math.round(((current - prev) / prev) * 100);
|
||||||
};
|
};
|
||||||
@@ -126,14 +146,27 @@ export function AnalyticsDashboard() {
|
|||||||
value={latest?.pageReach?.toLocaleString() || 0}
|
value={latest?.pageReach?.toLocaleString() || 0}
|
||||||
icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />}
|
icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />}
|
||||||
trend={latest && previous ? getTrend(latest.pageReach, previous.pageReach) : undefined}
|
trend={latest && previous ? getTrend(latest.pageReach, previous.pageReach) : undefined}
|
||||||
description="depuis la dernière mise à jour"
|
description="impressions uniques (jour)"
|
||||||
|
/>
|
||||||
|
<MetricsCard
|
||||||
|
title="Engagement"
|
||||||
|
value={(latest?.pageEngagement || 0).toLocaleString()}
|
||||||
|
icon={<Heart className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
trend={latest && previous ? getTrend(latest.pageEngagement || 0, previous.pageEngagement || 0) : undefined}
|
||||||
|
description="interactions totales (jour)"
|
||||||
|
/>
|
||||||
|
<MetricsCard
|
||||||
|
title="Vues de Page"
|
||||||
|
value={(latest?.pageViews || 0).toLocaleString()}
|
||||||
|
icon={<Eye className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
trend={latest && previous ? getTrend(latest.pageViews || 0, previous.pageViews || 0) : undefined}
|
||||||
|
description="vues totales (jour)"
|
||||||
/>
|
/>
|
||||||
{/* Add more metrics if available */}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Historique de Croissance & Portée</CardTitle>
|
<CardTitle>Historique de Croissance & Portée</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[300px]">
|
<div className="h-[300px]">
|
||||||
@@ -144,8 +177,11 @@ export function AnalyticsDashboard() {
|
|||||||
<YAxis yAxisId="left" />
|
<YAxis yAxisId="left" />
|
||||||
<YAxis yAxisId="right" orientation="right" />
|
<YAxis yAxisId="right" orientation="right" />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
<Line yAxisId="left" type="monotone" dataKey="followers" stroke="#8884d8" name="Abonnés" />
|
<Line yAxisId="left" type="monotone" dataKey="followers" stroke="#8884d8" name="Abonnés" />
|
||||||
<Line yAxisId="right" type="monotone" dataKey="reach" stroke="#82ca9d" name="Portée" />
|
<Line yAxisId="right" type="monotone" dataKey="reach" stroke="#82ca9d" name="Portée" />
|
||||||
|
<Line yAxisId="right" type="monotone" dataKey="engagement" stroke="#ff7f50" name="Engagement" />
|
||||||
|
<Line yAxisId="right" type="monotone" dataKey="views" stroke="#ffa500" name="Vues" />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+13
-1
@@ -1,11 +1,23 @@
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { TokenManager } from './services/token_manager';
|
import { TokenManager } from './services/token_manager';
|
||||||
|
import { AnalyticsService } from './services/analytics';
|
||||||
|
|
||||||
export function startTokenCron() {
|
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 () => {
|
cron.schedule('0 0 * * *', async () => {
|
||||||
console.log('[Cron] Running daily token check...');
|
console.log('[Cron] Running daily token check...');
|
||||||
await TokenManager.checkAndRefreshTokens();
|
await TokenManager.checkAndRefreshTokens();
|
||||||
});
|
});
|
||||||
console.log('[Cron] Token refresh job scheduled (0 0 * * *).');
|
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 * * *).');
|
||||||
}
|
}
|
||||||
@@ -111,6 +111,11 @@ export async function migrate() {
|
|||||||
ALTER TABLE "posts"
|
ALTER TABLE "posts"
|
||||||
ADD COLUMN IF NOT EXISTS "generation_error" text;
|
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.");
|
console.log("[Migration] Safe migration completed.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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) {
|
static async syncPageAnalytics(pageId: string) {
|
||||||
console.log(`[AnalyticsService] Syncing page ${pageId}`);
|
console.log(`[AnalyticsService] Syncing page ${pageId}`);
|
||||||
@@ -154,8 +155,8 @@ export class AnalyticsService {
|
|||||||
try { accessToken = TokenManager.decrypt(accessToken); } catch { }
|
try { accessToken = TokenManager.decrypt(accessToken); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Fetch Basic Page Info (Followers/Fans) - Essential
|
// 1. Fetch Basic Page Info (Followers/Fans) — Essential
|
||||||
// If this fails, the whole sync fails (because token might be bad)
|
// If this fails, the token is likely invalid → abort
|
||||||
let followers = page.followersCount || 0;
|
let followers = page.followersCount || 0;
|
||||||
try {
|
try {
|
||||||
const basicInfo = await GraphAPIClient.get<any>(page.pageId, {
|
const basicInfo = await GraphAPIClient.get<any>(page.pageId, {
|
||||||
@@ -168,13 +169,10 @@ export class AnalyticsService {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[AnalyticsService] Failed to fetch basic info for ${pageId}:`, error);
|
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';
|
const tokenPreview = accessToken ? `${accessToken.substring(0, 10)}...` : 'undefined';
|
||||||
console.log(`[AnalyticsService] DEBUG: Token used: ${tokenPreview}`);
|
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)
|
// Auth errors → mark token as expired and abort
|
||||||
// Codes: 190 (Invalid OAuth), 102 (API Session), 10 (Permission), 463 (Expired), 467 (Invalid)
|
|
||||||
if (error.code === 190 || error.code === 463 || error.code === 467) {
|
if (error.code === 190 || error.code === 463 || error.code === 467) {
|
||||||
console.log(`[AnalyticsService] Marking token as EXPIRED for page ${pageId}`);
|
console.log(`[AnalyticsService] Marking token as EXPIRED for page ${pageId}`);
|
||||||
await db.update(socialPages)
|
await db.update(socialPages)
|
||||||
@@ -183,63 +181,86 @@ export class AnalyticsService {
|
|||||||
lastTokenCheck: new Date()
|
lastTokenCheck: new Date()
|
||||||
})
|
})
|
||||||
.where(eq(socialPages.id, pageId));
|
.where(eq(socialPages.id, pageId));
|
||||||
|
|
||||||
// Return gracefully so the UI sees the updated status
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Failed to fetch Basic Info: ${error.message || error}`);
|
throw new Error(`Failed to fetch Basic Info: ${error.message || error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch Insights (Reach, Engagement) - Optional/Secondary
|
// Helper: fetch a single page insight metric safely
|
||||||
// If this fails, we catch it but still save the follower count
|
const fetchInsight = async (metricName: string): Promise<number> => {
|
||||||
let pageReach = 0;
|
try {
|
||||||
let pageViews = 0;
|
const result = await GraphAPIClient.get<any>(
|
||||||
|
`${page.pageId}/insights/${metricName}`,
|
||||||
try {
|
{
|
||||||
const fields = 'insights.metric(page_impressions,page_post_engagements).period(day)';
|
accessToken,
|
||||||
const result = await GraphAPIClient.get<any>(page.pageId, {
|
params: { period: 'day' }
|
||||||
accessToken,
|
}
|
||||||
params: { fields }
|
);
|
||||||
});
|
const data = result.data || [];
|
||||||
|
if (data.length > 0) {
|
||||||
const insights = result.insights?.data || [];
|
const values = data[0].values || [];
|
||||||
const findMetric = (name: string) => {
|
// Take the most recent value (last entry = yesterday)
|
||||||
const m = insights.find((i: any) => i.name === name);
|
return values.length > 0
|
||||||
// period(day) returns values array. We take the latest (yesterday usually)
|
? (values[values.length - 1]?.value || 0)
|
||||||
return m ? (m.values[m.values.length - 1]?.value || 0) : 0;
|
: 0;
|
||||||
};
|
}
|
||||||
|
return 0;
|
||||||
pageReach = findMetric('page_impressions');
|
} catch (error: any) {
|
||||||
} catch (error: any) {
|
if (error.code === 100) {
|
||||||
// Code 100: Invalid Parameter (often means metric not available for this page type)
|
console.warn(`[AnalyticsService] Metric "${metricName}" unavailable for ${pageId} (Code 100). Skipping.`);
|
||||||
if (error.code === 100) {
|
} else {
|
||||||
console.warn(`[AnalyticsService] Metrics unavailable for ${pageId} (Code 100). This is likely due to page type restrictions. Skipping insights.`);
|
console.warn(`[AnalyticsService] Failed to fetch "${metricName}" for ${pageId}:`, error.message || error);
|
||||||
} else {
|
}
|
||||||
console.warn(`[AnalyticsService] Failed to fetch insights for ${pageId}:`, error.message);
|
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)
|
await db.update(socialPages)
|
||||||
.set({
|
.set({
|
||||||
followersCount: followers,
|
followersCount: followers,
|
||||||
tokenStatus: 'valid', // If we got here, the token worked!
|
tokenStatus: 'valid',
|
||||||
lastTokenCheck: new Date()
|
lastTokenCheck: new Date()
|
||||||
})
|
})
|
||||||
.where(eq(socialPages.id, pageId));
|
.where(eq(socialPages.id, pageId));
|
||||||
|
|
||||||
// Insert History
|
// 4. Insert History snapshot
|
||||||
await db.insert(pageAnalyticsHistory).values({
|
await db.insert(pageAnalyticsHistory).values({
|
||||||
pageId,
|
pageId,
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
followersCount: followers,
|
followersCount: followers,
|
||||||
pageReach,
|
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<void> {
|
||||||
|
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.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,6 +69,7 @@ export const pageAnalyticsHistory = pgTable("page_analytics_history", {
|
|||||||
followersCount: integer("followers_count").default(0),
|
followersCount: integer("followers_count").default(0),
|
||||||
pageViews: integer("page_views").default(0),
|
pageViews: integer("page_views").default(0),
|
||||||
pageReach: integer("page_reach").default(0),
|
pageReach: integer("page_reach").default(0),
|
||||||
|
pageEngagement: integer("page_engagement").default(0),
|
||||||
|
|
||||||
createdAt: timestamp("created_at").defaultNow(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user