@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,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
+ }