@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,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social Media Publish Endpoint
|
|
3
|
+
*
|
|
4
|
+
* Publishes content to connected Instagram Business or Facebook Page accounts
|
|
5
|
+
* Accessible via: /api/v1/plugin/social-media-publisher/social/publish
|
|
6
|
+
*
|
|
7
|
+
* Token Refresh Strategy:
|
|
8
|
+
* - Checks token expiry before publishing
|
|
9
|
+
* - Automatically refreshes if token expires within 10 minutes
|
|
10
|
+
* - Uses Meta's token exchange endpoint (fb_exchange_token)
|
|
11
|
+
* - Re-encrypts tokens with AES-256-GCM
|
|
12
|
+
* - Blocks publishing if refresh fails (prevents wasted API calls)
|
|
13
|
+
*
|
|
14
|
+
* This ensures manual publishes never fail due to expired tokens.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
18
|
+
import { authenticateRequest } from '@nextsparkjs/core/lib/api/auth/dual-auth'
|
|
19
|
+
import { TokenEncryption } from '@nextsparkjs/core/lib/oauth/encryption'
|
|
20
|
+
import { FacebookAPI } from '../../../lib/providers/facebook'
|
|
21
|
+
import { InstagramAPI } from '../../../lib/providers/instagram'
|
|
22
|
+
import { PublishPhotoSchema, validateImageUrl, validateCaption, platformRequiresImage } from '../../../lib/validation'
|
|
23
|
+
import { queryOneWithRLS, mutateWithRLS } from '@nextsparkjs/core/lib/db'
|
|
24
|
+
|
|
25
|
+
export async function POST(request: NextRequest) {
|
|
26
|
+
try {
|
|
27
|
+
// 1. Authentication
|
|
28
|
+
const authResult = await authenticateRequest(request)
|
|
29
|
+
if (!authResult.success) {
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{ error: 'Authentication required' },
|
|
32
|
+
{ status: 401 }
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. Parse and validate request body
|
|
37
|
+
const body = await request.json()
|
|
38
|
+
const validation = PublishPhotoSchema.safeParse(body)
|
|
39
|
+
|
|
40
|
+
if (!validation.success) {
|
|
41
|
+
return NextResponse.json(
|
|
42
|
+
{
|
|
43
|
+
error: 'Validation failed',
|
|
44
|
+
details: validation.error.issues,
|
|
45
|
+
},
|
|
46
|
+
{ status: 400 }
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { accountId, imageUrl, imageUrls, caption, platform } = validation.data
|
|
51
|
+
|
|
52
|
+
// Determine media URLs (UPDATED: Support carousels)
|
|
53
|
+
const allImageUrls = imageUrls && imageUrls.length > 0
|
|
54
|
+
? imageUrls
|
|
55
|
+
: imageUrl
|
|
56
|
+
? [imageUrl]
|
|
57
|
+
: []
|
|
58
|
+
const isCarousel = allImageUrls.length >= 2
|
|
59
|
+
|
|
60
|
+
// 3. Platform-specific validation
|
|
61
|
+
// Some platforms (Instagram, TikTok, Pinterest) REQUIRE image, Facebook/LinkedIn allow text-only
|
|
62
|
+
if (platformRequiresImage(platform) && allImageUrls.length === 0) {
|
|
63
|
+
return NextResponse.json(
|
|
64
|
+
{
|
|
65
|
+
error: `${platform} requires an image`,
|
|
66
|
+
details: 'This platform does not support text-only posts. Please include at least one image.',
|
|
67
|
+
},
|
|
68
|
+
{ status: 400 }
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Validate image URLs format if provided
|
|
73
|
+
for (const url of allImageUrls) {
|
|
74
|
+
const imageValidation = validateImageUrl(url)
|
|
75
|
+
if (!imageValidation.valid) {
|
|
76
|
+
return NextResponse.json(
|
|
77
|
+
{
|
|
78
|
+
error: 'Invalid image URL',
|
|
79
|
+
details: imageValidation.error,
|
|
80
|
+
},
|
|
81
|
+
{ status: 400 }
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (caption) {
|
|
87
|
+
const captionValidation = validateCaption(caption, platform)
|
|
88
|
+
if (!captionValidation.valid) {
|
|
89
|
+
return NextResponse.json(
|
|
90
|
+
{
|
|
91
|
+
error: 'Invalid caption',
|
|
92
|
+
details: captionValidation.error,
|
|
93
|
+
},
|
|
94
|
+
{ status: 400 }
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 4. Get social account from database (clients_social_platforms child entity)
|
|
100
|
+
const account = await queryOneWithRLS<{
|
|
101
|
+
id: string
|
|
102
|
+
platform: string
|
|
103
|
+
platformAccountId: string
|
|
104
|
+
username: string
|
|
105
|
+
accessToken: string
|
|
106
|
+
tokenExpiresAt: string
|
|
107
|
+
isActive: boolean
|
|
108
|
+
permissions: string
|
|
109
|
+
accountMetadata: string
|
|
110
|
+
}>(
|
|
111
|
+
`SELECT id, platform, "platformAccountId", "username",
|
|
112
|
+
"accessToken", "tokenExpiresAt", "isActive", permissions, "accountMetadata"
|
|
113
|
+
FROM "clients_social_platforms"
|
|
114
|
+
WHERE id = $1 AND platform = $2 AND "isActive" = true`,
|
|
115
|
+
[accountId, platform],
|
|
116
|
+
authResult.user!.id
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// Verify account exists (access verified by RLS policy)
|
|
120
|
+
if (!account) {
|
|
121
|
+
return NextResponse.json(
|
|
122
|
+
{ error: 'Account not found or access denied' },
|
|
123
|
+
{ status: 404 }
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Verify account is active
|
|
128
|
+
if (!account.isActive) {
|
|
129
|
+
return NextResponse.json(
|
|
130
|
+
{
|
|
131
|
+
error: 'Account is inactive',
|
|
132
|
+
message: 'This account has been disconnected. Please reconnect it.',
|
|
133
|
+
},
|
|
134
|
+
{ status: 403 }
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 5. Decrypt access token (handle both encrypted and plain tokens)
|
|
139
|
+
let decryptedToken: string
|
|
140
|
+
if (account.accessToken.includes(':')) {
|
|
141
|
+
// Token is encrypted with format "encrypted:iv:keyId"
|
|
142
|
+
const [encrypted, iv, keyId] = account.accessToken.split(':')
|
|
143
|
+
decryptedToken = await TokenEncryption.decrypt(encrypted, iv, keyId)
|
|
144
|
+
} else {
|
|
145
|
+
// Token is in plain text (legacy or manual entry)
|
|
146
|
+
decryptedToken = account.accessToken
|
|
147
|
+
console.warn('[social-publish] Using unencrypted token - consider re-connecting account')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 6. Check if token needs refresh
|
|
151
|
+
const now = new Date()
|
|
152
|
+
const expiresAt = new Date(account.tokenExpiresAt)
|
|
153
|
+
const minutesUntilExpiry = (expiresAt.getTime() - now.getTime()) / (1000 * 60)
|
|
154
|
+
|
|
155
|
+
// Token refresh threshold: 10 minutes before expiration
|
|
156
|
+
const REFRESH_THRESHOLD_MINUTES = 10
|
|
157
|
+
|
|
158
|
+
if (minutesUntilExpiry < REFRESH_THRESHOLD_MINUTES) {
|
|
159
|
+
console.log(`[social-publish] 🔄 Token expires in ${minutesUntilExpiry.toFixed(2)} minutes - refreshing...`)
|
|
160
|
+
|
|
161
|
+
// Attempt to refresh the token
|
|
162
|
+
const refreshResult = await refreshAccountToken(accountId, platform, decryptedToken)
|
|
163
|
+
|
|
164
|
+
if (refreshResult.success && refreshResult.newAccessToken) {
|
|
165
|
+
console.log(`[social-publish] ✅ Token refreshed successfully for ${platform}`)
|
|
166
|
+
// Update decrypted token for this request (DB already updated)
|
|
167
|
+
decryptedToken = refreshResult.newAccessToken
|
|
168
|
+
account.tokenExpiresAt = refreshResult.newExpiresAt!
|
|
169
|
+
} else {
|
|
170
|
+
console.error(`[social-publish] ❌ Token refresh failed: ${refreshResult.error}`)
|
|
171
|
+
return NextResponse.json(
|
|
172
|
+
{
|
|
173
|
+
error: 'Token expired and refresh failed',
|
|
174
|
+
details: refreshResult.error,
|
|
175
|
+
suggestion: 'Please reconnect your social media account'
|
|
176
|
+
},
|
|
177
|
+
{ status: 403 }
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
console.log(`[social-publish] ✅ Token valid for ${minutesUntilExpiry.toFixed(2)} more minutes`)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 7. Publish to platform (UPDATED: Support carousels)
|
|
185
|
+
let publishResult
|
|
186
|
+
|
|
187
|
+
if (platform === 'instagram_business') {
|
|
188
|
+
if (isCarousel) {
|
|
189
|
+
console.log(`[social-publish] Publishing ${allImageUrls.length}-image carousel to Instagram`)
|
|
190
|
+
publishResult = await InstagramAPI.publishCarousel({
|
|
191
|
+
igAccountId: account.platformAccountId,
|
|
192
|
+
accessToken: decryptedToken,
|
|
193
|
+
carouselItems: allImageUrls,
|
|
194
|
+
caption,
|
|
195
|
+
})
|
|
196
|
+
} else {
|
|
197
|
+
console.log('[social-publish] Publishing single image to Instagram')
|
|
198
|
+
publishResult = await InstagramAPI.publishPhoto({
|
|
199
|
+
igAccountId: account.platformAccountId,
|
|
200
|
+
accessToken: decryptedToken,
|
|
201
|
+
imageUrl: allImageUrls[0], // ✅ Already validated above (Instagram requires image)
|
|
202
|
+
caption,
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
} else if (platform === 'facebook_page') {
|
|
206
|
+
if (isCarousel) {
|
|
207
|
+
console.log(`[social-publish] Publishing ${allImageUrls.length}-image carousel to Facebook`)
|
|
208
|
+
publishResult = await FacebookAPI.publishCarouselPost({
|
|
209
|
+
pageId: account.platformAccountId,
|
|
210
|
+
pageAccessToken: decryptedToken,
|
|
211
|
+
message: caption || '',
|
|
212
|
+
imageUrls: allImageUrls,
|
|
213
|
+
})
|
|
214
|
+
} else if (allImageUrls.length > 0) {
|
|
215
|
+
console.log('[social-publish] Publishing single image to Facebook')
|
|
216
|
+
publishResult = await FacebookAPI.publishPhotoPost({
|
|
217
|
+
pageId: account.platformAccountId,
|
|
218
|
+
pageAccessToken: decryptedToken,
|
|
219
|
+
message: caption || '',
|
|
220
|
+
imageUrl: allImageUrls[0],
|
|
221
|
+
})
|
|
222
|
+
} else {
|
|
223
|
+
console.log('[social-publish] Publishing text-only post to Facebook')
|
|
224
|
+
publishResult = await FacebookAPI.publishTextPost({
|
|
225
|
+
pageId: account.platformAccountId,
|
|
226
|
+
pageAccessToken: decryptedToken,
|
|
227
|
+
message: caption || '',
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
return NextResponse.json(
|
|
232
|
+
{ error: 'Unsupported platform' },
|
|
233
|
+
{ status: 400 }
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 8. Create audit log (UPDATED: Track carousel details)
|
|
238
|
+
const auditAction = publishResult.success ? 'post_published' : 'post_failed'
|
|
239
|
+
await mutateWithRLS(
|
|
240
|
+
`INSERT INTO "audit_logs"
|
|
241
|
+
("userId", "accountId", action, details, "ipAddress", "userAgent")
|
|
242
|
+
VALUES ($1, $2::uuid, $3, $4, $5, $6)`,
|
|
243
|
+
[
|
|
244
|
+
authResult.user!.id,
|
|
245
|
+
accountId, // clients_social_platforms.id (TEXT) cast to UUID for audit_logs compatibility
|
|
246
|
+
auditAction,
|
|
247
|
+
JSON.stringify({
|
|
248
|
+
platform,
|
|
249
|
+
accountName: account.username,
|
|
250
|
+
success: publishResult.success,
|
|
251
|
+
postId: publishResult.postId,
|
|
252
|
+
error: publishResult.error,
|
|
253
|
+
postType: isCarousel ? 'carousel' : (allImageUrls.length > 0 ? 'photo' : 'text'),
|
|
254
|
+
imageCount: allImageUrls.length,
|
|
255
|
+
imageUrls: allImageUrls,
|
|
256
|
+
caption: caption || '',
|
|
257
|
+
publishedAt: new Date().toISOString()
|
|
258
|
+
}),
|
|
259
|
+
request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || null,
|
|
260
|
+
request.headers.get('user-agent') || null
|
|
261
|
+
],
|
|
262
|
+
authResult.user!.id
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
// 9. Return result
|
|
266
|
+
if (!publishResult.success) {
|
|
267
|
+
return NextResponse.json(
|
|
268
|
+
{
|
|
269
|
+
error: 'Publishing failed',
|
|
270
|
+
platform,
|
|
271
|
+
details: publishResult.error,
|
|
272
|
+
errorDetails: publishResult.errorDetails,
|
|
273
|
+
},
|
|
274
|
+
{ status: 500 }
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return NextResponse.json({
|
|
279
|
+
success: true,
|
|
280
|
+
platform,
|
|
281
|
+
postId: publishResult.postId,
|
|
282
|
+
postUrl: publishResult.postUrl,
|
|
283
|
+
message: `Successfully published to ${platform}`,
|
|
284
|
+
})
|
|
285
|
+
} catch (error: unknown) {
|
|
286
|
+
console.error('❌ Social publish error:', error)
|
|
287
|
+
console.error('❌ Error stack:', error instanceof Error ? error.stack : 'No stack trace')
|
|
288
|
+
console.error('❌ Error details:', {
|
|
289
|
+
name: error instanceof Error ? error.name : 'Unknown',
|
|
290
|
+
message: error instanceof Error ? error.message : String(error),
|
|
291
|
+
type: typeof error
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
return NextResponse.json(
|
|
295
|
+
{
|
|
296
|
+
error: 'Failed to publish content',
|
|
297
|
+
details: error instanceof Error ? error.message : 'Unknown error',
|
|
298
|
+
stack: error instanceof Error ? error.stack : undefined
|
|
299
|
+
},
|
|
300
|
+
{ status: 500 }
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Refresh OAuth token for a social media account
|
|
307
|
+
*
|
|
308
|
+
* This function handles token refresh for accounts stored in clients_social_platforms table.
|
|
309
|
+
* Uses Meta's token exchange endpoint to get a fresh long-lived token.
|
|
310
|
+
*
|
|
311
|
+
* @param accountId - Social platform account ID (clients_social_platforms.id)
|
|
312
|
+
* @param platform - Platform type ('instagram_business' | 'facebook_page')
|
|
313
|
+
* @param currentToken - Current decrypted access token
|
|
314
|
+
* @returns Refresh result with new token (decrypted) and expiration
|
|
315
|
+
*/
|
|
316
|
+
async function refreshAccountToken(
|
|
317
|
+
accountId: string,
|
|
318
|
+
platform: string,
|
|
319
|
+
currentToken: string
|
|
320
|
+
): Promise<{
|
|
321
|
+
success: boolean
|
|
322
|
+
newAccessToken?: string
|
|
323
|
+
newExpiresAt?: string
|
|
324
|
+
error?: string
|
|
325
|
+
}> {
|
|
326
|
+
try {
|
|
327
|
+
// 1. Get OAuth client credentials from environment
|
|
328
|
+
let clientId: string | undefined
|
|
329
|
+
let clientSecret: string | undefined
|
|
330
|
+
|
|
331
|
+
if (platform === 'facebook_page' || platform === 'instagram_business') {
|
|
332
|
+
clientId = process.env.FACEBOOK_CLIENT_ID
|
|
333
|
+
clientSecret = process.env.FACEBOOK_CLIENT_SECRET
|
|
334
|
+
}
|
|
335
|
+
// Add more platforms as needed (Google, Twitter, etc.)
|
|
336
|
+
|
|
337
|
+
if (!clientId || !clientSecret) {
|
|
338
|
+
return {
|
|
339
|
+
success: false,
|
|
340
|
+
error: `OAuth credentials not configured for ${platform}`
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 2. Call Meta token refresh endpoint
|
|
345
|
+
// For Facebook/Instagram, we use the token exchange endpoint
|
|
346
|
+
// https://developers.facebook.com/docs/facebook-login/guides/access-tokens/get-long-lived
|
|
347
|
+
const tokenEndpoint = 'https://graph.facebook.com/v18.0/oauth/access_token'
|
|
348
|
+
const params = new URLSearchParams({
|
|
349
|
+
grant_type: 'fb_exchange_token',
|
|
350
|
+
client_id: clientId,
|
|
351
|
+
client_secret: clientSecret,
|
|
352
|
+
fb_exchange_token: currentToken
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
const response = await fetch(`${tokenEndpoint}?${params.toString()}`, {
|
|
356
|
+
method: 'GET'
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
if (!response.ok) {
|
|
360
|
+
const errorData = await response.json()
|
|
361
|
+
throw new Error(`Meta API error: ${errorData.error?.message || response.statusText}`)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const data = await response.json()
|
|
365
|
+
|
|
366
|
+
// 3. Encrypt new access token
|
|
367
|
+
const newAccessToken = data.access_token
|
|
368
|
+
const expiresIn = data.expires_in || 5184000 // Default: 60 days
|
|
369
|
+
|
|
370
|
+
const { encrypted, iv, keyId } = await TokenEncryption.encrypt(newAccessToken)
|
|
371
|
+
const encryptedToken = `${encrypted}:${iv}:${keyId}`
|
|
372
|
+
const newExpiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()
|
|
373
|
+
|
|
374
|
+
// 4. Update database with new token
|
|
375
|
+
await mutateWithRLS(
|
|
376
|
+
`UPDATE "clients_social_platforms"
|
|
377
|
+
SET "accessToken" = $1,
|
|
378
|
+
"tokenExpiresAt" = $2,
|
|
379
|
+
"updatedAt" = NOW()
|
|
380
|
+
WHERE id = $3`,
|
|
381
|
+
[encryptedToken, newExpiresAt, accountId],
|
|
382
|
+
'system'
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
console.log(`[social-publish] 🔐 Token refreshed and encrypted for account ${accountId}`)
|
|
386
|
+
console.log(`[social-publish] 📅 New expiration: ${newExpiresAt}`)
|
|
387
|
+
|
|
388
|
+
// Return DECRYPTED token for immediate use in this request
|
|
389
|
+
return {
|
|
390
|
+
success: true,
|
|
391
|
+
newAccessToken: newAccessToken, // Decrypted for immediate use
|
|
392
|
+
newExpiresAt
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
} catch (error) {
|
|
396
|
+
console.error(`[social-publish] Token refresh failed for account ${accountId}:`, error)
|
|
397
|
+
return {
|
|
398
|
+
success: false,
|
|
399
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|