File size: 5,314 Bytes
9a43362
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d9ba1d6
 
 
 
 
 
 
9a43362
 
d9ba1d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a43362
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
import type { EngagementMetrics, CarouselAnalytics } from '@/types/analytics';

/**
 * Fetch engagement metrics from publisher APIs (Zernio/Postiz).
 * Gracefully falls back to mock data if APIs are unavailable.
 */

async function fetchFromZernio(postId: string): Promise<EngagementMetrics | null> {
  try {
    const apiKey = process.env.ZERNIO_API_KEY;
    if (!apiKey) {
      console.warn('[analytics] ZERNIO_API_KEY not set, using fallback');
      return null;
    }

    const response = await fetch(`https://api.zernio.com/posts/${postId}/metrics`, {
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (!response.ok) {
      console.error('[analytics] Zernio API error:', response.status);
      return null;
    }

    const data = await response.json();
    return {
      likes: data.likes || 0,
      comments: data.comments || 0,
      shares: data.shares || 0,
      saves: data.saves || 0,
      impressions: data.impressions || 0,
      reach: data.reach || 0,
      engagement_rate: ((data.likes + data.comments + data.shares) / data.impressions) * 100 || 0,
    };
  } catch (error) {
    console.error('[analytics] fetchFromZernio failed:', error);
    return null;
  }
}

async function fetchFromPostiz(postId: string): Promise<EngagementMetrics | null> {
  try {
    const apiKey = process.env.POSTIZ_API_KEY;
    if (!apiKey) {
      console.warn('[analytics] POSTIZ_API_KEY not set, using fallback');
      return null;
    }

    const response = await fetch(`https://api.postiz.com/posts/${postId}/metrics`, {
      headers: { 'X-API-Key': apiKey },
    });

    if (!response.ok) {
      console.error('[analytics] Postiz API error:', response.status);
      return null;
    }

    const data = await response.json();
    return {
      likes: data.likeCount || 0,
      comments: data.commentCount || 0,
      shares: data.shareCount || 0,
      saves: data.saveCount || 0,
      impressions: data.impressionCount || 0,
      reach: data.reachCount || 0,
      engagement_rate:
        ((data.likeCount + data.commentCount + data.shareCount) / data.impressionCount) * 100 || 0,
    };
  } catch (error) {
    console.error('[analytics] fetchFromPostiz failed:', error);
    return null;
  }
}

function generateMockMetrics(): EngagementMetrics {
  // Realistic mock data for testing without real APIs
  const impressions = Math.floor(Math.random() * 5000) + 500;
  const reach = Math.floor(impressions * 0.7);
  const likes = Math.floor(impressions * 0.08);
  const comments = Math.floor(impressions * 0.02);
  const shares = Math.floor(impressions * 0.01);
  const saves = Math.floor(impressions * 0.03);

  return {
    likes,
    comments,
    shares,
    saves,
    impressions,
    reach,
    engagement_rate: ((likes + comments + shares) / impressions) * 100,
  };
}

/**
 * Fetch engagement metrics for a carousel post.
 * Tries Zernio first, then Postiz, then returns mock data.
 */
export async function getEngagementMetrics(
  carouselId: string,
  platform: 'instagram' | 'threads' | 'linkedin',
  externalPostId?: string
): Promise<EngagementMetrics> {
  // If we have an external post ID from publisher, try real APIs
  if (externalPostId) {
    // Try Zernio first (primary publisher)
    const zernioMetrics = await fetchFromZernio(externalPostId);
    if (zernioMetrics) return zernioMetrics;

    // Try Postiz as fallback
    const postizMetrics = await fetchFromPostiz(externalPostId);
    if (postizMetrics) return postizMetrics;
  }

  // Fall back to mock data (Phase 11: real DB integration in future phases)
  console.warn(`[analytics] No real metrics for carousel ${carouselId}, using mock data`);
  return generateMockMetrics();
}

export async function getRecentCarouselAnalytics(
  limit: number = 10
): Promise<CarouselAnalytics[]> {
  try {
    // Import dynamically so it doesn't break analytics if sqlite is missing in pure web enviroments
    const { getScheduledCarousels } = require('@/lib/scheduler/scheduler');
    const published = await getScheduledCarousels('published');
    
    if (!published || published.length === 0) {
      return [];
    }

    const carouselsWithMetrics: CarouselAnalytics[] = await Promise.all(
      published.slice(0, limit).map(async (carousel: any) => {
        // Grab the first platform from their platforms array, defaulting to instagram
        const platform = (carousel.platforms && carousel.platforms.length > 0) ? carousel.platforms[0] : 'instagram';
        
        return {
          carousel_id: carousel.carouselId,
          title: carousel.title || 'Untitled Post',
          platform: platform as 'instagram' | 'threads' | 'linkedin',
          published_at: carousel.publishedAt || carousel.scheduledFor,
          slide_count: 5, // Default fallback since scheduler doesn't currently store this context
          template_type: 'bold-statement', // Default fallback
          palette: 'blue', // Default fallback
          metrics: await getEngagementMetrics(carousel.carouselId, platform),
        };
      })
    );

    return carouselsWithMetrics.sort(
      (a, b) =>
        new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
    );
  } catch (error) {
    console.error('[analytics] getRecentCarouselAnalytics failed:', error);
    return [];
  }
}