@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,695 @@
1
+ # Custom Integrations
2
+
3
+ ## Overview
4
+
5
+ The Social Media Publisher plugin provides flexible building blocks that allow you to create custom publishing workflows, scheduled posts, analytics dashboards, and content management integrations. This guide shows you how to extend the plugin's capabilities.
6
+
7
+ **What You Can Build:**
8
+ - Batch publishing systems
9
+ - Scheduled post queues
10
+ - Cross-posting automation
11
+ - Analytics dashboards
12
+ - Content approval workflows
13
+ - Custom publishing endpoints
14
+
15
+ ## Building Custom Endpoints
16
+
17
+ ### Custom Publishing Endpoint
18
+
19
+ Create a custom endpoint with additional business logic:
20
+
21
+ ```typescript
22
+ // app/api/v1/custom/publish-with-approval/route.ts
23
+ import { NextRequest, NextResponse } from 'next/server'
24
+ import { authenticateRequest } from '@/core/lib/api/auth/dual-auth'
25
+ import { InstagramAPI, FacebookAPI } from '@/contents/plugins/social-media-publisher/lib/providers'
26
+ import { TokenEncryption } from '@/core/lib/oauth/encryption'
27
+ import { query } from '@/core/lib/db'
28
+
29
+ export async function POST(request: NextRequest) {
30
+ const authResult = await authenticateRequest(request)
31
+ if (!authResult.success) {
32
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
33
+ }
34
+
35
+ const { accountId, imageUrl, caption, requireApproval } = await request.json()
36
+
37
+ // Custom business logic: Check if approval needed
38
+ if (requireApproval) {
39
+ // Save to pending queue
40
+ await query(`
41
+ INSERT INTO "pending_posts" (
42
+ "accountId", "imageUrl", caption, "createdBy", status
43
+ ) VALUES ($1, $2, $3, $4, 'pending_approval')
44
+ `, [accountId, imageUrl, caption, authResult.user!.id])
45
+
46
+ return NextResponse.json({
47
+ success: true,
48
+ status: 'pending_approval',
49
+ message: 'Post submitted for approval'
50
+ })
51
+ }
52
+
53
+ // Direct publish (no approval needed)
54
+ const account = await getAccount(accountId)
55
+ const token = await decryptToken(account.accessToken)
56
+
57
+ const result = account.platform === 'instagram_business'
58
+ ? await InstagramAPI.publishPhoto({
59
+ igAccountId: account.platformAccountId,
60
+ accessToken: token,
61
+ imageUrl,
62
+ caption
63
+ })
64
+ : await FacebookAPI.publishPhotoPost({
65
+ pageId: account.platformAccountId,
66
+ pageAccessToken: token,
67
+ message: caption,
68
+ imageUrl
69
+ })
70
+
71
+ return NextResponse.json(result)
72
+ }
73
+ ```
74
+
75
+ ### Approval System Endpoint
76
+
77
+ Approve and publish pending posts:
78
+
79
+ ```typescript
80
+ // app/api/v1/custom/approve-post/[postId]/route.ts
81
+ export async function POST(
82
+ request: NextRequest,
83
+ { params }: { params: Promise<{ postId: string }> }
84
+ ) {
85
+ const { postId } = await params
86
+ const authResult = await authenticateRequest(request)
87
+
88
+ // Check if user has approval permission
89
+ if (!hasApprovalPermission(authResult.user!.id)) {
90
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
91
+ }
92
+
93
+ // Get pending post
94
+ const post = await query(`
95
+ SELECT * FROM "pending_posts"
96
+ WHERE id = $1 AND status = 'pending_approval'
97
+ `, [postId])
98
+
99
+ if (post.rowCount === 0) {
100
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 })
101
+ }
102
+
103
+ const postData = post.rows[0]
104
+
105
+ // Publish the post
106
+ const account = await getAccount(postData.accountId)
107
+ const token = await decryptToken(account.accessToken)
108
+
109
+ const result = await publishToAccount(account, token, {
110
+ imageUrl: postData.imageUrl,
111
+ caption: postData.caption
112
+ })
113
+
114
+ if (result.success) {
115
+ // Update status
116
+ await query(`
117
+ UPDATE "pending_posts"
118
+ SET status = 'published',
119
+ "publishedAt" = NOW(),
120
+ "approvedBy" = $1,
121
+ "postId" = $2
122
+ WHERE id = $3
123
+ `, [authResult.user!.id, result.postId, postId])
124
+ }
125
+
126
+ return NextResponse.json(result)
127
+ }
128
+ ```
129
+
130
+ ## Scheduled Publishing
131
+
132
+ ### Database Schema for Scheduled Posts
133
+
134
+ ```sql
135
+ CREATE TABLE "scheduled_posts" (
136
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
137
+ "accountId" UUID NOT NULL REFERENCES "clients_social_platforms"(id),
138
+ "scheduledFor" TIMESTAMPTZ NOT NULL,
139
+ "imageUrl" TEXT NOT NULL,
140
+ caption TEXT,
141
+ status TEXT DEFAULT 'scheduled', -- 'scheduled', 'publishing', 'published', 'failed'
142
+ "postId" TEXT,
143
+ "postUrl" TEXT,
144
+ "createdBy" TEXT NOT NULL,
145
+ "createdAt" TIMESTAMPTZ DEFAULT now(),
146
+ "publishedAt" TIMESTAMPTZ,
147
+ error TEXT
148
+ );
149
+
150
+ CREATE INDEX "idx_scheduled_posts_scheduledFor"
151
+ ON "scheduled_posts"("scheduledFor")
152
+ WHERE status = 'scheduled';
153
+ ```
154
+
155
+ ### Schedule Endpoint
156
+
157
+ ```typescript
158
+ // app/api/v1/custom/schedule-post/route.ts
159
+ export async function POST(request: NextRequest) {
160
+ const authResult = await authenticateRequest(request)
161
+ if (!authResult.success) {
162
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
163
+ }
164
+
165
+ const { accountId, imageUrl, caption, scheduledFor } = await request.json()
166
+
167
+ // Validate scheduled time is in future
168
+ const scheduledDate = new Date(scheduledFor)
169
+ if (scheduledDate <= new Date()) {
170
+ return NextResponse.json(
171
+ { error: 'Scheduled time must be in the future' },
172
+ { status: 400 }
173
+ )
174
+ }
175
+
176
+ // Save scheduled post
177
+ const result = await query(`
178
+ INSERT INTO "scheduled_posts" (
179
+ "accountId", "scheduledFor", "imageUrl", caption, "createdBy"
180
+ ) VALUES ($1, $2, $3, $4, $5)
181
+ RETURNING id
182
+ `, [accountId, scheduledDate, imageUrl, caption, authResult.user!.id])
183
+
184
+ return NextResponse.json({
185
+ success: true,
186
+ scheduledPostId: result.rows[0].id,
187
+ scheduledFor: scheduledDate.toISOString()
188
+ })
189
+ }
190
+ ```
191
+
192
+ ### Cron Job to Process Scheduled Posts
193
+
194
+ ```typescript
195
+ // app/api/cron/process-scheduled-posts/route.ts
196
+ import { NextRequest, NextResponse } from 'next/server'
197
+ import { query } from '@/core/lib/db'
198
+ import { publishScheduledPost } from '@/lib/scheduled-posts'
199
+
200
+ export async function GET(request: NextRequest) {
201
+ // Verify cron secret (Vercel Cron)
202
+ const authHeader = request.headers.get('authorization')
203
+ if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
204
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
205
+ }
206
+
207
+ // Get posts ready to publish (within next 5 minutes)
208
+ const posts = await query(`
209
+ SELECT * FROM "scheduled_posts"
210
+ WHERE status = 'scheduled'
211
+ AND "scheduledFor" <= NOW() + INTERVAL '5 minutes'
212
+ ORDER BY "scheduledFor" ASC
213
+ LIMIT 10
214
+ `)
215
+
216
+ const results = []
217
+
218
+ for (const post of posts.rows) {
219
+ // Mark as publishing
220
+ await query(`
221
+ UPDATE "scheduled_posts"
222
+ SET status = 'publishing'
223
+ WHERE id = $1
224
+ `, [post.id])
225
+
226
+ try {
227
+ // Publish
228
+ const result = await publishScheduledPost(post)
229
+
230
+ if (result.success) {
231
+ // Mark as published
232
+ await query(`
233
+ UPDATE "scheduled_posts"
234
+ SET status = 'published',
235
+ "publishedAt" = NOW(),
236
+ "postId" = $1,
237
+ "postUrl" = $2
238
+ WHERE id = $3
239
+ `, [result.postId, result.postUrl, post.id])
240
+
241
+ results.push({ id: post.id, success: true })
242
+ } else {
243
+ // Mark as failed
244
+ await query(`
245
+ UPDATE "scheduled_posts"
246
+ SET status = 'failed',
247
+ error = $1
248
+ WHERE id = $2
249
+ `, [result.error, post.id])
250
+
251
+ results.push({ id: post.id, success: false, error: result.error })
252
+ }
253
+ } catch (error) {
254
+ // Mark as failed
255
+ await query(`
256
+ UPDATE "scheduled_posts"
257
+ SET status = 'failed',
258
+ error = $1
259
+ WHERE id = $2
260
+ `, [error instanceof Error ? error.message : 'Unknown error', post.id])
261
+
262
+ results.push({ id: post.id, success: false, error: String(error) })
263
+ }
264
+ }
265
+
266
+ return NextResponse.json({
267
+ success: true,
268
+ processed: results.length,
269
+ results
270
+ })
271
+ }
272
+
273
+ // Helper function
274
+ async function publishScheduledPost(post: any) {
275
+ const account = await getAccount(post.accountId)
276
+ const token = await decryptToken(account.accessToken)
277
+
278
+ if (account.platform === 'instagram_business') {
279
+ return await InstagramAPI.publishPhoto({
280
+ igAccountId: account.platformAccountId,
281
+ accessToken: token,
282
+ imageUrl: post.imageUrl,
283
+ caption: post.caption
284
+ })
285
+ } else {
286
+ return await FacebookAPI.publishPhotoPost({
287
+ pageId: account.platformAccountId,
288
+ pageAccessToken: token,
289
+ message: post.caption,
290
+ imageUrl: post.imageUrl
291
+ })
292
+ }
293
+ }
294
+ ```
295
+
296
+ ### Vercel Cron Configuration
297
+
298
+ ```json
299
+ // vercel.json
300
+ {
301
+ "crons": [
302
+ {
303
+ "path": "/api/cron/process-scheduled-posts",
304
+ "schedule": "*/5 * * * *"
305
+ }
306
+ ]
307
+ }
308
+ ```
309
+
310
+ ## Cross-Posting Automation
311
+
312
+ ### Publish to Multiple Accounts at Once
313
+
314
+ ```typescript
315
+ // app/api/v1/custom/cross-post/route.ts
316
+ export async function POST(request: NextRequest) {
317
+ const authResult = await authenticateRequest(request)
318
+ if (!authResult.success) {
319
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
320
+ }
321
+
322
+ const { accountIds, imageUrl, caption } = await request.json()
323
+
324
+ // Validate user has access to all accounts
325
+ const accounts = await query(`
326
+ SELECT csp.*
327
+ FROM "clients_social_platforms" csp
328
+ JOIN "clients" c ON c.id = csp."parentId"
329
+ WHERE csp.id = ANY($1)
330
+ AND c."userId" = $2
331
+ AND csp."isActive" = true
332
+ `, [accountIds, authResult.user!.id])
333
+
334
+ if (accounts.rowCount !== accountIds.length) {
335
+ return NextResponse.json(
336
+ { error: 'Access denied to one or more accounts' },
337
+ { status: 403 }
338
+ )
339
+ }
340
+
341
+ // Publish to all accounts
342
+ const results = await Promise.allSettled(
343
+ accounts.rows.map(async (account) => {
344
+ const token = await decryptToken(account.accessToken)
345
+
346
+ if (account.platform === 'instagram_business') {
347
+ return await InstagramAPI.publishPhoto({
348
+ igAccountId: account.platformAccountId,
349
+ accessToken: token,
350
+ imageUrl,
351
+ caption
352
+ })
353
+ } else {
354
+ return await FacebookAPI.publishPhotoPost({
355
+ pageId: account.platformAccountId,
356
+ pageAccessToken: token,
357
+ message: caption,
358
+ imageUrl
359
+ })
360
+ }
361
+ })
362
+ )
363
+
364
+ const successful = results.filter(r =>
365
+ r.status === 'fulfilled' && r.value.success
366
+ )
367
+ const failed = results.filter(r =>
368
+ r.status === 'rejected' || (r.status === 'fulfilled' && !r.value.success)
369
+ )
370
+
371
+ return NextResponse.json({
372
+ success: successful.length > 0,
373
+ total: accountIds.length,
374
+ successful: successful.length,
375
+ failed: failed.length,
376
+ results: results.map((r, i) => ({
377
+ accountId: accountIds[i],
378
+ accountName: accounts.rows[i].platformAccountName,
379
+ status: r.status === 'fulfilled' && r.value.success ? 'published' : 'failed',
380
+ postUrl: r.status === 'fulfilled' ? r.value.postUrl : null,
381
+ error: r.status === 'rejected'
382
+ ? r.reason
383
+ : (r.status === 'fulfilled' && !r.value.success ? r.value.error : null)
384
+ }))
385
+ })
386
+ }
387
+ ```
388
+
389
+ ## Analytics Dashboard Integration
390
+
391
+ ### Aggregate Analytics Endpoint
392
+
393
+ ```typescript
394
+ // app/api/v1/custom/analytics/summary/route.ts
395
+ export async function GET(request: NextRequest) {
396
+ const authResult = await authenticateRequest(request)
397
+ if (!authResult.success) {
398
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
399
+ }
400
+
401
+ const { searchParams } = new URL(request.url)
402
+ const clientId = searchParams.get('clientId')
403
+
404
+ // Get all active accounts for client
405
+ const accounts = await query(`
406
+ SELECT csp.*
407
+ FROM "clients_social_platforms" csp
408
+ JOIN "clients" c ON c.id = csp."parentId"
409
+ WHERE csp."parentId" = $1
410
+ AND c."userId" = $2
411
+ AND csp."isActive" = true
412
+ `, [clientId, authResult.user!.id])
413
+
414
+ const analytics = await Promise.all(
415
+ accounts.rows.map(async (account) => {
416
+ const token = await decryptToken(account.accessToken)
417
+
418
+ try {
419
+ if (account.platform === 'instagram_business') {
420
+ const insights = await InstagramAPI.getAccountInsights(
421
+ account.platformAccountId,
422
+ token
423
+ )
424
+
425
+ return {
426
+ accountId: account.id,
427
+ accountName: account.platformAccountName,
428
+ platform: account.platform,
429
+ ...insights
430
+ }
431
+ } else {
432
+ const insights = await FacebookAPI.getPageInsights(
433
+ account.platformAccountId,
434
+ token
435
+ )
436
+
437
+ return {
438
+ accountId: account.id,
439
+ accountName: account.platformAccountName,
440
+ platform: account.platform,
441
+ ...insights
442
+ }
443
+ }
444
+ } catch (error) {
445
+ return {
446
+ accountId: account.id,
447
+ accountName: account.platformAccountName,
448
+ platform: account.platform,
449
+ error: error instanceof Error ? error.message : 'Failed to fetch insights'
450
+ }
451
+ }
452
+ })
453
+ )
454
+
455
+ // Calculate totals
456
+ const totals = analytics.reduce((acc, curr) => {
457
+ if (!curr.error) {
458
+ acc.impressions += curr.impressions || 0
459
+ acc.reach += curr.reach || 0
460
+ acc.engagement += curr.engagement || 0
461
+ }
462
+ return acc
463
+ }, { impressions: 0, reach: 0, engagement: 0 })
464
+
465
+ return NextResponse.json({
466
+ success: true,
467
+ totals,
468
+ accounts: analytics
469
+ })
470
+ }
471
+ ```
472
+
473
+ ### Publish History Dashboard
474
+
475
+ ```typescript
476
+ // app/api/v1/custom/analytics/publish-history/route.ts
477
+ export async function GET(request: NextRequest) {
478
+ const authResult = await authenticateRequest(request)
479
+ if (!authResult.success) {
480
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
481
+ }
482
+
483
+ const { searchParams } = new URL(request.url)
484
+ const days = parseInt(searchParams.get('days') || '30')
485
+
486
+ // Get publish history from audit logs
487
+ const history = await query(`
488
+ SELECT
489
+ DATE(al."createdAt") as date,
490
+ al.details->>'platform' as platform,
491
+ COUNT(*) FILTER (WHERE al.action = 'post_published') as successful,
492
+ COUNT(*) FILTER (WHERE al.action = 'post_failed') as failed,
493
+ array_agg(DISTINCT al."accountId") as account_ids
494
+ FROM "audit_logs" al
495
+ WHERE al."userId" = $1
496
+ AND al.action IN ('post_published', 'post_failed')
497
+ AND al."createdAt" > NOW() - INTERVAL '${days} days'
498
+ GROUP BY DATE(al."createdAt"), al.details->>'platform'
499
+ ORDER BY date DESC
500
+ `, [authResult.user!.id])
501
+
502
+ return NextResponse.json({
503
+ success: true,
504
+ period: `${days} days`,
505
+ history: history.rows
506
+ })
507
+ }
508
+ ```
509
+
510
+ ## Content Management Integration
511
+
512
+ ### Save Post as Template
513
+
514
+ ```typescript
515
+ // app/api/v1/custom/templates/save/route.ts
516
+ export async function POST(request: NextRequest) {
517
+ const authResult = await authenticateRequest(request)
518
+ if (!authResult.success) {
519
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
520
+ }
521
+
522
+ const { name, caption, imageUrl, tags } = await request.json()
523
+
524
+ const result = await query(`
525
+ INSERT INTO "post_templates" (
526
+ name, caption, "imageUrl", tags, "createdBy"
527
+ ) VALUES ($1, $2, $3, $4, $5)
528
+ RETURNING id
529
+ `, [name, caption, imageUrl, tags, authResult.user!.id])
530
+
531
+ return NextResponse.json({
532
+ success: true,
533
+ templateId: result.rows[0].id
534
+ })
535
+ }
536
+ ```
537
+
538
+ ### Use Template to Publish
539
+
540
+ ```typescript
541
+ // app/api/v1/custom/templates/publish/[templateId]/route.ts
542
+ export async function POST(
543
+ request: NextRequest,
544
+ { params }: { params: Promise<{ templateId: string }> }
545
+ ) {
546
+ const { templateId } = await params
547
+ const authResult = await authenticateRequest(request)
548
+ const { accountId, customCaption } = await request.json()
549
+
550
+ // Get template
551
+ const template = await query(`
552
+ SELECT * FROM "post_templates"
553
+ WHERE id = $1
554
+ `, [templateId])
555
+
556
+ if (template.rowCount === 0) {
557
+ return NextResponse.json({ error: 'Template not found' }, { status: 404 })
558
+ }
559
+
560
+ const { imageUrl, caption } = template.rows[0]
561
+ const finalCaption = customCaption || caption
562
+
563
+ // Publish using template
564
+ const account = await getAccount(accountId)
565
+ const token = await decryptToken(account.accessToken)
566
+
567
+ const result = await publishToAccount(account, token, {
568
+ imageUrl,
569
+ caption: finalCaption
570
+ })
571
+
572
+ return NextResponse.json(result)
573
+ }
574
+ ```
575
+
576
+ ## Webhook Integration
577
+
578
+ ### Receive Notifications from Meta
579
+
580
+ ```typescript
581
+ // app/api/webhooks/meta/route.ts
582
+ export async function POST(request: NextRequest) {
583
+ const signature = request.headers.get('x-hub-signature-256')
584
+ const body = await request.text()
585
+
586
+ // Verify signature
587
+ if (!verifyWebhookSignature(signature, body)) {
588
+ return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
589
+ }
590
+
591
+ const event = JSON.parse(body)
592
+
593
+ // Handle different event types
594
+ for (const entry of event.entry) {
595
+ for (const change of entry.changes) {
596
+ if (change.field === 'feed') {
597
+ // Post published or updated
598
+ await handleFeedChange(change.value)
599
+ } else if (change.field === 'comments') {
600
+ // New comment
601
+ await handleNewComment(change.value)
602
+ }
603
+ }
604
+ }
605
+
606
+ return NextResponse.json({ success: true })
607
+ }
608
+
609
+ // Verification endpoint (GET)
610
+ export async function GET(request: NextRequest) {
611
+ const { searchParams } = new URL(request.url)
612
+ const mode = searchParams.get('hub.mode')
613
+ const token = searchParams.get('hub.verify_token')
614
+ const challenge = searchParams.get('hub.challenge')
615
+
616
+ if (mode === 'subscribe' && token === process.env.WEBHOOK_VERIFY_TOKEN) {
617
+ return new NextResponse(challenge, { status: 200 })
618
+ }
619
+
620
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
621
+ }
622
+ ```
623
+
624
+ ## Best Practices
625
+
626
+ ### Do's ✅
627
+
628
+ **1. Validate Permissions:**
629
+ ```typescript
630
+ // Always check user has access to resources
631
+ const hasAccess = await checkUserAccessToAccount(userId, accountId)
632
+ if (!hasAccess) throw new Error('Access denied')
633
+ ```
634
+
635
+ **2. Handle Failures Gracefully:**
636
+ ```typescript
637
+ // Don't fail entire batch if one post fails
638
+ const results = await Promise.allSettled(publishOperations)
639
+ ```
640
+
641
+ **3. Rate Limit Custom Endpoints:**
642
+ ```typescript
643
+ // Implement rate limiting
644
+ const rateLimit = rateLimit({
645
+ interval: 60 * 1000, // 1 minute
646
+ uniqueTokenPerInterval: 500,
647
+ })
648
+
649
+ await rateLimit.check(request, 10, userId) // 10 requests per minute
650
+ ```
651
+
652
+ **4. Audit Custom Actions:**
653
+ ```typescript
654
+ // Log custom operations
655
+ await logAudit('custom_bulk_publish', {
656
+ accountCount: accountIds.length,
657
+ successful: results.successful,
658
+ failed: results.failed
659
+ })
660
+ ```
661
+
662
+ ### Don'ts ❌
663
+
664
+ **1. Don't Skip Token Validation:**
665
+ ```typescript
666
+ // ❌ Bad
667
+ const token = await decryptToken(account.accessToken)
668
+ await publishDirectly(token)
669
+
670
+ // ✅ Good
671
+ if (isTokenExpired(account.tokenExpiresAt)) {
672
+ await refreshToken(account.id)
673
+ }
674
+ const token = await decryptToken(account.accessToken)
675
+ ```
676
+
677
+ **2. Don't Expose Internal Errors:**
678
+ ```typescript
679
+ // ❌ Bad
680
+ catch (error) {
681
+ return NextResponse.json({ error: error.stack })
682
+ }
683
+
684
+ // ✅ Good
685
+ catch (error) {
686
+ console.error('Internal error:', error)
687
+ return NextResponse.json({ error: 'Publishing failed' })
688
+ }
689
+ ```
690
+
691
+ ## Next Steps
692
+
693
+ - **[Provider APIs](./01-provider-apis.md)** - API wrappers reference
694
+ - **[Per-Client Architecture](./03-per-client-architecture.md)** - Understand data model
695
+ - **[Agency Management](../04-use-cases/01-agency-management.md)** - Real-world examples