@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,661 @@
|
|
|
1
|
+
# Agency Management Use Case
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This guide demonstrates how a **social media agency** can use the Social Media Publisher plugin to manage multiple client social accounts, handle publishing workflows, track activity, and maintain compliance.
|
|
6
|
+
|
|
7
|
+
**Agency Profile:**
|
|
8
|
+
- Name: Social Media Pro Agency
|
|
9
|
+
- Clients: 50+ businesses
|
|
10
|
+
- Team: 10 social media managers
|
|
11
|
+
- Accounts Managed: 150+ Instagram/Facebook accounts
|
|
12
|
+
- Posts/Month: 1,000+ across all clients
|
|
13
|
+
|
|
14
|
+
## Agency Setup
|
|
15
|
+
|
|
16
|
+
### 1. Client Onboarding
|
|
17
|
+
|
|
18
|
+
**Scenario:** New client "Acme Corp" signs up for social media management.
|
|
19
|
+
|
|
20
|
+
**Steps:**
|
|
21
|
+
|
|
22
|
+
**Create Client Record:**
|
|
23
|
+
```typescript
|
|
24
|
+
// app/actions/clients/create-client.ts
|
|
25
|
+
'use server'
|
|
26
|
+
|
|
27
|
+
import { auth } from '@/core/lib/auth'
|
|
28
|
+
import { query } from '@/core/lib/db'
|
|
29
|
+
|
|
30
|
+
export async function createClient(data: {
|
|
31
|
+
name: string
|
|
32
|
+
email: string
|
|
33
|
+
website?: string
|
|
34
|
+
}) {
|
|
35
|
+
const session = await auth.api.getSession({ headers: await headers() })
|
|
36
|
+
if (!session?.user) throw new Error('Unauthorized')
|
|
37
|
+
|
|
38
|
+
const result = await query(`
|
|
39
|
+
INSERT INTO "clients" (
|
|
40
|
+
"userId", name, slug, email, status
|
|
41
|
+
) VALUES ($1, $2, $3, $4, 'active')
|
|
42
|
+
RETURNING id
|
|
43
|
+
`, [
|
|
44
|
+
session.user.id,
|
|
45
|
+
data.name,
|
|
46
|
+
data.name.toLowerCase().replace(/\s+/g, '-'),
|
|
47
|
+
data.email
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
return { clientId: result.rows[0].id }
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Connect Social Accounts:**
|
|
55
|
+
```typescript
|
|
56
|
+
// Client detail page component
|
|
57
|
+
'use client'
|
|
58
|
+
|
|
59
|
+
export function ClientSocialAccounts({ clientId }: { clientId: string }) {
|
|
60
|
+
const handleConnectInstagram = () => {
|
|
61
|
+
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || window.location.origin
|
|
62
|
+
const oauthUrl = `${baseUrl}/api/v1/plugin/social-media-publisher/social/connect?platform=instagram_business&clientId=${clientId}`
|
|
63
|
+
|
|
64
|
+
const popup = window.open(oauthUrl, 'oauth-popup', 'width=600,height=700')
|
|
65
|
+
|
|
66
|
+
window.addEventListener('message', (event) => {
|
|
67
|
+
if (event.origin !== window.location.origin) return
|
|
68
|
+
|
|
69
|
+
if (event.data.type === 'oauth-success') {
|
|
70
|
+
toast.success(`Connected ${event.data.connectedCount} account(s)`)
|
|
71
|
+
router.refresh()
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div>
|
|
78
|
+
<h2>Social Media Accounts</h2>
|
|
79
|
+
<button onClick={handleConnectInstagram}>
|
|
80
|
+
Connect Instagram Business
|
|
81
|
+
</button>
|
|
82
|
+
<button onClick={() => handleConnectPlatform('facebook_page')}>
|
|
83
|
+
Connect Facebook Page
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Result:**
|
|
91
|
+
- Client record created
|
|
92
|
+
- OAuth popup opens
|
|
93
|
+
- Client connects @acmecorp Instagram
|
|
94
|
+
- Account saved to database
|
|
95
|
+
- Agency can now publish to @acmecorp
|
|
96
|
+
|
|
97
|
+
### 2. Team Structure
|
|
98
|
+
|
|
99
|
+
**Roles:**
|
|
100
|
+
- **Agency Owner** - Full access to all clients
|
|
101
|
+
- **Account Manager** - Manages specific clients
|
|
102
|
+
- **Content Creator** - Creates content, submits for approval
|
|
103
|
+
- **Social Media Manager** - Publishes approved content
|
|
104
|
+
|
|
105
|
+
**Implementation (Optional Extension):**
|
|
106
|
+
```sql
|
|
107
|
+
-- Add team member access control
|
|
108
|
+
CREATE TABLE "client_collaborators" (
|
|
109
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
110
|
+
"clientId" UUID NOT NULL REFERENCES "clients"(id),
|
|
111
|
+
"userId" TEXT NOT NULL,
|
|
112
|
+
role TEXT NOT NULL, -- 'owner', 'manager', 'creator', 'viewer'
|
|
113
|
+
"createdAt" TIMESTAMPTZ DEFAULT now()
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
-- Check if user has access to client
|
|
117
|
+
CREATE FUNCTION can_access_client(
|
|
118
|
+
p_user_id TEXT,
|
|
119
|
+
p_client_id UUID
|
|
120
|
+
) RETURNS BOOLEAN AS $$
|
|
121
|
+
BEGIN
|
|
122
|
+
RETURN EXISTS (
|
|
123
|
+
SELECT 1 FROM "clients"
|
|
124
|
+
WHERE id = p_client_id AND "userId" = p_user_id
|
|
125
|
+
) OR EXISTS (
|
|
126
|
+
SELECT 1 FROM "client_collaborators"
|
|
127
|
+
WHERE "clientId" = p_client_id AND "userId" = p_user_id
|
|
128
|
+
);
|
|
129
|
+
END;
|
|
130
|
+
$$ LANGUAGE plpgsql;
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Daily Workflows
|
|
134
|
+
|
|
135
|
+
### Morning: Review Client Dashboard
|
|
136
|
+
|
|
137
|
+
**Agency Dashboard Component:**
|
|
138
|
+
```typescript
|
|
139
|
+
// app/dashboard/agency/page.tsx
|
|
140
|
+
import { getAgencyDashboard } from '@/lib/agency'
|
|
141
|
+
|
|
142
|
+
export default async function AgencyDashboardPage() {
|
|
143
|
+
const dashboard = await getAgencyDashboard()
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div>
|
|
147
|
+
<h1>Agency Dashboard</h1>
|
|
148
|
+
|
|
149
|
+
{/* Summary Cards */}
|
|
150
|
+
<div className="grid grid-cols-4 gap-4">
|
|
151
|
+
<Card>
|
|
152
|
+
<CardHeader>Total Clients</CardHeader>
|
|
153
|
+
<CardContent>{dashboard.totalClients}</CardContent>
|
|
154
|
+
</Card>
|
|
155
|
+
<Card>
|
|
156
|
+
<CardHeader>Active Accounts</CardHeader>
|
|
157
|
+
<CardContent>{dashboard.activeAccounts}</CardContent>
|
|
158
|
+
</Card>
|
|
159
|
+
<Card>
|
|
160
|
+
<CardHeader>Posts Today</CardHeader>
|
|
161
|
+
<CardContent>{dashboard.postsToday}</CardContent>
|
|
162
|
+
</Card>
|
|
163
|
+
<Card>
|
|
164
|
+
<CardHeader>Expiring Tokens</CardHeader>
|
|
165
|
+
<CardContent className="text-red-600">
|
|
166
|
+
{dashboard.expiringTokens}
|
|
167
|
+
</CardContent>
|
|
168
|
+
</Card>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{/* Client List */}
|
|
172
|
+
<div className="mt-8">
|
|
173
|
+
<h2>Clients</h2>
|
|
174
|
+
{dashboard.clients.map(client => (
|
|
175
|
+
<div key={client.id} className="border p-4 mb-2">
|
|
176
|
+
<h3>{client.name}</h3>
|
|
177
|
+
<p>
|
|
178
|
+
{client.instagramCount} Instagram · {client.facebookCount} Facebook
|
|
179
|
+
</p>
|
|
180
|
+
<Link href={`/dashboard/clients/${client.id}`}>
|
|
181
|
+
Manage →
|
|
182
|
+
</Link>
|
|
183
|
+
</div>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// lib/agency.ts
|
|
191
|
+
export async function getAgencyDashboard() {
|
|
192
|
+
const session = await auth.api.getSession({ headers: await headers() })
|
|
193
|
+
|
|
194
|
+
// Get all clients with account counts
|
|
195
|
+
const clients = await query(`
|
|
196
|
+
SELECT
|
|
197
|
+
c.*,
|
|
198
|
+
COUNT(csp.id) FILTER (WHERE csp.platform = 'instagram_business' AND csp."isActive" = true) as "instagramCount",
|
|
199
|
+
COUNT(csp.id) FILTER (WHERE csp.platform = 'facebook_page' AND csp."isActive" = true) as "facebookCount"
|
|
200
|
+
FROM "clients" c
|
|
201
|
+
LEFT JOIN "clients_social_platforms" csp ON csp."parentId" = c.id
|
|
202
|
+
WHERE c."userId" = $1
|
|
203
|
+
GROUP BY c.id
|
|
204
|
+
ORDER BY c.name
|
|
205
|
+
`, [session.user.id])
|
|
206
|
+
|
|
207
|
+
// Get today's posts
|
|
208
|
+
const postsToday = await query(`
|
|
209
|
+
SELECT COUNT(*) as count
|
|
210
|
+
FROM "audit_logs"
|
|
211
|
+
WHERE "userId" = $1
|
|
212
|
+
AND action = 'post_published'
|
|
213
|
+
AND "createdAt"::date = CURRENT_DATE
|
|
214
|
+
`, [session.user.id])
|
|
215
|
+
|
|
216
|
+
// Get expiring tokens (< 7 days)
|
|
217
|
+
const expiringTokens = await query(`
|
|
218
|
+
SELECT COUNT(*) as count
|
|
219
|
+
FROM "clients_social_platforms" csp
|
|
220
|
+
JOIN "clients" c ON c.id = csp."parentId"
|
|
221
|
+
WHERE c."userId" = $1
|
|
222
|
+
AND csp."tokenExpiresAt" < NOW() + INTERVAL '7 days'
|
|
223
|
+
AND csp."isActive" = true
|
|
224
|
+
`, [session.user.id])
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
totalClients: clients.rowCount,
|
|
228
|
+
activeAccounts: clients.rows.reduce((sum, c) =>
|
|
229
|
+
sum + c.instagramCount + c.facebookCount, 0
|
|
230
|
+
),
|
|
231
|
+
postsToday: postsToday.rows[0].count,
|
|
232
|
+
expiringTokens: expiringTokens.rows[0].count,
|
|
233
|
+
clients: clients.rows
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Content Creation & Publishing
|
|
239
|
+
|
|
240
|
+
**Scenario:** Social media manager needs to publish content for 3 clients.
|
|
241
|
+
|
|
242
|
+
**Bulk Publishing Interface:**
|
|
243
|
+
```typescript
|
|
244
|
+
// app/dashboard/publish/page.tsx
|
|
245
|
+
'use client'
|
|
246
|
+
|
|
247
|
+
export default function BulkPublishPage() {
|
|
248
|
+
const [selectedClients, setSelectedClients] = useState<string[]>([])
|
|
249
|
+
const [selectedAccounts, setSelectedAccounts] = useState<string[]>([])
|
|
250
|
+
const [imageUrl, setImageUrl] = useState('')
|
|
251
|
+
const [caption, setCaption] = useState('')
|
|
252
|
+
const [publishing, setPublishing] = useState(false)
|
|
253
|
+
|
|
254
|
+
const handlePublish = async () => {
|
|
255
|
+
setPublishing(true)
|
|
256
|
+
|
|
257
|
+
const response = await fetch('/api/v1/custom/cross-post', {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: { 'Content-Type': 'application/json' },
|
|
260
|
+
body: JSON.stringify({
|
|
261
|
+
accountIds: selectedAccounts,
|
|
262
|
+
imageUrl,
|
|
263
|
+
caption
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
const result = await response.json()
|
|
268
|
+
|
|
269
|
+
toast.success(`Published to ${result.successful}/${result.total} accounts`)
|
|
270
|
+
|
|
271
|
+
setPublishing(false)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div>
|
|
276
|
+
<h1>Bulk Publishing</h1>
|
|
277
|
+
|
|
278
|
+
{/* Client Selector */}
|
|
279
|
+
<div>
|
|
280
|
+
<h2>Select Clients</h2>
|
|
281
|
+
<ClientMultiSelect
|
|
282
|
+
onChange={setSelectedClients}
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
{/* Account Selector */}
|
|
287
|
+
<div>
|
|
288
|
+
<h2>Select Accounts</h2>
|
|
289
|
+
<AccountMultiSelect
|
|
290
|
+
clientIds={selectedClients}
|
|
291
|
+
onChange={setSelectedAccounts}
|
|
292
|
+
/>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
{/* Content Input */}
|
|
296
|
+
<div>
|
|
297
|
+
<h2>Content</h2>
|
|
298
|
+
<input
|
|
299
|
+
type="url"
|
|
300
|
+
placeholder="Image URL"
|
|
301
|
+
value={imageUrl}
|
|
302
|
+
onChange={(e) => setImageUrl(e.target.value)}
|
|
303
|
+
/>
|
|
304
|
+
<textarea
|
|
305
|
+
placeholder="Caption..."
|
|
306
|
+
value={caption}
|
|
307
|
+
onChange={(e) => setCaption(e.target.value)}
|
|
308
|
+
maxLength={2200}
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
{/* Publish Button */}
|
|
313
|
+
<button
|
|
314
|
+
onClick={handlePublish}
|
|
315
|
+
disabled={publishing || selectedAccounts.length === 0}
|
|
316
|
+
>
|
|
317
|
+
{publishing
|
|
318
|
+
? 'Publishing...'
|
|
319
|
+
: `Publish to ${selectedAccounts.length} account(s)`
|
|
320
|
+
}
|
|
321
|
+
</button>
|
|
322
|
+
</div>
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Afternoon: Review Analytics
|
|
328
|
+
|
|
329
|
+
**Client Performance Report:**
|
|
330
|
+
```typescript
|
|
331
|
+
// app/dashboard/clients/[clientId]/analytics/page.tsx
|
|
332
|
+
export default async function ClientAnalyticsPage({
|
|
333
|
+
params
|
|
334
|
+
}: {
|
|
335
|
+
params: Promise<{ clientId: string }>
|
|
336
|
+
}) {
|
|
337
|
+
const { clientId } = await params
|
|
338
|
+
const analytics = await getClientAnalytics(clientId)
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<div>
|
|
342
|
+
<h1>{analytics.client.name} - Analytics</h1>
|
|
343
|
+
|
|
344
|
+
{/* Summary */}
|
|
345
|
+
<div className="grid grid-cols-3 gap-4">
|
|
346
|
+
<Card>
|
|
347
|
+
<CardHeader>Total Posts (30 days)</CardHeader>
|
|
348
|
+
<CardContent>{analytics.totalPosts}</CardContent>
|
|
349
|
+
</Card>
|
|
350
|
+
<Card>
|
|
351
|
+
<CardHeader>Total Impressions</CardHeader>
|
|
352
|
+
<CardContent>{analytics.totalImpressions.toLocaleString()}</CardContent>
|
|
353
|
+
</Card>
|
|
354
|
+
<Card>
|
|
355
|
+
<CardHeader>Engagement Rate</CardHeader>
|
|
356
|
+
<CardContent>
|
|
357
|
+
{((analytics.totalEngagement / analytics.totalReach) * 100).toFixed(2)}%
|
|
358
|
+
</CardContent>
|
|
359
|
+
</Card>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
{/* Per-Account Breakdown */}
|
|
363
|
+
<div className="mt-8">
|
|
364
|
+
<h2>Account Performance</h2>
|
|
365
|
+
{analytics.accounts.map(account => (
|
|
366
|
+
<div key={account.id} className="border p-4 mb-2">
|
|
367
|
+
<h3>{account.platformAccountName}</h3>
|
|
368
|
+
<div className="grid grid-cols-4 gap-2">
|
|
369
|
+
<div>
|
|
370
|
+
<small>Posts</small>
|
|
371
|
+
<p>{account.postCount}</p>
|
|
372
|
+
</div>
|
|
373
|
+
<div>
|
|
374
|
+
<small>Impressions</small>
|
|
375
|
+
<p>{account.impressions.toLocaleString()}</p>
|
|
376
|
+
</div>
|
|
377
|
+
<div>
|
|
378
|
+
<small>Engagement</small>
|
|
379
|
+
<p>{account.engagement}</p>
|
|
380
|
+
</div>
|
|
381
|
+
<div>
|
|
382
|
+
<small>Followers</small>
|
|
383
|
+
<p>{account.followersCount.toLocaleString()}</p>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
))}
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// lib/analytics.ts
|
|
394
|
+
async function getClientAnalytics(clientId: string) {
|
|
395
|
+
// Get client
|
|
396
|
+
const client = await query(`
|
|
397
|
+
SELECT * FROM "clients" WHERE id = $1
|
|
398
|
+
`, [clientId])
|
|
399
|
+
|
|
400
|
+
// Get social accounts
|
|
401
|
+
const accounts = await query(`
|
|
402
|
+
SELECT * FROM "clients_social_platforms"
|
|
403
|
+
WHERE "parentId" = $1 AND "isActive" = true
|
|
404
|
+
`, [clientId])
|
|
405
|
+
|
|
406
|
+
// Get post count from audit logs
|
|
407
|
+
const postStats = await query(`
|
|
408
|
+
SELECT
|
|
409
|
+
al."accountId",
|
|
410
|
+
COUNT(*) as post_count
|
|
411
|
+
FROM "audit_logs" al
|
|
412
|
+
WHERE al."accountId" = ANY($1)
|
|
413
|
+
AND al.action = 'post_published'
|
|
414
|
+
AND al."createdAt" > NOW() - INTERVAL '30 days'
|
|
415
|
+
GROUP BY al."accountId"
|
|
416
|
+
`, [accounts.rows.map(a => a.id)])
|
|
417
|
+
|
|
418
|
+
// Fetch insights from APIs
|
|
419
|
+
const accountsWithInsights = await Promise.all(
|
|
420
|
+
accounts.rows.map(async (account) => {
|
|
421
|
+
const token = await decryptToken(account.accessToken)
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
const insights = account.platform === 'instagram_business'
|
|
425
|
+
? await InstagramAPI.getAccountInsights(account.platformAccountId, token)
|
|
426
|
+
: await FacebookAPI.getPageInsights(account.platformAccountId, token)
|
|
427
|
+
|
|
428
|
+
const postCount = postStats.rows.find(p => p.accountId === account.id)?.post_count || 0
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
...account,
|
|
432
|
+
...insights,
|
|
433
|
+
postCount
|
|
434
|
+
}
|
|
435
|
+
} catch (error) {
|
|
436
|
+
return {
|
|
437
|
+
...account,
|
|
438
|
+
error: 'Failed to fetch insights',
|
|
439
|
+
postCount: 0
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
})
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
client: client.rows[0],
|
|
447
|
+
totalPosts: accountsWithInsights.reduce((sum, a) => sum + a.postCount, 0),
|
|
448
|
+
totalImpressions: accountsWithInsights.reduce((sum, a) => sum + (a.impressions || 0), 0),
|
|
449
|
+
totalReach: accountsWithInsights.reduce((sum, a) => sum + (a.reach || 0), 0),
|
|
450
|
+
totalEngagement: accountsWithInsights.reduce((sum, a) => sum + (a.engagement || 0), 0),
|
|
451
|
+
accounts: accountsWithInsights
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
## Compliance & Reporting
|
|
457
|
+
|
|
458
|
+
### Monthly Client Reports
|
|
459
|
+
|
|
460
|
+
**Generate PDF Report:**
|
|
461
|
+
```typescript
|
|
462
|
+
// lib/reports/client-report.ts
|
|
463
|
+
import { jsPDF } from 'jspdf'
|
|
464
|
+
|
|
465
|
+
export async function generateClientReport(
|
|
466
|
+
clientId: string,
|
|
467
|
+
month: string // 'YYYY-MM'
|
|
468
|
+
) {
|
|
469
|
+
const analytics = await getClientAnalyticsForMonth(clientId, month)
|
|
470
|
+
const posts = await getClientPostsForMonth(clientId, month)
|
|
471
|
+
|
|
472
|
+
const doc = new jsPDF()
|
|
473
|
+
|
|
474
|
+
// Cover Page
|
|
475
|
+
doc.setFontSize(24)
|
|
476
|
+
doc.text(`${analytics.client.name}`, 20, 30)
|
|
477
|
+
doc.setFontSize(16)
|
|
478
|
+
doc.text(`Social Media Report - ${month}`, 20, 45)
|
|
479
|
+
|
|
480
|
+
// Summary
|
|
481
|
+
doc.setFontSize(14)
|
|
482
|
+
doc.text('Summary', 20, 70)
|
|
483
|
+
doc.setFontSize(12)
|
|
484
|
+
doc.text(`Total Posts: ${analytics.totalPosts}`, 20, 85)
|
|
485
|
+
doc.text(`Total Impressions: ${analytics.totalImpressions.toLocaleString()}`, 20, 95)
|
|
486
|
+
doc.text(`Total Engagement: ${analytics.totalEngagement}`, 20, 105)
|
|
487
|
+
doc.text(`Engagement Rate: ${analytics.engagementRate}%`, 20, 115)
|
|
488
|
+
|
|
489
|
+
// Account Breakdown
|
|
490
|
+
doc.addPage()
|
|
491
|
+
doc.setFontSize(14)
|
|
492
|
+
doc.text('Account Performance', 20, 30)
|
|
493
|
+
|
|
494
|
+
let y = 45
|
|
495
|
+
analytics.accounts.forEach(account => {
|
|
496
|
+
doc.setFontSize(12)
|
|
497
|
+
doc.text(`${account.platformAccountName} (${account.platform})`, 20, y)
|
|
498
|
+
doc.setFontSize(10)
|
|
499
|
+
doc.text(`Posts: ${account.postCount}`, 30, y + 8)
|
|
500
|
+
doc.text(`Impressions: ${account.impressions.toLocaleString()}`, 30, y + 16)
|
|
501
|
+
doc.text(`Engagement: ${account.engagement}`, 30, y + 24)
|
|
502
|
+
y += 40
|
|
503
|
+
|
|
504
|
+
if (y > 250) {
|
|
505
|
+
doc.addPage()
|
|
506
|
+
y = 30
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
// Top Posts
|
|
511
|
+
doc.addPage()
|
|
512
|
+
doc.setFontSize(14)
|
|
513
|
+
doc.text('Top Performing Posts', 20, 30)
|
|
514
|
+
|
|
515
|
+
y = 45
|
|
516
|
+
posts.slice(0, 5).forEach((post, i) => {
|
|
517
|
+
doc.setFontSize(12)
|
|
518
|
+
doc.text(`${i + 1}. ${post.caption.substring(0, 50)}...`, 20, y)
|
|
519
|
+
doc.setFontSize(10)
|
|
520
|
+
doc.text(`Engagement: ${post.engagement} · ${new Date(post.publishedAt).toLocaleDateString()}`, 30, y + 8)
|
|
521
|
+
y += 25
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
// Save
|
|
525
|
+
doc.save(`${analytics.client.slug}-report-${month}.pdf`)
|
|
526
|
+
}
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
### Audit Trail for Compliance
|
|
530
|
+
|
|
531
|
+
**Export All Activity:**
|
|
532
|
+
```typescript
|
|
533
|
+
// app/api/v1/custom/export-audit-log/route.ts
|
|
534
|
+
export async function GET(request: NextRequest) {
|
|
535
|
+
const authResult = await authenticateRequest(request)
|
|
536
|
+
if (!authResult.success) {
|
|
537
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const { searchParams } = new URL(request.url)
|
|
541
|
+
const clientId = searchParams.get('clientId')
|
|
542
|
+
const startDate = searchParams.get('startDate')
|
|
543
|
+
const endDate = searchParams.get('endDate')
|
|
544
|
+
|
|
545
|
+
// Get audit logs
|
|
546
|
+
const logs = await query(`
|
|
547
|
+
SELECT
|
|
548
|
+
al.*,
|
|
549
|
+
csp."platformAccountName",
|
|
550
|
+
c.name as "clientName"
|
|
551
|
+
FROM "audit_logs" al
|
|
552
|
+
JOIN "clients_social_platforms" csp ON csp.id = al."accountId"
|
|
553
|
+
JOIN "clients" c ON c.id = csp."parentId"
|
|
554
|
+
WHERE c.id = $1
|
|
555
|
+
AND c."userId" = $2
|
|
556
|
+
AND al."createdAt" BETWEEN $3 AND $4
|
|
557
|
+
ORDER BY al."createdAt" DESC
|
|
558
|
+
`, [clientId, authResult.user!.id, startDate, endDate])
|
|
559
|
+
|
|
560
|
+
// Convert to CSV
|
|
561
|
+
const csv = [
|
|
562
|
+
'Date,Time,User,Client,Account,Action,Details,IP Address',
|
|
563
|
+
...logs.rows.map(log => [
|
|
564
|
+
new Date(log.createdAt).toLocaleDateString(),
|
|
565
|
+
new Date(log.createdAt).toLocaleTimeString(),
|
|
566
|
+
log.userId,
|
|
567
|
+
log.clientName,
|
|
568
|
+
log.platformAccountName,
|
|
569
|
+
log.action,
|
|
570
|
+
JSON.stringify(log.details),
|
|
571
|
+
log.ipAddress || 'N/A'
|
|
572
|
+
].join(','))
|
|
573
|
+
].join('\n')
|
|
574
|
+
|
|
575
|
+
return new NextResponse(csv, {
|
|
576
|
+
headers: {
|
|
577
|
+
'Content-Type': 'text/csv',
|
|
578
|
+
'Content-Disposition': `attachment; filename="audit-log-${clientId}-${startDate}-to-${endDate}.csv"`
|
|
579
|
+
}
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
## Best Practices for Agencies
|
|
585
|
+
|
|
586
|
+
### 1. Client Naming Convention
|
|
587
|
+
|
|
588
|
+
```typescript
|
|
589
|
+
// Use consistent naming
|
|
590
|
+
const clientSlug = name
|
|
591
|
+
.toLowerCase()
|
|
592
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
593
|
+
.replace(/^-|-$/g, '')
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
### 2. Regular Token Checks
|
|
597
|
+
|
|
598
|
+
```typescript
|
|
599
|
+
// Weekly cron job to alert about expiring tokens
|
|
600
|
+
export async function checkExpiringTokens() {
|
|
601
|
+
const expiring = await query(`
|
|
602
|
+
SELECT
|
|
603
|
+
csp.*,
|
|
604
|
+
c.name as "clientName",
|
|
605
|
+
c.email as "clientEmail"
|
|
606
|
+
FROM "clients_social_platforms" csp
|
|
607
|
+
JOIN "clients" c ON c.id = csp."parentId"
|
|
608
|
+
WHERE csp."tokenExpiresAt" < NOW() + INTERVAL '14 days'
|
|
609
|
+
AND csp."isActive" = true
|
|
610
|
+
`)
|
|
611
|
+
|
|
612
|
+
for (const account of expiring.rows) {
|
|
613
|
+
await sendEmail({
|
|
614
|
+
to: account.clientEmail,
|
|
615
|
+
subject: `Action Required: ${account.platformAccountName} needs reconnection`,
|
|
616
|
+
body: `Your ${account.platform} account expires in less than 14 days. Please reconnect.`
|
|
617
|
+
})
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
### 3. Content Calendar Integration
|
|
623
|
+
|
|
624
|
+
```typescript
|
|
625
|
+
// Sync with content calendar
|
|
626
|
+
export async function syncWithContentCalendar(clientId: string) {
|
|
627
|
+
// Get scheduled posts from external calendar
|
|
628
|
+
const calendarPosts = await fetchFromCalendar(clientId)
|
|
629
|
+
|
|
630
|
+
// Create scheduled posts in system
|
|
631
|
+
for (const post of calendarPosts) {
|
|
632
|
+
await query(`
|
|
633
|
+
INSERT INTO "scheduled_posts" (
|
|
634
|
+
"accountId", "scheduledFor", "imageUrl", caption, "createdBy"
|
|
635
|
+
) VALUES ($1, $2, $3, $4, 'calendar_sync')
|
|
636
|
+
`, [post.accountId, post.scheduledFor, post.imageUrl, post.caption])
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
### 4. Client Portal Access
|
|
642
|
+
|
|
643
|
+
```typescript
|
|
644
|
+
// Give clients view-only access to their analytics
|
|
645
|
+
// Create separate client portal route
|
|
646
|
+
// app/client-portal/[slug]/page.tsx
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
## Key Takeaways
|
|
650
|
+
|
|
651
|
+
✅ **Per-Client Organization** - Clear separation of client accounts
|
|
652
|
+
✅ **Bulk Operations** - Publish to multiple clients efficiently
|
|
653
|
+
✅ **Complete Audit Trail** - Full compliance and reporting
|
|
654
|
+
✅ **Scalable** - Handle 50+ clients seamlessly
|
|
655
|
+
✅ **Team Collaboration** - Multiple users managing same clients
|
|
656
|
+
|
|
657
|
+
## Next Steps
|
|
658
|
+
|
|
659
|
+
- **[Content Publishing](./02-content-publishing.md)** - Publishing workflows
|
|
660
|
+
- **[Analytics Reporting](./03-analytics-reporting.md)** - Advanced analytics
|
|
661
|
+
- **[Custom Integrations](../03-advanced-usage/02-custom-integrations.md)** - Extend functionality
|