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