@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,501 @@
|
|
|
1
|
+
# OAuth Integration
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The Social Media Publisher plugin uses **Facebook OAuth 2.0** to connect Instagram Business and Facebook Page accounts. OAuth connections are managed per-client, with tokens encrypted and stored securely in the database.
|
|
6
|
+
|
|
7
|
+
**Key Features:**
|
|
8
|
+
- Popup-based OAuth flow (no full-page redirects)
|
|
9
|
+
- Per-client account management
|
|
10
|
+
- Automatic token encryption
|
|
11
|
+
- Multi-account support
|
|
12
|
+
- CSRF protection via state parameter
|
|
13
|
+
- PostMessage communication between popup and parent window
|
|
14
|
+
|
|
15
|
+
## OAuth Flow Diagram
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
┌─────────────┐
|
|
19
|
+
│ User │
|
|
20
|
+
│ (Client │
|
|
21
|
+
│ Context) │
|
|
22
|
+
└──────┬──────┘
|
|
23
|
+
│ 1. Clicks "Connect Instagram"
|
|
24
|
+
↓
|
|
25
|
+
┌──────────────────────────────┐
|
|
26
|
+
│ /api/v1/plugin/social- │
|
|
27
|
+
│ media-publisher/social/ │
|
|
28
|
+
│ connect?platform= │
|
|
29
|
+
│ instagram_business& │
|
|
30
|
+
│ clientId={uuid} │
|
|
31
|
+
└──────┬───────────────────────┘
|
|
32
|
+
│ 2. Generates OAuth URL
|
|
33
|
+
↓
|
|
34
|
+
┌──────────────────────────────┐
|
|
35
|
+
│ OAuth Popup Window │
|
|
36
|
+
│ facebook.com/dialog/oauth │
|
|
37
|
+
└──────┬───────────────────────┘
|
|
38
|
+
│ 3. User authorizes
|
|
39
|
+
↓
|
|
40
|
+
┌──────────────────────────────┐
|
|
41
|
+
│ /api/v1/plugin/social- │
|
|
42
|
+
│ media-publisher/social/ │
|
|
43
|
+
│ connect/callback? │
|
|
44
|
+
│ code={code}&state={state} │
|
|
45
|
+
└──────┬───────────────────────┘
|
|
46
|
+
│ 4. Exchange code for token
|
|
47
|
+
↓
|
|
48
|
+
┌──────────────────────────────┐
|
|
49
|
+
│ Facebook Graph API │
|
|
50
|
+
│ - Get access token │
|
|
51
|
+
│ - Fetch user's Pages │
|
|
52
|
+
│ - Get Instagram accounts │
|
|
53
|
+
└──────┬───────────────────────┘
|
|
54
|
+
│ 5. Encrypt tokens
|
|
55
|
+
↓
|
|
56
|
+
┌──────────────────────────────┐
|
|
57
|
+
│ Database │
|
|
58
|
+
│ clients_social_platforms │
|
|
59
|
+
│ (encrypted tokens stored) │
|
|
60
|
+
└──────┬───────────────────────┘
|
|
61
|
+
│ 6. Return success HTML
|
|
62
|
+
↓
|
|
63
|
+
┌──────────────────────────────┐
|
|
64
|
+
│ Popup window.postMessage │
|
|
65
|
+
│ { type: 'oauth-success', │
|
|
66
|
+
│ connectedCount: 2 } │
|
|
67
|
+
└──────┬───────────────────────┘
|
|
68
|
+
│ 7. Parent receives message
|
|
69
|
+
↓
|
|
70
|
+
┌──────────────────────────────┐
|
|
71
|
+
│ Parent Window │
|
|
72
|
+
│ - Refresh page │
|
|
73
|
+
│ - Show new accounts │
|
|
74
|
+
│ - Close popup (auto) │
|
|
75
|
+
└──────────────────────────────┘
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Step-by-Step Flow
|
|
79
|
+
|
|
80
|
+
### Step 1: Initiate OAuth
|
|
81
|
+
|
|
82
|
+
**User Action:** Clicks "Connect Instagram Business" or "Connect Facebook Page"
|
|
83
|
+
|
|
84
|
+
**Frontend Code:**
|
|
85
|
+
```typescript
|
|
86
|
+
'use client'
|
|
87
|
+
|
|
88
|
+
import { useRouter } from 'next/navigation'
|
|
89
|
+
|
|
90
|
+
export function ConnectSocialButton({
|
|
91
|
+
clientId,
|
|
92
|
+
platform
|
|
93
|
+
}: {
|
|
94
|
+
clientId: string
|
|
95
|
+
platform: 'instagram_business' | 'facebook_page'
|
|
96
|
+
}) {
|
|
97
|
+
const router = useRouter()
|
|
98
|
+
|
|
99
|
+
const handleConnect = () => {
|
|
100
|
+
// Build OAuth initiation URL
|
|
101
|
+
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || window.location.origin
|
|
102
|
+
const oauthUrl = `${baseUrl}/api/v1/plugin/social-media-publisher/social/connect?platform=${platform}&clientId=${clientId}`
|
|
103
|
+
|
|
104
|
+
// Open popup window
|
|
105
|
+
const popup = window.open(
|
|
106
|
+
oauthUrl,
|
|
107
|
+
'oauth-popup',
|
|
108
|
+
'width=600,height=700,scrollbars=yes'
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// Listen for success message from popup
|
|
112
|
+
const handleMessage = (event: MessageEvent) => {
|
|
113
|
+
// Verify origin
|
|
114
|
+
if (event.origin !== window.location.origin) return
|
|
115
|
+
|
|
116
|
+
if (event.data.type === 'oauth-success') {
|
|
117
|
+
console.log(`✅ Connected ${event.data.connectedCount} account(s)`)
|
|
118
|
+
|
|
119
|
+
// Cleanup
|
|
120
|
+
window.removeEventListener('message', handleMessage)
|
|
121
|
+
|
|
122
|
+
// Refresh to show new accounts
|
|
123
|
+
router.refresh()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
window.addEventListener('message', handleMessage)
|
|
128
|
+
|
|
129
|
+
// Cleanup if popup closes without success
|
|
130
|
+
const checkPopup = setInterval(() => {
|
|
131
|
+
if (popup?.closed) {
|
|
132
|
+
clearInterval(checkPopup)
|
|
133
|
+
window.removeEventListener('message', handleMessage)
|
|
134
|
+
}
|
|
135
|
+
}, 1000)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<button onClick={handleConnect}>
|
|
140
|
+
Connect {platform === 'instagram_business' ? 'Instagram' : 'Facebook Page'}
|
|
141
|
+
</button>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Step 2: Generate OAuth URL
|
|
147
|
+
|
|
148
|
+
**Endpoint:** `GET /api/v1/plugin/social-media-publisher/social/connect`
|
|
149
|
+
|
|
150
|
+
**Query Parameters:**
|
|
151
|
+
- `platform` - `'instagram_business'` or `'facebook_page'`
|
|
152
|
+
- `clientId` - UUID of client to connect accounts to
|
|
153
|
+
|
|
154
|
+
**What Happens:**
|
|
155
|
+
1. Generates random state value (CSRF protection)
|
|
156
|
+
2. Constructs state parameter: `{randomState}&platform={platform}&clientId={clientId}`
|
|
157
|
+
3. Builds Facebook OAuth URL with required scopes
|
|
158
|
+
4. Redirects user to Facebook authorization page
|
|
159
|
+
|
|
160
|
+
**OAuth URL Format:**
|
|
161
|
+
```
|
|
162
|
+
https://www.facebook.com/v18.0/dialog/oauth
|
|
163
|
+
?client_id={FACEBOOK_CLIENT_ID}
|
|
164
|
+
&redirect_uri={callback_url}
|
|
165
|
+
&state={encoded_state}
|
|
166
|
+
&scope={permissions}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Scopes for Instagram Business:**
|
|
170
|
+
```
|
|
171
|
+
pages_show_list,instagram_basic,instagram_content_publish,instagram_manage_insights
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Scopes for Facebook Pages:**
|
|
175
|
+
```
|
|
176
|
+
pages_show_list,pages_manage_posts,pages_read_engagement,read_insights
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Step 3: User Authorization
|
|
180
|
+
|
|
181
|
+
**What User Sees:**
|
|
182
|
+
1. Facebook authorization page opens in popup
|
|
183
|
+
2. Shows app name, icon, and requested permissions
|
|
184
|
+
3. User can choose which Pages to grant access to
|
|
185
|
+
4. User clicks "Continue" or "Cancel"
|
|
186
|
+
|
|
187
|
+
**User Can:**
|
|
188
|
+
- ✅ Grant all permissions
|
|
189
|
+
- ✅ Grant partial permissions (select specific Pages)
|
|
190
|
+
- ❌ Deny all permissions
|
|
191
|
+
|
|
192
|
+
### Step 4: OAuth Callback
|
|
193
|
+
|
|
194
|
+
**Endpoint:** `GET /api/v1/plugin/social-media-publisher/social/connect/callback`
|
|
195
|
+
|
|
196
|
+
**Success Parameters:**
|
|
197
|
+
```
|
|
198
|
+
code: Authorization code from Facebook
|
|
199
|
+
state: CSRF token + platform + clientId
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Error Parameters:**
|
|
203
|
+
```
|
|
204
|
+
error: Error code (e.g., 'access_denied')
|
|
205
|
+
error_description: Human-readable error
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Callback Processing:**
|
|
209
|
+
|
|
210
|
+
1. **Validate State Parameter:**
|
|
211
|
+
```typescript
|
|
212
|
+
const [randomState, platformParam, clientIdParam] = state.split('&')
|
|
213
|
+
// Verify state matches expected format
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
2. **Exchange Code for Access Token:**
|
|
217
|
+
```typescript
|
|
218
|
+
const tokenUrl = `https://graph.facebook.com/v18.0/oauth/access_token`
|
|
219
|
+
const params = new URLSearchParams({
|
|
220
|
+
client_id: FACEBOOK_CLIENT_ID,
|
|
221
|
+
client_secret: FACEBOOK_CLIENT_SECRET,
|
|
222
|
+
redirect_uri: callbackUrl,
|
|
223
|
+
code: authorizationCode
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
const response = await fetch(`${tokenUrl}?${params.toString()}`)
|
|
227
|
+
const { access_token } = await response.json()
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
3. **Fetch User's Facebook Pages:**
|
|
231
|
+
```typescript
|
|
232
|
+
const pages = await FacebookAPI.getUserPages(access_token)
|
|
233
|
+
// Returns array of Pages with Page-specific tokens
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
4. **For Each Page:**
|
|
237
|
+
```typescript
|
|
238
|
+
if (platform === 'instagram_business') {
|
|
239
|
+
// Check if Page has Instagram Business Account
|
|
240
|
+
const igAccount = await FacebookAPI.getInstagramBusinessAccount(
|
|
241
|
+
page.id,
|
|
242
|
+
page.accessToken
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
if (igAccount) {
|
|
246
|
+
await saveToDatabase({
|
|
247
|
+
parentId: clientId,
|
|
248
|
+
platform: 'instagram_business',
|
|
249
|
+
platformAccountId: igAccount.id,
|
|
250
|
+
platformAccountName: igAccount.username,
|
|
251
|
+
accessToken: await encrypt(page.accessToken),
|
|
252
|
+
...
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
// Save Facebook Page directly
|
|
257
|
+
await saveToDatabase({
|
|
258
|
+
parentId: clientId,
|
|
259
|
+
platform: 'facebook_page',
|
|
260
|
+
platformAccountId: page.id,
|
|
261
|
+
platformAccountName: page.name,
|
|
262
|
+
accessToken: await encrypt(page.accessToken),
|
|
263
|
+
...
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
5. **Encrypt and Store Tokens:**
|
|
269
|
+
```typescript
|
|
270
|
+
// Encrypt access token
|
|
271
|
+
const { encrypted, iv, keyId } = await TokenEncryption.encrypt(accessToken)
|
|
272
|
+
const encryptedToken = `${encrypted}:${iv}:${keyId}`
|
|
273
|
+
|
|
274
|
+
// Calculate expiration (60 days for long-lived tokens)
|
|
275
|
+
const expiresAt = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000)
|
|
276
|
+
|
|
277
|
+
// Store in database
|
|
278
|
+
await query(`
|
|
279
|
+
INSERT INTO "clients_social_platforms" (
|
|
280
|
+
"parentId", platform, "platformAccountId",
|
|
281
|
+
"platformAccountName", "accessToken", "tokenExpiresAt"
|
|
282
|
+
) VALUES ($1, $2, $3, $4, $5, $6)
|
|
283
|
+
`, [clientId, platform, accountId, accountName, encryptedToken, expiresAt])
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
6. **Create Audit Log:**
|
|
287
|
+
```typescript
|
|
288
|
+
await query(`
|
|
289
|
+
INSERT INTO "audit_logs" (
|
|
290
|
+
"userId", "accountId", action, details
|
|
291
|
+
) VALUES ($1, $2, 'account_connected', $3)
|
|
292
|
+
`, [userId, accountId, { platform, accountName }])
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
7. **Return Success HTML:**
|
|
296
|
+
```html
|
|
297
|
+
<!DOCTYPE html>
|
|
298
|
+
<html>
|
|
299
|
+
<head>
|
|
300
|
+
<title>Authorization Successful</title>
|
|
301
|
+
</head>
|
|
302
|
+
<body>
|
|
303
|
+
<h1>✅ Successfully Connected</h1>
|
|
304
|
+
<p>Connected 2 Instagram Business account(s)</p>
|
|
305
|
+
<p>This window will close automatically...</p>
|
|
306
|
+
<script>
|
|
307
|
+
// Send success message to parent window
|
|
308
|
+
window.opener.postMessage({
|
|
309
|
+
type: 'oauth-success',
|
|
310
|
+
platform: 'instagram_business',
|
|
311
|
+
connectedCount: 2
|
|
312
|
+
}, window.location.origin)
|
|
313
|
+
|
|
314
|
+
// Auto-close after 2 seconds
|
|
315
|
+
setTimeout(() => window.close(), 2000)
|
|
316
|
+
</script>
|
|
317
|
+
</body>
|
|
318
|
+
</html>
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Step 5: PostMessage Communication
|
|
322
|
+
|
|
323
|
+
**Popup Window:**
|
|
324
|
+
```javascript
|
|
325
|
+
// Send message to parent window
|
|
326
|
+
window.opener.postMessage({
|
|
327
|
+
type: 'oauth-success',
|
|
328
|
+
platform: 'instagram_business',
|
|
329
|
+
connectedCount: 2
|
|
330
|
+
}, window.location.origin)
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
**Parent Window:**
|
|
334
|
+
```javascript
|
|
335
|
+
window.addEventListener('message', (event) => {
|
|
336
|
+
// Verify origin (security)
|
|
337
|
+
if (event.origin !== window.location.origin) return
|
|
338
|
+
|
|
339
|
+
if (event.data.type === 'oauth-success') {
|
|
340
|
+
// Refresh page to show new accounts
|
|
341
|
+
router.refresh()
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Security Features
|
|
347
|
+
|
|
348
|
+
### CSRF Protection (State Parameter)
|
|
349
|
+
|
|
350
|
+
**State Format:**
|
|
351
|
+
```
|
|
352
|
+
{randomString}&platform={platform}&clientId={clientId}
|
|
353
|
+
|
|
354
|
+
Example:
|
|
355
|
+
a7f9b2c8d1e3f4a5&platform=instagram_business&clientId=550e8400-e29b-41d4-a716-446655440000
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
**Validation:**
|
|
359
|
+
1. Generate random state before redirect
|
|
360
|
+
2. Include platform and clientId in state
|
|
361
|
+
3. Facebook returns state unchanged
|
|
362
|
+
4. Verify state format and extract data
|
|
363
|
+
5. Reject if state is invalid
|
|
364
|
+
|
|
365
|
+
### Token Encryption
|
|
366
|
+
|
|
367
|
+
**All tokens encrypted before storage:**
|
|
368
|
+
```typescript
|
|
369
|
+
// Plain text token from Facebook
|
|
370
|
+
const accessToken = "EAABwzLixnjY..."
|
|
371
|
+
|
|
372
|
+
// Encrypt
|
|
373
|
+
const { encrypted, iv, keyId } = await TokenEncryption.encrypt(accessToken)
|
|
374
|
+
|
|
375
|
+
// Store format: encrypted:iv:keyId
|
|
376
|
+
const storedToken = `${encrypted}:${iv}:${keyId}`
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
**Decryption (only when needed):**
|
|
380
|
+
```typescript
|
|
381
|
+
const [encrypted, iv, keyId] = storedToken.split(':')
|
|
382
|
+
const decryptedToken = await TokenEncryption.decrypt(encrypted, iv, keyId)
|
|
383
|
+
// Use for API call
|
|
384
|
+
// Never log or store decrypted token
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### Origin Validation
|
|
388
|
+
|
|
389
|
+
**PostMessage security:**
|
|
390
|
+
```javascript
|
|
391
|
+
window.addEventListener('message', (event) => {
|
|
392
|
+
// ✅ Validate origin
|
|
393
|
+
if (event.origin !== window.location.origin) {
|
|
394
|
+
console.warn('Rejected message from untrusted origin:', event.origin)
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Process message
|
|
399
|
+
})
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
## Error Handling
|
|
403
|
+
|
|
404
|
+
### User Denies Permission
|
|
405
|
+
|
|
406
|
+
**Error Response:**
|
|
407
|
+
```
|
|
408
|
+
?error=access_denied
|
|
409
|
+
&error_description=The+user+denied+your+request
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
**Handling:**
|
|
413
|
+
```typescript
|
|
414
|
+
if (error === 'access_denied') {
|
|
415
|
+
return NextResponse.html(`
|
|
416
|
+
<html>
|
|
417
|
+
<body>
|
|
418
|
+
<h1>Authorization Cancelled</h1>
|
|
419
|
+
<p>You chose not to connect your account.</p>
|
|
420
|
+
<button onclick="window.close()">Close</button>
|
|
421
|
+
</body>
|
|
422
|
+
</html>
|
|
423
|
+
`)
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### Invalid OAuth Code
|
|
428
|
+
|
|
429
|
+
**Cause:** Code expired or already used
|
|
430
|
+
|
|
431
|
+
**Solution:** Restart OAuth flow
|
|
432
|
+
|
|
433
|
+
### No Instagram Account Found
|
|
434
|
+
|
|
435
|
+
**Cause:** Facebook Page not linked to Instagram Business
|
|
436
|
+
|
|
437
|
+
**Solution:**
|
|
438
|
+
1. Show clear error message
|
|
439
|
+
2. Provide link to Facebook Page settings
|
|
440
|
+
3. Guide user to connect Instagram
|
|
441
|
+
|
|
442
|
+
**Error HTML:**
|
|
443
|
+
```html
|
|
444
|
+
<html>
|
|
445
|
+
<body>
|
|
446
|
+
<h1>⚠️ No Instagram Business Account Found</h1>
|
|
447
|
+
<p>None of your Facebook Pages have an Instagram Business Account connected.</p>
|
|
448
|
+
<h3>How to Connect Instagram:</h3>
|
|
449
|
+
<ol>
|
|
450
|
+
<li>Go to your Facebook Page Settings</li>
|
|
451
|
+
<li>Click "Instagram" in the sidebar</li>
|
|
452
|
+
<li>Click "Connect Account"</li>
|
|
453
|
+
<li>Log in to Instagram and authorize</li>
|
|
454
|
+
<li>Try connecting again</li>
|
|
455
|
+
</ol>
|
|
456
|
+
<button onclick="window.close()">Close</button>
|
|
457
|
+
</body>
|
|
458
|
+
</html>
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
## Multi-Account Support
|
|
462
|
+
|
|
463
|
+
### Connecting Multiple Accounts
|
|
464
|
+
|
|
465
|
+
**Same Platform, Different Accounts:**
|
|
466
|
+
```typescript
|
|
467
|
+
// User connects Instagram account @brand1
|
|
468
|
+
// Then connects Instagram account @brand2
|
|
469
|
+
// Both stored under same client
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
**Database:**
|
|
473
|
+
```sql
|
|
474
|
+
SELECT * FROM "clients_social_platforms" WHERE "parentId" = '{clientId}'
|
|
475
|
+
|
|
476
|
+
-- Results:
|
|
477
|
+
id | platform | platformAccountName | isActive
|
|
478
|
+
---|----------|---------------------|----------
|
|
479
|
+
1 | instagram_business | @brand1 | true
|
|
480
|
+
2 | instagram_business | @brand2 | true
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
### Preventing Duplicates
|
|
484
|
+
|
|
485
|
+
**Unique Constraint:**
|
|
486
|
+
```sql
|
|
487
|
+
UNIQUE("parentId", "platformAccountId")
|
|
488
|
+
WHERE "platformAccountId" IS NOT NULL
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
**On Duplicate:**
|
|
492
|
+
- Update existing record
|
|
493
|
+
- Refresh token
|
|
494
|
+
- Update metadata
|
|
495
|
+
- Don't create new record
|
|
496
|
+
|
|
497
|
+
## Next Steps
|
|
498
|
+
|
|
499
|
+
- **[Publishing](./02-publishing.md)** - Use connected accounts to publish
|
|
500
|
+
- **[Token Management](./03-token-management.md)** - Understand token lifecycle
|
|
501
|
+
- **[Audit Logging](./04-audit-logging.md)** - Track all OAuth events
|