@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,327 @@
1
+ /**
2
+ * Social Media Connect Endpoint
3
+ *
4
+ * OAuth initiator and callback handler for connecting Facebook Pages and Instagram Business Accounts
5
+ * Accessible via: /api/v1/plugin/social-media-publisher/social/connect
6
+ */
7
+
8
+ import { NextRequest, NextResponse } from 'next/server'
9
+ import { authenticateRequest } from '@nextsparkjs/core/lib/api/auth/dual-auth'
10
+ import { TokenEncryption } from '@nextsparkjs/core/lib/oauth/encryption'
11
+ import { FacebookAPI } from '../../../lib/providers/facebook'
12
+ import { ConnectAccountSchema } from '../../../lib/validation'
13
+ import { exchangeCodeForToken, getOAuthConfig, generateAuthorizationUrl } from '../../../lib/oauth-helper'
14
+ import type { SocialPlatform } from '../../../types/social.types'
15
+ import { mutateWithRLS } from '@nextsparkjs/core/lib/db'
16
+
17
+ /**
18
+ * GET - Initiate OAuth flow
19
+ * Redirects user to Facebook OAuth authorization page
20
+ */
21
+ export async function GET(request: NextRequest) {
22
+ try {
23
+ const { searchParams } = new URL(request.url)
24
+ const platform = searchParams.get('platform') || 'instagram_business'
25
+ const clientId = searchParams.get('clientId')
26
+ const randomState = searchParams.get('state') || ''
27
+ const mode = searchParams.get('mode') // 'preview' or undefined (save mode)
28
+
29
+ // Validate required parameters
30
+ if (!clientId) {
31
+ return NextResponse.json(
32
+ {
33
+ error: 'Missing clientId',
34
+ details: 'clientId parameter is required to associate social accounts with the correct client',
35
+ },
36
+ { status: 400 }
37
+ )
38
+ }
39
+
40
+ // Validate platform
41
+ if (platform !== 'facebook_page' && platform !== 'instagram_business') {
42
+ return NextResponse.json(
43
+ {
44
+ error: 'Invalid platform',
45
+ details: 'Platform must be "facebook_page" or "instagram_business"',
46
+ },
47
+ { status: 400 }
48
+ )
49
+ }
50
+
51
+ // Build state with clientId, platform, and mode embedded
52
+ // Format: "{randomState}&platform={platform}&clientId={clientId}&mode={mode}"
53
+ let state = `${randomState}&platform=${platform}&clientId=${clientId}`
54
+ if (mode) {
55
+ state += `&mode=${mode}`
56
+ }
57
+
58
+ // Get OAuth configuration
59
+ const oauthConfig = getOAuthConfig()
60
+
61
+ console.log('[social-connect] OAuth Config:', {
62
+ facebookClientId: oauthConfig.facebookClientId,
63
+ redirectUri: oauthConfig.redirectUri,
64
+ baseUrl: process.env.NEXT_PUBLIC_APP_URL,
65
+ platform
66
+ })
67
+
68
+ // Generate authorization URL with proper scopes for platform
69
+ const authorizationUrl = generateAuthorizationUrl(
70
+ platform as 'facebook_page' | 'instagram_business',
71
+ oauthConfig,
72
+ state
73
+ )
74
+
75
+ console.log('[social-connect] Generated Authorization URL:', authorizationUrl)
76
+ console.log('[social-connect] Redirecting to Instagram OAuth:', {
77
+ platform,
78
+ clientId,
79
+ mode,
80
+ state,
81
+ redirectUri: oauthConfig.redirectUri,
82
+ authUrl: authorizationUrl
83
+ })
84
+
85
+ // Redirect to Facebook OAuth
86
+ return NextResponse.redirect(authorizationUrl)
87
+ } catch (error) {
88
+ console.error('❌ OAuth initiation error:', error)
89
+ return NextResponse.json(
90
+ {
91
+ error: 'Failed to initiate OAuth',
92
+ details: error instanceof Error ? error.message : 'Unknown error',
93
+ },
94
+ { status: 500 }
95
+ )
96
+ }
97
+ }
98
+
99
+ /**
100
+ * POST - Handle OAuth callback (deprecated - use /callback endpoint instead)
101
+ * This endpoint receives the authorization code and exchanges it for access token
102
+ */
103
+ export async function POST(request: NextRequest) {
104
+ try {
105
+ // 1. Authentication
106
+ const authResult = await authenticateRequest(request)
107
+ if (!authResult.success) {
108
+ return NextResponse.json(
109
+ { error: 'Authentication required' },
110
+ { status: 401 }
111
+ )
112
+ }
113
+
114
+ // 2. Parse and validate request body
115
+ const body = await request.json()
116
+ const validation = ConnectAccountSchema.safeParse(body)
117
+
118
+ if (!validation.success) {
119
+ return NextResponse.json(
120
+ {
121
+ error: 'Validation failed',
122
+ details: validation.error.issues,
123
+ },
124
+ { status: 400 }
125
+ )
126
+ }
127
+
128
+ const { code, state, platform } = validation.data
129
+
130
+ // 3. Verify state to prevent CSRF
131
+ // TODO: Implement state validation with session storage
132
+ // For now, we'll just log it
133
+ console.log('[social-connect] OAuth state:', state)
134
+
135
+ // 4. Exchange authorization code for access token
136
+ const oauthConfig = getOAuthConfig()
137
+ const tokenData = await exchangeCodeForToken(
138
+ code,
139
+ oauthConfig,
140
+ platform as 'facebook_page' | 'instagram_business'
141
+ )
142
+
143
+ const userAccessToken = tokenData.accessToken
144
+ const expiresIn = tokenData.expiresIn
145
+
146
+ // 5. Get accounts based on platform
147
+ let accountsToConnect: Array<{
148
+ platformAccountId: string
149
+ username: string
150
+ accessToken: string
151
+ permissions: string[]
152
+ metadata: any
153
+ }> = []
154
+
155
+ if (platform === 'facebook_page') {
156
+ // Get Facebook Pages
157
+ const pages = await FacebookAPI.getUserPages(userAccessToken)
158
+
159
+ // For each page, get detailed stats
160
+ for (const page of pages) {
161
+ try {
162
+ const pageInfo = await FacebookAPI.getPageInfo(page.id, page.accessToken)
163
+
164
+ accountsToConnect.push({
165
+ platformAccountId: page.id,
166
+ username: page.name,
167
+ accessToken: page.accessToken, // Use page token, not user token
168
+ permissions: page.tasks || [],
169
+ metadata: {
170
+ profilePictureUrl: pageInfo.profilePictureUrl,
171
+ fanCount: pageInfo.fanCount,
172
+ about: pageInfo.about,
173
+ category: pageInfo.category || page.category,
174
+ link: pageInfo.link,
175
+ },
176
+ })
177
+ } catch (error) {
178
+ console.error(`[social-connect] Failed to get stats for page ${page.id}:`, error)
179
+ // Fallback: add page without detailed stats
180
+ accountsToConnect.push({
181
+ platformAccountId: page.id,
182
+ username: page.name,
183
+ accessToken: page.accessToken,
184
+ permissions: page.tasks || [],
185
+ metadata: {
186
+ category: page.category,
187
+ pictureUrl: page.pictureUrl,
188
+ },
189
+ })
190
+ }
191
+ }
192
+ } else if (platform === 'instagram_business') {
193
+ // Get Facebook Pages first (Instagram Business Accounts are linked to Pages)
194
+ const pages = await FacebookAPI.getUserPages(userAccessToken)
195
+
196
+ // For each page, check if it has an Instagram Business Account
197
+ for (const page of pages) {
198
+ const igAccount = await FacebookAPI.getInstagramBusinessAccount(
199
+ page.id,
200
+ page.accessToken
201
+ )
202
+
203
+ if (igAccount) {
204
+ accountsToConnect.push({
205
+ platformAccountId: igAccount.id,
206
+ username: igAccount.username,
207
+ accessToken: page.accessToken, // Use page token (has IG permissions)
208
+ permissions: ['instagram_basic', 'instagram_content_publish', 'instagram_manage_comments'],
209
+ metadata: {
210
+ profilePictureUrl: igAccount.profilePictureUrl,
211
+ followersCount: igAccount.followersCount,
212
+ followsCount: igAccount.followsCount,
213
+ mediaCount: igAccount.mediaCount,
214
+ linkedPageId: page.id,
215
+ linkedPageName: page.name,
216
+ },
217
+ })
218
+ }
219
+ }
220
+
221
+ if (accountsToConnect.length === 0) {
222
+ return NextResponse.json(
223
+ {
224
+ error: 'No Instagram Business Accounts found',
225
+ message:
226
+ 'No Instagram Business Accounts are linked to your Facebook Pages. ' +
227
+ 'Please connect an Instagram Business Account to one of your Facebook Pages first.',
228
+ },
229
+ { status: 404 }
230
+ )
231
+ }
232
+ }
233
+
234
+ // 6. Encrypt tokens and save accounts
235
+ const savedAccounts = []
236
+
237
+ for (const account of accountsToConnect) {
238
+ // Encrypt access token
239
+ const encryptedToken = await TokenEncryption.encrypt(account.accessToken)
240
+
241
+ // Calculate expiration date
242
+ const now = new Date()
243
+ const expiresAt = new Date(now.getTime() + expiresIn * 1000)
244
+
245
+ // Insert social account into database
246
+ const result = await mutateWithRLS<{ id: string; platform: string; username: string; createdAt: string; permissions: string; accountMetadata: string }>(
247
+ `INSERT INTO "social_accounts"
248
+ ("userId", platform, "platformAccountId", "username", "accessToken", "tokenExpiresAt", permissions, "accountMetadata", "isActive")
249
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
250
+ ON CONFLICT ("platformAccountId")
251
+ DO UPDATE SET
252
+ "accessToken" = EXCLUDED."accessToken",
253
+ "tokenExpiresAt" = EXCLUDED."tokenExpiresAt",
254
+ permissions = EXCLUDED.permissions,
255
+ "accountMetadata" = EXCLUDED."accountMetadata",
256
+ "isActive" = EXCLUDED."isActive",
257
+ "updatedAt" = CURRENT_TIMESTAMP
258
+ RETURNING *`,
259
+ [
260
+ authResult.user!.id,
261
+ platform,
262
+ account.platformAccountId,
263
+ account.username,
264
+ `${encryptedToken.encrypted}:${encryptedToken.iv}:${encryptedToken.keyId}`,
265
+ expiresAt.toISOString(),
266
+ JSON.stringify(account.permissions),
267
+ JSON.stringify(account.metadata),
268
+ true
269
+ ],
270
+ authResult.user!.id
271
+ )
272
+
273
+ const savedAccount = result.rows[0]!
274
+ savedAccounts.push(savedAccount)
275
+
276
+ // Create audit log entry
277
+ await mutateWithRLS(
278
+ `INSERT INTO "audit_logs"
279
+ ("userId", "accountId", action, details, "ipAddress", "userAgent")
280
+ VALUES ($1, $2, $3, $4, $5, $6)`,
281
+ [
282
+ authResult.user!.id,
283
+ savedAccount.id,
284
+ 'account_connected',
285
+ JSON.stringify({
286
+ platform,
287
+ accountName: account.username,
288
+ success: true,
289
+ connectedAt: new Date().toISOString()
290
+ }),
291
+ request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || null,
292
+ request.headers.get('user-agent') || null
293
+ ],
294
+ authResult.user!.id
295
+ )
296
+
297
+ console.log('[social-connect] ✅ Account saved and audit log created:', {
298
+ accountId: savedAccount.id,
299
+ platform,
300
+ accountName: account.username,
301
+ })
302
+ }
303
+
304
+ return NextResponse.json({
305
+ success: true,
306
+ message: `Successfully connected ${savedAccounts.length} ${platform} account(s)`,
307
+ accounts: savedAccounts.map((acc: any) => ({
308
+ id: acc.id,
309
+ platform: acc.platform,
310
+ accountName: acc.username,
311
+ permissions: typeof acc.permissions === 'string' ? JSON.parse(acc.permissions) : acc.permissions,
312
+ metadata: typeof acc.accountMetadata === 'string' ? JSON.parse(acc.accountMetadata) : acc.accountMetadata,
313
+ connectedAt: acc.createdAt,
314
+ })),
315
+ })
316
+ } catch (error: unknown) {
317
+ console.error('❌ Social connect error:', error)
318
+
319
+ return NextResponse.json(
320
+ {
321
+ error: 'Failed to connect social account',
322
+ details: error instanceof Error ? error.message : 'Unknown error',
323
+ },
324
+ { status: 500 }
325
+ )
326
+ }
327
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Social Media Disconnect Endpoint
3
+ *
4
+ * Disconnects a social media account (marks as inactive)
5
+ * Accessible via: /api/v1/plugin/social-media-publisher/social/disconnect
6
+ */
7
+
8
+ import { NextRequest, NextResponse } from 'next/server'
9
+ import { authenticateRequest } from '@nextsparkjs/core/lib/api/auth/dual-auth'
10
+ import { DisconnectAccountSchema } from '../../../lib/validation'
11
+ import { queryOneWithRLS, mutateWithRLS } from '@nextsparkjs/core/lib/db'
12
+
13
+ export async function POST(request: NextRequest) {
14
+ try {
15
+ // 1. Authentication
16
+ const authResult = await authenticateRequest(request)
17
+ if (!authResult.success) {
18
+ return NextResponse.json(
19
+ { error: 'Authentication required' },
20
+ { status: 401 }
21
+ )
22
+ }
23
+
24
+ // 2. Parse and validate request body
25
+ const body = await request.json()
26
+ const validation = DisconnectAccountSchema.safeParse(body)
27
+
28
+ if (!validation.success) {
29
+ return NextResponse.json(
30
+ {
31
+ error: 'Validation failed',
32
+ details: validation.error.issues,
33
+ },
34
+ { status: 400 }
35
+ )
36
+ }
37
+
38
+ const { accountId } = validation.data
39
+
40
+ // 3. Get social account from database
41
+ const account = await queryOneWithRLS<{
42
+ id: string
43
+ userId: string
44
+ platform: string
45
+ platformAccountId: string
46
+ username: string
47
+ isActive: boolean
48
+ }>(
49
+ `SELECT id, "userId", platform, "platformAccountId", "username", "isActive"
50
+ FROM "social_accounts"
51
+ WHERE id = $1 AND "userId" = $2`,
52
+ [accountId, authResult.user!.id],
53
+ authResult.user!.id
54
+ )
55
+
56
+ // Verify account exists
57
+ if (!account) {
58
+ return NextResponse.json(
59
+ { error: 'Account not found or access denied' },
60
+ { status: 404 }
61
+ )
62
+ }
63
+
64
+ // Verify account is active
65
+ if (!account.isActive) {
66
+ return NextResponse.json(
67
+ {
68
+ error: 'Account already disconnected',
69
+ message: 'This account is already inactive.',
70
+ },
71
+ { status: 400 }
72
+ )
73
+ }
74
+
75
+ // 4. Mark account as inactive (soft delete)
76
+ await mutateWithRLS(
77
+ `UPDATE "social_accounts"
78
+ SET "isActive" = false, "updatedAt" = CURRENT_TIMESTAMP
79
+ WHERE id = $1 AND "userId" = $2`,
80
+ [accountId, authResult.user!.id],
81
+ authResult.user!.id
82
+ )
83
+
84
+ console.log('[social-disconnect] ✅ Account marked as inactive:', {
85
+ accountId,
86
+ userId: authResult.user!.id,
87
+ platform: account.platform,
88
+ accountName: account.username,
89
+ })
90
+
91
+ // 5. Create audit log
92
+ await mutateWithRLS(
93
+ `INSERT INTO "audit_logs"
94
+ ("userId", "accountId", action, details, "ipAddress", "userAgent")
95
+ VALUES ($1, $2, $3, $4, $5, $6)`,
96
+ [
97
+ authResult.user!.id,
98
+ accountId,
99
+ 'account_disconnected',
100
+ JSON.stringify({
101
+ platform: account.platform,
102
+ accountName: account.username,
103
+ success: true,
104
+ disconnectedAt: new Date().toISOString()
105
+ }),
106
+ request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || null,
107
+ request.headers.get('user-agent') || null
108
+ ],
109
+ authResult.user!.id
110
+ )
111
+
112
+ console.log('[social-disconnect] ✅ Audit log created:', {
113
+ action: 'account_disconnected',
114
+ platform: account.platform,
115
+ success: true,
116
+ })
117
+
118
+ // 6. Return success
119
+ return NextResponse.json({
120
+ success: true,
121
+ accountId,
122
+ message: `Successfully disconnected ${account.username} (${account.platform})`,
123
+ })
124
+ } catch (error: unknown) {
125
+ console.error('❌ Social disconnect error:', error)
126
+
127
+ return NextResponse.json(
128
+ {
129
+ error: 'Failed to disconnect account',
130
+ details: error instanceof Error ? error.message : 'Unknown error',
131
+ },
132
+ { status: 500 }
133
+ )
134
+ }
135
+ }
136
+
137
+ /**
138
+ * DELETE method - Alternative endpoint using accountId in URL
139
+ */
140
+ export async function DELETE(
141
+ request: NextRequest,
142
+ { params }: { params: Promise<{ accountId: string }> }
143
+ ) {
144
+ try {
145
+ // 1. Authentication
146
+ const authResult = await authenticateRequest(request)
147
+ if (!authResult.success) {
148
+ return NextResponse.json(
149
+ { error: 'Authentication required' },
150
+ { status: 401 }
151
+ )
152
+ }
153
+
154
+ const resolvedParams = await params
155
+ const accountId = resolvedParams.accountId
156
+
157
+ // Validate UUID format
158
+ const uuidRegex =
159
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
160
+ if (!uuidRegex.test(accountId)) {
161
+ return NextResponse.json(
162
+ { error: 'Invalid account ID format' },
163
+ { status: 400 }
164
+ )
165
+ }
166
+
167
+ // Reuse POST logic
168
+ const body = { accountId }
169
+ return POST(
170
+ new NextRequest(request.url, {
171
+ method: 'POST',
172
+ headers: request.headers,
173
+ body: JSON.stringify(body),
174
+ })
175
+ )
176
+ } catch (error: unknown) {
177
+ console.error('❌ Social disconnect DELETE error:', error)
178
+
179
+ return NextResponse.json(
180
+ {
181
+ error: 'Failed to disconnect account',
182
+ details: error instanceof Error ? error.message : 'Unknown error',
183
+ },
184
+ { status: 500 }
185
+ )
186
+ }
187
+ }