@nextsparkjs/plugin-social-media-publisher 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +76 -0
- package/README.md +423 -0
- package/api/social/connect/callback/route.ts +669 -0
- package/api/social/connect/route.ts +327 -0
- package/api/social/disconnect/route.ts +187 -0
- package/api/social/publish/route.ts +402 -0
- package/docs/01-getting-started/01-introduction.md +471 -0
- package/docs/01-getting-started/02-installation.md +471 -0
- package/docs/01-getting-started/03-configuration.md +515 -0
- package/docs/02-core-features/01-oauth-integration.md +501 -0
- package/docs/02-core-features/02-publishing.md +527 -0
- package/docs/02-core-features/03-token-management.md +661 -0
- package/docs/02-core-features/04-audit-logging.md +646 -0
- package/docs/03-advanced-usage/01-provider-apis.md +764 -0
- package/docs/03-advanced-usage/02-custom-integrations.md +695 -0
- package/docs/03-advanced-usage/03-per-client-architecture.md +575 -0
- package/docs/04-use-cases/01-agency-management.md +661 -0
- package/docs/04-use-cases/02-content-publishing.md +668 -0
- package/docs/04-use-cases/03-analytics-reporting.md +748 -0
- package/entities/audit-logs/audit-logs.config.ts +150 -0
- package/lib/oauth-helper.ts +167 -0
- package/lib/providers/facebook.ts +672 -0
- package/lib/providers/index.ts +21 -0
- package/lib/providers/instagram.ts +791 -0
- package/lib/validation.ts +155 -0
- package/migrations/001_social_media_tables.sql +167 -0
- package/package.json +15 -0
- package/plugin.config.ts +81 -0
- package/tsconfig.json +47 -0
- package/types/social.types.ts +171 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
# Analytics and Reporting
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This guide covers how to leverage the Social Media Publisher plugin's analytics capabilities to track performance, generate reports, and make data-driven decisions for social media management.
|
|
6
|
+
|
|
7
|
+
**Covered Topics:**
|
|
8
|
+
- Account-level analytics
|
|
9
|
+
- Post performance tracking
|
|
10
|
+
- Client reporting
|
|
11
|
+
- Comparative analysis
|
|
12
|
+
- Export and visualization
|
|
13
|
+
- ROI measurement
|
|
14
|
+
|
|
15
|
+
## Account-Level Analytics
|
|
16
|
+
|
|
17
|
+
### Instagram Business Insights
|
|
18
|
+
|
|
19
|
+
**Fetch Account Metrics:**
|
|
20
|
+
```typescript
|
|
21
|
+
import { InstagramAPI } from '@/contents/plugins/social-media-publisher/lib/providers/instagram'
|
|
22
|
+
import { TokenEncryption } from '@/core/lib/oauth/encryption'
|
|
23
|
+
|
|
24
|
+
export async function getInstagramAnalytics(accountId: string) {
|
|
25
|
+
// Get account with encrypted token
|
|
26
|
+
const account = await query(`
|
|
27
|
+
SELECT * FROM "clients_social_platforms"
|
|
28
|
+
WHERE id = $1
|
|
29
|
+
`, [accountId])
|
|
30
|
+
|
|
31
|
+
if (account.rowCount === 0) throw new Error('Account not found')
|
|
32
|
+
|
|
33
|
+
// Decrypt token
|
|
34
|
+
const [encrypted, iv, keyId] = account.rows[0].accessToken.split(':')
|
|
35
|
+
const decryptedToken = await TokenEncryption.decrypt(encrypted, iv, keyId)
|
|
36
|
+
|
|
37
|
+
// Fetch insights from Instagram API
|
|
38
|
+
const insights = await InstagramAPI.getAccountInsights(
|
|
39
|
+
account.rows[0].platformAccountId,
|
|
40
|
+
decryptedToken
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
// Fetch account info
|
|
44
|
+
const info = await InstagramAPI.getAccountInfo(
|
|
45
|
+
account.rows[0].platformAccountId,
|
|
46
|
+
decryptedToken
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
account: {
|
|
51
|
+
id: account.rows[0].id,
|
|
52
|
+
username: account.rows[0].platformAccountName,
|
|
53
|
+
followersCount: info.followersCount,
|
|
54
|
+
followsCount: info.followsCount,
|
|
55
|
+
mediaCount: info.mediaCount,
|
|
56
|
+
profilePictureUrl: info.profilePictureUrl
|
|
57
|
+
},
|
|
58
|
+
insights: {
|
|
59
|
+
impressions: insights.impressions,
|
|
60
|
+
reach: insights.reach,
|
|
61
|
+
engagement: insights.engagement,
|
|
62
|
+
likes: insights.likes,
|
|
63
|
+
comments: insights.comments,
|
|
64
|
+
saves: insights.saves,
|
|
65
|
+
profileViews: insights.profileViews
|
|
66
|
+
},
|
|
67
|
+
metrics: {
|
|
68
|
+
engagementRate: ((insights.engagement / insights.reach) * 100).toFixed(2),
|
|
69
|
+
averageEngagementPerPost: (insights.engagement / info.mediaCount).toFixed(0),
|
|
70
|
+
savesRate: ((insights.saves / insights.reach) * 100).toFixed(2)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Display in Dashboard:**
|
|
77
|
+
```typescript
|
|
78
|
+
// app/dashboard/analytics/[accountId]/page.tsx
|
|
79
|
+
export default async function AccountAnalyticsPage({
|
|
80
|
+
params
|
|
81
|
+
}: {
|
|
82
|
+
params: Promise<{ accountId: string }>
|
|
83
|
+
}) {
|
|
84
|
+
const { accountId } = await params
|
|
85
|
+
const analytics = await getInstagramAnalytics(accountId)
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="max-w-6xl mx-auto p-6">
|
|
89
|
+
{/* Header */}
|
|
90
|
+
<div className="flex items-center gap-4 mb-8">
|
|
91
|
+
<img
|
|
92
|
+
src={analytics.account.profilePictureUrl}
|
|
93
|
+
alt={analytics.account.username}
|
|
94
|
+
className="w-20 h-20 rounded-full"
|
|
95
|
+
/>
|
|
96
|
+
<div>
|
|
97
|
+
<h1 className="text-2xl font-bold">@{analytics.account.username}</h1>
|
|
98
|
+
<p className="text-gray-600">
|
|
99
|
+
{analytics.account.followersCount.toLocaleString()} followers
|
|
100
|
+
</p>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{/* Metrics Grid */}
|
|
105
|
+
<div className="grid grid-cols-4 gap-4 mb-8">
|
|
106
|
+
<MetricCard
|
|
107
|
+
title="Total Impressions"
|
|
108
|
+
value={analytics.insights.impressions.toLocaleString()}
|
|
109
|
+
icon="👁️"
|
|
110
|
+
/>
|
|
111
|
+
<MetricCard
|
|
112
|
+
title="Reach"
|
|
113
|
+
value={analytics.insights.reach.toLocaleString()}
|
|
114
|
+
icon="📊"
|
|
115
|
+
/>
|
|
116
|
+
<MetricCard
|
|
117
|
+
title="Engagement"
|
|
118
|
+
value={analytics.insights.engagement.toLocaleString()}
|
|
119
|
+
icon="❤️"
|
|
120
|
+
/>
|
|
121
|
+
<MetricCard
|
|
122
|
+
title="Engagement Rate"
|
|
123
|
+
value={`${analytics.metrics.engagementRate}%`}
|
|
124
|
+
icon="📈"
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{/* Detailed Metrics */}
|
|
129
|
+
<div className="grid grid-cols-2 gap-6">
|
|
130
|
+
<Card>
|
|
131
|
+
<CardHeader>Engagement Breakdown</CardHeader>
|
|
132
|
+
<CardContent>
|
|
133
|
+
<div className="space-y-3">
|
|
134
|
+
<MetricRow label="Likes" value={analytics.insights.likes} />
|
|
135
|
+
<MetricRow label="Comments" value={analytics.insights.comments} />
|
|
136
|
+
<MetricRow label="Saves" value={analytics.insights.saves} />
|
|
137
|
+
<MetricRow label="Profile Views" value={analytics.insights.profileViews} />
|
|
138
|
+
</div>
|
|
139
|
+
</CardContent>
|
|
140
|
+
</Card>
|
|
141
|
+
|
|
142
|
+
<Card>
|
|
143
|
+
<CardHeader>Performance Metrics</CardHeader>
|
|
144
|
+
<CardContent>
|
|
145
|
+
<div className="space-y-3">
|
|
146
|
+
<MetricRow
|
|
147
|
+
label="Engagement Rate"
|
|
148
|
+
value={`${analytics.metrics.engagementRate}%`}
|
|
149
|
+
/>
|
|
150
|
+
<MetricRow
|
|
151
|
+
label="Avg. Engagement/Post"
|
|
152
|
+
value={analytics.metrics.averageEngagementPerPost}
|
|
153
|
+
/>
|
|
154
|
+
<MetricRow
|
|
155
|
+
label="Saves Rate"
|
|
156
|
+
value={`${analytics.metrics.savesRate}%`}
|
|
157
|
+
/>
|
|
158
|
+
<MetricRow
|
|
159
|
+
label="Total Posts"
|
|
160
|
+
value={analytics.account.mediaCount}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
</CardContent>
|
|
164
|
+
</Card>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function MetricCard({ title, value, icon }: {
|
|
171
|
+
title: string
|
|
172
|
+
value: string
|
|
173
|
+
icon: string
|
|
174
|
+
}) {
|
|
175
|
+
return (
|
|
176
|
+
<div className="border rounded-lg p-4">
|
|
177
|
+
<div className="text-2xl mb-2">{icon}</div>
|
|
178
|
+
<p className="text-sm text-gray-600">{title}</p>
|
|
179
|
+
<p className="text-2xl font-bold">{value}</p>
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function MetricRow({ label, value }: { label: string; value: number | string }) {
|
|
185
|
+
return (
|
|
186
|
+
<div className="flex justify-between items-center">
|
|
187
|
+
<span className="text-gray-600">{label}</span>
|
|
188
|
+
<span className="font-semibold">{value}</span>
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Facebook Page Insights
|
|
195
|
+
|
|
196
|
+
**Similar to Instagram but with Page-specific metrics:**
|
|
197
|
+
```typescript
|
|
198
|
+
export async function getFacebookPageAnalytics(accountId: string) {
|
|
199
|
+
// Similar structure to Instagram
|
|
200
|
+
const account = await getAccount(accountId)
|
|
201
|
+
const decryptedToken = await decryptToken(account.accessToken)
|
|
202
|
+
|
|
203
|
+
const insights = await FacebookAPI.getPageInsights(
|
|
204
|
+
account.platformAccountId,
|
|
205
|
+
decryptedToken
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
const pageInfo = await FacebookAPI.getPageInfo(
|
|
209
|
+
account.platformAccountId,
|
|
210
|
+
decryptedToken
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
account: {
|
|
215
|
+
id: account.id,
|
|
216
|
+
name: account.platformAccountName,
|
|
217
|
+
fanCount: pageInfo.fanCount,
|
|
218
|
+
about: pageInfo.about,
|
|
219
|
+
link: pageInfo.link
|
|
220
|
+
},
|
|
221
|
+
insights: {
|
|
222
|
+
impressions: insights.impressions,
|
|
223
|
+
reach: insights.reach,
|
|
224
|
+
engagement: insights.engagement,
|
|
225
|
+
reactions: insights.reactions,
|
|
226
|
+
comments: insights.comments,
|
|
227
|
+
shares: insights.shares
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Post Performance Tracking
|
|
234
|
+
|
|
235
|
+
### Individual Post Analytics
|
|
236
|
+
|
|
237
|
+
**Track Post Performance from Audit Logs:**
|
|
238
|
+
```typescript
|
|
239
|
+
export async function getPostPerformance(clientId: string, days: number = 30) {
|
|
240
|
+
// Get all published posts from audit logs
|
|
241
|
+
const posts = await query(`
|
|
242
|
+
SELECT
|
|
243
|
+
al.id,
|
|
244
|
+
al."createdAt" as "publishedAt",
|
|
245
|
+
al.details->>'postId' as "postId",
|
|
246
|
+
al.details->>'postUrl' as "postUrl",
|
|
247
|
+
al.details->>'platform' as platform,
|
|
248
|
+
al.details->>'accountName' as "accountName",
|
|
249
|
+
al.details->>'caption' as caption,
|
|
250
|
+
al.details->>'imageUrl' as "imageUrl",
|
|
251
|
+
al."accountId"
|
|
252
|
+
FROM "audit_logs" al
|
|
253
|
+
JOIN "clients_social_platforms" csp ON csp.id = al."accountId"
|
|
254
|
+
WHERE csp."parentId" = $1
|
|
255
|
+
AND al.action = 'post_published'
|
|
256
|
+
AND al."createdAt" > NOW() - INTERVAL '${days} days'
|
|
257
|
+
ORDER BY al."createdAt" DESC
|
|
258
|
+
`, [clientId])
|
|
259
|
+
|
|
260
|
+
// Enrich with real-time insights from APIs
|
|
261
|
+
const enrichedPosts = await Promise.all(
|
|
262
|
+
posts.rows.map(async (post) => {
|
|
263
|
+
try {
|
|
264
|
+
const account = await getAccount(post.accountId)
|
|
265
|
+
const token = await decryptToken(account.accessToken)
|
|
266
|
+
|
|
267
|
+
let insights = {}
|
|
268
|
+
|
|
269
|
+
if (post.platform === 'instagram_business') {
|
|
270
|
+
insights = await InstagramAPI.getMediaInsights(post.postId, token)
|
|
271
|
+
} else {
|
|
272
|
+
// Facebook post insights
|
|
273
|
+
// Note: Requires additional API implementation
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
...post,
|
|
278
|
+
insights
|
|
279
|
+
}
|
|
280
|
+
} catch (error) {
|
|
281
|
+
return {
|
|
282
|
+
...post,
|
|
283
|
+
insights: null,
|
|
284
|
+
error: 'Failed to fetch insights'
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return enrichedPosts
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
**Display Top Performing Posts:**
|
|
295
|
+
```typescript
|
|
296
|
+
// app/dashboard/analytics/top-posts/page.tsx
|
|
297
|
+
export default async function TopPostsPage({
|
|
298
|
+
searchParams
|
|
299
|
+
}: {
|
|
300
|
+
searchParams: Promise<{ clientId?: string; days?: string }>
|
|
301
|
+
}) {
|
|
302
|
+
const params = await searchParams
|
|
303
|
+
const clientId = params.clientId
|
|
304
|
+
const days = parseInt(params.days || '30')
|
|
305
|
+
|
|
306
|
+
const posts = await getPostPerformance(clientId, days)
|
|
307
|
+
|
|
308
|
+
// Sort by engagement
|
|
309
|
+
const sortedPosts = posts
|
|
310
|
+
.filter(p => p.insights)
|
|
311
|
+
.sort((a, b) => (b.insights.engagement || 0) - (a.insights.engagement || 0))
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<div className="max-w-6xl mx-auto p-6">
|
|
315
|
+
<h1 className="text-2xl font-bold mb-6">
|
|
316
|
+
Top Performing Posts ({days} days)
|
|
317
|
+
</h1>
|
|
318
|
+
|
|
319
|
+
<div className="grid grid-cols-1 gap-4">
|
|
320
|
+
{sortedPosts.slice(0, 10).map((post, index) => (
|
|
321
|
+
<div key={post.id} className="border rounded-lg p-4 flex gap-4">
|
|
322
|
+
{/* Rank */}
|
|
323
|
+
<div className="text-3xl font-bold text-gray-300 w-12">
|
|
324
|
+
#{index + 1}
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
{/* Image */}
|
|
328
|
+
<img
|
|
329
|
+
src={post.imageUrl}
|
|
330
|
+
alt=""
|
|
331
|
+
className="w-32 h-32 object-cover rounded"
|
|
332
|
+
/>
|
|
333
|
+
|
|
334
|
+
{/* Content */}
|
|
335
|
+
<div className="flex-1">
|
|
336
|
+
<div className="flex justify-between items-start">
|
|
337
|
+
<div>
|
|
338
|
+
<p className="font-medium">{post.accountName}</p>
|
|
339
|
+
<p className="text-sm text-gray-600">
|
|
340
|
+
{new Date(post.publishedAt).toLocaleDateString()}
|
|
341
|
+
</p>
|
|
342
|
+
</div>
|
|
343
|
+
<a
|
|
344
|
+
href={post.postUrl}
|
|
345
|
+
target="_blank"
|
|
346
|
+
className="text-blue-600 text-sm"
|
|
347
|
+
>
|
|
348
|
+
View Post →
|
|
349
|
+
</a>
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<p className="text-sm mt-2 line-clamp-2">{post.caption}</p>
|
|
353
|
+
|
|
354
|
+
{/* Metrics */}
|
|
355
|
+
<div className="flex gap-6 mt-3">
|
|
356
|
+
<Metric
|
|
357
|
+
label="Impressions"
|
|
358
|
+
value={post.insights.impressions}
|
|
359
|
+
/>
|
|
360
|
+
<Metric
|
|
361
|
+
label="Engagement"
|
|
362
|
+
value={post.insights.engagement}
|
|
363
|
+
/>
|
|
364
|
+
<Metric
|
|
365
|
+
label="Likes"
|
|
366
|
+
value={post.insights.likes}
|
|
367
|
+
/>
|
|
368
|
+
<Metric
|
|
369
|
+
label="Comments"
|
|
370
|
+
value={post.insights.comments}
|
|
371
|
+
/>
|
|
372
|
+
<Metric
|
|
373
|
+
label="Saves"
|
|
374
|
+
value={post.insights.saves}
|
|
375
|
+
/>
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
))}
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function Metric({ label, value }: { label: string; value: number }) {
|
|
386
|
+
return (
|
|
387
|
+
<div>
|
|
388
|
+
<p className="text-xs text-gray-600">{label}</p>
|
|
389
|
+
<p className="font-semibold">{value?.toLocaleString() || '0'}</p>
|
|
390
|
+
</div>
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
## Client Reporting
|
|
396
|
+
|
|
397
|
+
### Monthly Report Generation
|
|
398
|
+
|
|
399
|
+
**Comprehensive Client Report:**
|
|
400
|
+
```typescript
|
|
401
|
+
export async function generateMonthlyReport(
|
|
402
|
+
clientId: string,
|
|
403
|
+
year: number,
|
|
404
|
+
month: number
|
|
405
|
+
) {
|
|
406
|
+
// Date range
|
|
407
|
+
const startDate = new Date(year, month - 1, 1)
|
|
408
|
+
const endDate = new Date(year, month, 0)
|
|
409
|
+
|
|
410
|
+
// Get client info
|
|
411
|
+
const client = await query(`
|
|
412
|
+
SELECT * FROM "clients" WHERE id = $1
|
|
413
|
+
`, [clientId])
|
|
414
|
+
|
|
415
|
+
// Get all accounts
|
|
416
|
+
const accounts = await query(`
|
|
417
|
+
SELECT * FROM "clients_social_platforms"
|
|
418
|
+
WHERE "parentId" = $1 AND "isActive" = true
|
|
419
|
+
`, [clientId])
|
|
420
|
+
|
|
421
|
+
// Get publishing activity
|
|
422
|
+
const publishingStats = await query(`
|
|
423
|
+
SELECT
|
|
424
|
+
al.details->>'platform' as platform,
|
|
425
|
+
COUNT(*) FILTER (WHERE al.action = 'post_published') as successful,
|
|
426
|
+
COUNT(*) FILTER (WHERE al.action = 'post_failed') as failed
|
|
427
|
+
FROM "audit_logs" al
|
|
428
|
+
WHERE al."accountId" = ANY($1)
|
|
429
|
+
AND al."createdAt" BETWEEN $2 AND $3
|
|
430
|
+
GROUP BY al.details->>'platform'
|
|
431
|
+
`, [accounts.rows.map(a => a.id), startDate, endDate])
|
|
432
|
+
|
|
433
|
+
// Fetch insights for each account
|
|
434
|
+
const accountInsights = await Promise.all(
|
|
435
|
+
accounts.rows.map(async (account) => {
|
|
436
|
+
const token = await decryptToken(account.accessToken)
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
if (account.platform === 'instagram_business') {
|
|
440
|
+
const insights = await InstagramAPI.getAccountInsights(
|
|
441
|
+
account.platformAccountId,
|
|
442
|
+
token
|
|
443
|
+
)
|
|
444
|
+
const info = await InstagramAPI.getAccountInfo(
|
|
445
|
+
account.platformAccountId,
|
|
446
|
+
token
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
accountName: account.platformAccountName,
|
|
451
|
+
platform: account.platform,
|
|
452
|
+
followersCount: info.followersCount,
|
|
453
|
+
...insights
|
|
454
|
+
}
|
|
455
|
+
} else {
|
|
456
|
+
const insights = await FacebookAPI.getPageInsights(
|
|
457
|
+
account.platformAccountId,
|
|
458
|
+
token
|
|
459
|
+
)
|
|
460
|
+
const info = await FacebookAPI.getPageInfo(
|
|
461
|
+
account.platformAccountId,
|
|
462
|
+
token
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
accountName: account.platformAccountName,
|
|
467
|
+
platform: account.platform,
|
|
468
|
+
fanCount: info.fanCount,
|
|
469
|
+
...insights
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
} catch (error) {
|
|
473
|
+
return {
|
|
474
|
+
accountName: account.platformAccountName,
|
|
475
|
+
platform: account.platform,
|
|
476
|
+
error: 'Failed to fetch insights'
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
// Calculate totals
|
|
483
|
+
const totals = accountInsights.reduce(
|
|
484
|
+
(acc, curr) => {
|
|
485
|
+
if (!curr.error) {
|
|
486
|
+
acc.impressions += curr.impressions || 0
|
|
487
|
+
acc.reach += curr.reach || 0
|
|
488
|
+
acc.engagement += curr.engagement || 0
|
|
489
|
+
}
|
|
490
|
+
return acc
|
|
491
|
+
},
|
|
492
|
+
{ impressions: 0, reach: 0, engagement: 0 }
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
const totalPosts = publishingStats.rows.reduce(
|
|
496
|
+
(sum, row) => sum + row.successful,
|
|
497
|
+
0
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
client: client.rows[0],
|
|
502
|
+
period: {
|
|
503
|
+
month,
|
|
504
|
+
year,
|
|
505
|
+
startDate,
|
|
506
|
+
endDate
|
|
507
|
+
},
|
|
508
|
+
summary: {
|
|
509
|
+
totalPosts,
|
|
510
|
+
totalAccounts: accounts.rowCount,
|
|
511
|
+
totalImpressions: totals.impressions,
|
|
512
|
+
totalReach: totals.reach,
|
|
513
|
+
totalEngagement: totals.engagement,
|
|
514
|
+
engagementRate: ((totals.engagement / totals.reach) * 100).toFixed(2)
|
|
515
|
+
},
|
|
516
|
+
accounts: accountInsights,
|
|
517
|
+
publishingStats: publishingStats.rows
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
**Export as PDF:**
|
|
523
|
+
```typescript
|
|
524
|
+
import { jsPDF } from 'jspdf'
|
|
525
|
+
|
|
526
|
+
export async function exportReportAsPDF(clientId: string, month: number, year: number) {
|
|
527
|
+
const report = await generateMonthlyReport(clientId, year, month)
|
|
528
|
+
|
|
529
|
+
const doc = new jsPDF()
|
|
530
|
+
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
|
|
531
|
+
'July', 'August', 'September', 'October', 'November', 'December']
|
|
532
|
+
|
|
533
|
+
// Title Page
|
|
534
|
+
doc.setFontSize(24)
|
|
535
|
+
doc.text(report.client.name, 20, 30)
|
|
536
|
+
doc.setFontSize(18)
|
|
537
|
+
doc.text(`Social Media Report`, 20, 45)
|
|
538
|
+
doc.setFontSize(14)
|
|
539
|
+
doc.text(`${monthNames[month - 1]} ${year}`, 20, 55)
|
|
540
|
+
|
|
541
|
+
// Summary
|
|
542
|
+
doc.addPage()
|
|
543
|
+
doc.setFontSize(16)
|
|
544
|
+
doc.text('Executive Summary', 20, 30)
|
|
545
|
+
|
|
546
|
+
doc.setFontSize(12)
|
|
547
|
+
let y = 50
|
|
548
|
+
doc.text(`Total Posts Published: ${report.summary.totalPosts}`, 20, y)
|
|
549
|
+
y += 10
|
|
550
|
+
doc.text(`Total Impressions: ${report.summary.totalImpressions.toLocaleString()}`, 20, y)
|
|
551
|
+
y += 10
|
|
552
|
+
doc.text(`Total Reach: ${report.summary.totalReach.toLocaleString()}`, 20, y)
|
|
553
|
+
y += 10
|
|
554
|
+
doc.text(`Total Engagement: ${report.summary.totalEngagement.toLocaleString()}`, 20, y)
|
|
555
|
+
y += 10
|
|
556
|
+
doc.text(`Engagement Rate: ${report.summary.engagementRate}%`, 20, y)
|
|
557
|
+
|
|
558
|
+
// Account Breakdown
|
|
559
|
+
doc.addPage()
|
|
560
|
+
doc.setFontSize(16)
|
|
561
|
+
doc.text('Account Performance', 20, 30)
|
|
562
|
+
|
|
563
|
+
y = 50
|
|
564
|
+
report.accounts.forEach((account) => {
|
|
565
|
+
if (account.error) return
|
|
566
|
+
|
|
567
|
+
doc.setFontSize(14)
|
|
568
|
+
doc.text(`${account.accountName}`, 20, y)
|
|
569
|
+
|
|
570
|
+
doc.setFontSize(11)
|
|
571
|
+
y += 10
|
|
572
|
+
doc.text(`Platform: ${account.platform === 'instagram_business' ? 'Instagram' : 'Facebook'}`, 30, y)
|
|
573
|
+
y += 8
|
|
574
|
+
doc.text(`Followers: ${(account.followersCount || account.fanCount || 0).toLocaleString()}`, 30, y)
|
|
575
|
+
y += 8
|
|
576
|
+
doc.text(`Impressions: ${(account.impressions || 0).toLocaleString()}`, 30, y)
|
|
577
|
+
y += 8
|
|
578
|
+
doc.text(`Engagement: ${(account.engagement || 0).toLocaleString()}`, 30, y)
|
|
579
|
+
y += 15
|
|
580
|
+
|
|
581
|
+
if (y > 250) {
|
|
582
|
+
doc.addPage()
|
|
583
|
+
y = 30
|
|
584
|
+
}
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
// Save
|
|
588
|
+
doc.save(`${report.client.slug}-report-${year}-${month}.pdf`)
|
|
589
|
+
}
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
## Comparative Analysis
|
|
593
|
+
|
|
594
|
+
### Month-over-Month Comparison
|
|
595
|
+
|
|
596
|
+
```typescript
|
|
597
|
+
export async function getComparativeAnalytics(clientId: string) {
|
|
598
|
+
const currentMonth = new Date().getMonth() + 1
|
|
599
|
+
const currentYear = new Date().getFullYear()
|
|
600
|
+
const previousMonth = currentMonth === 1 ? 12 : currentMonth - 1
|
|
601
|
+
const previousYear = currentMonth === 1 ? currentYear - 1 : currentYear
|
|
602
|
+
|
|
603
|
+
const [current, previous] = await Promise.all([
|
|
604
|
+
generateMonthlyReport(clientId, currentYear, currentMonth),
|
|
605
|
+
generateMonthlyReport(clientId, previousYear, previousMonth)
|
|
606
|
+
])
|
|
607
|
+
|
|
608
|
+
return {
|
|
609
|
+
current: current.summary,
|
|
610
|
+
previous: previous.summary,
|
|
611
|
+
changes: {
|
|
612
|
+
posts: calculateChange(current.summary.totalPosts, previous.summary.totalPosts),
|
|
613
|
+
impressions: calculateChange(current.summary.totalImpressions, previous.summary.totalImpressions),
|
|
614
|
+
reach: calculateChange(current.summary.totalReach, previous.summary.totalReach),
|
|
615
|
+
engagement: calculateChange(current.summary.totalEngagement, previous.summary.totalEngagement),
|
|
616
|
+
engagementRate: calculateChange(
|
|
617
|
+
parseFloat(current.summary.engagementRate),
|
|
618
|
+
parseFloat(previous.summary.engagementRate)
|
|
619
|
+
)
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function calculateChange(current: number, previous: number) {
|
|
625
|
+
if (previous === 0) return { value: 0, percentage: 0 }
|
|
626
|
+
|
|
627
|
+
const difference = current - previous
|
|
628
|
+
const percentage = ((difference / previous) * 100).toFixed(1)
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
value: difference,
|
|
632
|
+
percentage: parseFloat(percentage),
|
|
633
|
+
isPositive: difference >= 0
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
**Display Comparison:**
|
|
639
|
+
```typescript
|
|
640
|
+
export function ComparativeAnalyticsDashboard({ data }: { data: any }) {
|
|
641
|
+
return (
|
|
642
|
+
<div className="grid grid-cols-2 gap-6">
|
|
643
|
+
{/* Current Month */}
|
|
644
|
+
<div>
|
|
645
|
+
<h3 className="font-medium mb-4">This Month</h3>
|
|
646
|
+
<div className="space-y-3">
|
|
647
|
+
<MetricWithChange
|
|
648
|
+
label="Posts"
|
|
649
|
+
current={data.current.totalPosts}
|
|
650
|
+
change={data.changes.posts}
|
|
651
|
+
/>
|
|
652
|
+
<MetricWithChange
|
|
653
|
+
label="Impressions"
|
|
654
|
+
current={data.current.totalImpressions}
|
|
655
|
+
change={data.changes.impressions}
|
|
656
|
+
/>
|
|
657
|
+
<MetricWithChange
|
|
658
|
+
label="Engagement"
|
|
659
|
+
current={data.current.totalEngagement}
|
|
660
|
+
change={data.changes.engagement}
|
|
661
|
+
/>
|
|
662
|
+
<MetricWithChange
|
|
663
|
+
label="Engagement Rate"
|
|
664
|
+
current={`${data.current.engagementRate}%`}
|
|
665
|
+
change={data.changes.engagementRate}
|
|
666
|
+
/>
|
|
667
|
+
</div>
|
|
668
|
+
</div>
|
|
669
|
+
|
|
670
|
+
{/* Previous Month */}
|
|
671
|
+
<div>
|
|
672
|
+
<h3 className="font-medium mb-4">Last Month</h3>
|
|
673
|
+
<div className="space-y-3">
|
|
674
|
+
<SimplMetric label="Posts" value={data.previous.totalPosts} />
|
|
675
|
+
<SimpleMetric label="Impressions" value={data.previous.totalImpressions} />
|
|
676
|
+
<SimpleMetric label="Engagement" value={data.previous.totalEngagement} />
|
|
677
|
+
<SimpleMetric label="Engagement Rate" value={`${data.previous.engagementRate}%`} />
|
|
678
|
+
</div>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function MetricWithChange({ label, current, change }: {
|
|
685
|
+
label: string
|
|
686
|
+
current: number | string
|
|
687
|
+
change: { value: number; percentage: number; isPositive: boolean }
|
|
688
|
+
}) {
|
|
689
|
+
return (
|
|
690
|
+
<div className="border rounded p-3">
|
|
691
|
+
<p className="text-sm text-gray-600">{label}</p>
|
|
692
|
+
<div className="flex justify-between items-end">
|
|
693
|
+
<p className="text-2xl font-bold">{current}</p>
|
|
694
|
+
<div className={`text-sm ${change.isPositive ? 'text-green-600' : 'text-red-600'}`}>
|
|
695
|
+
{change.isPositive ? '↑' : '↓'} {Math.abs(change.percentage)}%
|
|
696
|
+
</div>
|
|
697
|
+
</div>
|
|
698
|
+
</div>
|
|
699
|
+
)
|
|
700
|
+
}
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
## Best Practices
|
|
704
|
+
|
|
705
|
+
### Data Refresh Strategy
|
|
706
|
+
|
|
707
|
+
```typescript
|
|
708
|
+
// Cache insights for 24 hours
|
|
709
|
+
export async function getCachedInsights(accountId: string) {
|
|
710
|
+
const cacheKey = `insights:${accountId}`
|
|
711
|
+
|
|
712
|
+
// Check cache
|
|
713
|
+
const cached = await redis.get(cacheKey)
|
|
714
|
+
if (cached) return JSON.parse(cached)
|
|
715
|
+
|
|
716
|
+
// Fetch fresh data
|
|
717
|
+
const insights = await getInstagramAnalytics(accountId)
|
|
718
|
+
|
|
719
|
+
// Cache for 24 hours
|
|
720
|
+
await redis.setex(cacheKey, 86400, JSON.stringify(insights))
|
|
721
|
+
|
|
722
|
+
return insights
|
|
723
|
+
}
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
### Rate Limit Handling
|
|
727
|
+
|
|
728
|
+
```typescript
|
|
729
|
+
// Batch insights fetching to avoid rate limits
|
|
730
|
+
export async function batchFetchInsights(accountIds: string[]) {
|
|
731
|
+
const results = []
|
|
732
|
+
|
|
733
|
+
for (const accountId of accountIds) {
|
|
734
|
+
results.push(await getCachedInsights(accountId))
|
|
735
|
+
|
|
736
|
+
// Wait 1 second between requests
|
|
737
|
+
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return results
|
|
741
|
+
}
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
## Next Steps
|
|
745
|
+
|
|
746
|
+
- **[Agency Management](./01-agency-management.md)** - Multi-client workflows
|
|
747
|
+
- **[Content Publishing](./02-content-publishing.md)** - Publishing workflows
|
|
748
|
+
- **[Provider APIs](../03-advanced-usage/01-provider-apis.md)** - API reference
|