@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.
@@ -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