@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,575 @@
1
+ # Per-Client Architecture
2
+
3
+ ## Overview
4
+
5
+ The Social Media Publisher plugin implements a **per-client architecture** where social media accounts are managed as **child entities** of clients. This pattern provides clear ownership, better organization for agencies, and scalable multi-client management.
6
+
7
+ **Key Concept:** Social accounts belong to **clients**, not directly to users. Users access social accounts through their client relationships.
8
+
9
+ ## Architecture Pattern
10
+
11
+ ### Entity Hierarchy
12
+
13
+ ```
14
+ User (parent)
15
+ └── Client (child of user)
16
+ └── Social Platform (child of client)
17
+ ├── Instagram Business Account #1
18
+ ├── Instagram Business Account #2
19
+ ├── Facebook Page #1
20
+ └── Facebook Page #2
21
+ ```
22
+
23
+ **Example:**
24
+ ```
25
+ User: john@agency.com
26
+ └── Client: "Acme Corp"
27
+ ├── Instagram: @acmecorp
28
+ ├── Instagram: @acmeproducts
29
+ └── Facebook: "Acme Official"
30
+ └── Client: "Widget Co"
31
+ ├── Instagram: @widgetco
32
+ └── Facebook: "Widget Company"
33
+ ```
34
+
35
+ ## Database Schema
36
+
37
+ ### Core Tables
38
+
39
+ **clients (Parent Entity):**
40
+ ```sql
41
+ CREATE TABLE "clients" (
42
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
43
+ "userId" TEXT NOT NULL, -- Owner user
44
+ name TEXT NOT NULL,
45
+ slug TEXT NOT NULL,
46
+ email TEXT,
47
+ status TEXT DEFAULT 'active',
48
+ "createdAt" TIMESTAMPTZ DEFAULT now(),
49
+ "updatedAt" TIMESTAMPTZ DEFAULT now()
50
+ );
51
+
52
+ CREATE INDEX "idx_clients_userId" ON "clients"("userId");
53
+ ```
54
+
55
+ **clients_social_platforms (Child Entity):**
56
+ ```sql
57
+ CREATE TABLE "clients_social_platforms" (
58
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
59
+ "parentId" UUID NOT NULL REFERENCES "clients"(id) ON DELETE CASCADE,
60
+ platform TEXT NOT NULL, -- 'instagram_business' | 'facebook_page'
61
+ "platformAccountId" TEXT, -- Instagram ID or Facebook Page ID
62
+ "platformAccountName" TEXT NOT NULL, -- @username or Page name
63
+ "accessToken" TEXT NOT NULL, -- Encrypted: 'encrypted:iv:keyId'
64
+ "tokenExpiresAt" TIMESTAMPTZ NOT NULL,
65
+ permissions JSONB DEFAULT '[]',
66
+ "accountMetadata" JSONB DEFAULT '{}',
67
+ "isActive" BOOLEAN DEFAULT true,
68
+ "createdAt" TIMESTAMPTZ DEFAULT now(),
69
+ "updatedAt" TIMESTAMPTZ DEFAULT now(),
70
+
71
+ -- Prevent duplicate connections per client
72
+ UNIQUE("parentId", "platformAccountId")
73
+ WHERE "platformAccountId" IS NOT NULL
74
+ );
75
+
76
+ CREATE INDEX "idx_social_platforms_parentId"
77
+ ON "clients_social_platforms"("parentId");
78
+
79
+ CREATE INDEX "idx_social_platforms_platform"
80
+ ON "clients_social_platforms"(platform);
81
+ ```
82
+
83
+ ### Relationships
84
+
85
+ **Parent-Child Relationship:**
86
+ ```sql
87
+ -- Cascading delete: Remove social accounts when client deleted
88
+ "parentId" UUID NOT NULL REFERENCES "clients"(id) ON DELETE CASCADE
89
+ ```
90
+
91
+ **User-Client Relationship:**
92
+ ```sql
93
+ -- User owns multiple clients
94
+ SELECT c.* FROM "clients" c WHERE c."userId" = $1
95
+ ```
96
+
97
+ **Client-Social Platform Relationship:**
98
+ ```sql
99
+ -- Client has multiple social platforms
100
+ SELECT csp.* FROM "clients_social_platforms" csp WHERE csp."parentId" = $1
101
+ ```
102
+
103
+ ## Row-Level Security (RLS)
104
+
105
+ ### Security Policies
106
+
107
+ **Purpose:** Ensure users can only access social platforms for clients they own.
108
+
109
+ **clients Table Policies:**
110
+ ```sql
111
+ -- Enable RLS
112
+ ALTER TABLE "clients" ENABLE ROW LEVEL SECURITY;
113
+
114
+ -- Users can only see their own clients
115
+ CREATE POLICY "clients_select_own"
116
+ ON "clients" FOR SELECT
117
+ USING ("userId" = current_setting('app.current_user_id', true));
118
+
119
+ -- Users can only insert clients for themselves
120
+ CREATE POLICY "clients_insert_own"
121
+ ON "clients" FOR INSERT
122
+ WITH CHECK ("userId" = current_setting('app.current_user_id', true));
123
+
124
+ -- Users can only update their own clients
125
+ CREATE POLICY "clients_update_own"
126
+ ON "clients" FOR UPDATE
127
+ USING ("userId" = current_setting('app.current_user_id', true));
128
+
129
+ -- Users can only delete their own clients
130
+ CREATE POLICY "clients_delete_own"
131
+ ON "clients" FOR DELETE
132
+ USING ("userId" = current_setting('app.current_user_id', true));
133
+ ```
134
+
135
+ **clients_social_platforms Table Policies:**
136
+ ```sql
137
+ -- Enable RLS
138
+ ALTER TABLE "clients_social_platforms" ENABLE ROW LEVEL SECURITY;
139
+
140
+ -- Users can only see social platforms for their clients
141
+ CREATE POLICY "social_platforms_select_own"
142
+ ON "clients_social_platforms" FOR SELECT
143
+ USING (
144
+ "parentId" IN (
145
+ SELECT id FROM "clients"
146
+ WHERE "userId" = current_setting('app.current_user_id', true)
147
+ )
148
+ );
149
+
150
+ -- Users can only insert social platforms for their clients
151
+ CREATE POLICY "social_platforms_insert_own"
152
+ ON "clients_social_platforms" FOR INSERT
153
+ WITH CHECK (
154
+ "parentId" IN (
155
+ SELECT id FROM "clients"
156
+ WHERE "userId" = current_setting('app.current_user_id', true)
157
+ )
158
+ );
159
+
160
+ -- Users can only update social platforms for their clients
161
+ CREATE POLICY "social_platforms_update_own"
162
+ ON "clients_social_platforms" FOR UPDATE
163
+ USING (
164
+ "parentId" IN (
165
+ SELECT id FROM "clients"
166
+ WHERE "userId" = current_setting('app.current_user_id', true)
167
+ )
168
+ );
169
+
170
+ -- Users can only delete social platforms for their clients
171
+ CREATE POLICY "social_platforms_delete_own"
172
+ ON "clients_social_platforms" FOR DELETE
173
+ USING (
174
+ "parentId" IN (
175
+ SELECT id FROM "clients"
176
+ WHERE "userId" = current_setting('app.current_user_id', true)
177
+ )
178
+ );
179
+ ```
180
+
181
+ ### RLS Enforcement
182
+
183
+ **Setting User Context:**
184
+ ```typescript
185
+ import { query } from '@/core/lib/db'
186
+
187
+ async function queryWithRLS<T>(
188
+ sql: string,
189
+ params: any[],
190
+ userId: string
191
+ ): Promise<T> {
192
+ // Set user context for RLS
193
+ await query(`SET LOCAL app.current_user_id = $1`, [userId])
194
+
195
+ // Execute query (RLS automatically enforced)
196
+ const result = await query(sql, params)
197
+
198
+ return result.rows as T
199
+ }
200
+ ```
201
+
202
+ **Example Usage:**
203
+ ```typescript
204
+ // Get client's social platforms (RLS enforced)
205
+ const platforms = await queryWithRLS(
206
+ `SELECT * FROM "clients_social_platforms" WHERE "parentId" = $1`,
207
+ [clientId],
208
+ userId // Current user ID
209
+ )
210
+
211
+ // If user doesn't own the client, no results returned
212
+ ```
213
+
214
+ ## Entity API Endpoints
215
+
216
+ ### Dynamic Entity API
217
+
218
+ The plugin leverages the boilerplate's dynamic entity system:
219
+
220
+ **Base Pattern:**
221
+ ```
222
+ /api/v1/entity/clients/{clientId}/social-platforms
223
+ ```
224
+
225
+ **Available Endpoints:**
226
+
227
+ **1. List Social Platforms for Client:**
228
+ ```
229
+ GET /api/v1/entity/clients/{clientId}/social-platforms
230
+ ```
231
+
232
+ **Response:**
233
+ ```json
234
+ {
235
+ "success": true,
236
+ "data": [
237
+ {
238
+ "id": "550e8400-e29b-41d4-a716-446655440000",
239
+ "parentId": "client-uuid",
240
+ "platform": "instagram_business",
241
+ "platformAccountId": "17841401234567890",
242
+ "platformAccountName": "@brandname",
243
+ "tokenExpiresAt": "2024-03-15T10:30:00Z",
244
+ "isActive": true,
245
+ "accountMetadata": {
246
+ "profilePictureUrl": "https://...",
247
+ "followersCount": 10500
248
+ }
249
+ }
250
+ ],
251
+ "total": 1
252
+ }
253
+ ```
254
+
255
+ **2. Get Single Social Platform:**
256
+ ```
257
+ GET /api/v1/entity/clients/{clientId}/social-platforms/{platformId}
258
+ ```
259
+
260
+ **3. Update Social Platform:**
261
+ ```
262
+ PATCH /api/v1/entity/clients/{clientId}/social-platforms/{platformId}
263
+ {
264
+ "isActive": false
265
+ }
266
+ ```
267
+
268
+ **4. Delete Social Platform:**
269
+ ```
270
+ DELETE /api/v1/entity/clients/{clientId}/social-platforms/{platformId}
271
+ ```
272
+
273
+ ## Data Access Patterns
274
+
275
+ ### Get Client's Social Accounts
276
+
277
+ ```typescript
278
+ async function getClientSocialAccounts(
279
+ clientId: string,
280
+ userId: string
281
+ ): Promise<SocialAccount[]> {
282
+ const result = await query(`
283
+ SELECT csp.*
284
+ FROM "clients_social_platforms" csp
285
+ JOIN "clients" c ON c.id = csp."parentId"
286
+ WHERE csp."parentId" = $1
287
+ AND c."userId" = $2
288
+ AND csp."isActive" = true
289
+ ORDER BY csp."createdAt" DESC
290
+ `, [clientId, userId])
291
+
292
+ return result.rows
293
+ }
294
+ ```
295
+
296
+ ### Get All Clients with Social Account Counts
297
+
298
+ ```typescript
299
+ async function getClientsWithSocialCounts(
300
+ userId: string
301
+ ): Promise<ClientWithCounts[]> {
302
+ const result = await query(`
303
+ SELECT
304
+ c.*,
305
+ COUNT(csp.id) FILTER (WHERE csp.platform = 'instagram_business') as instagram_count,
306
+ COUNT(csp.id) FILTER (WHERE csp.platform = 'facebook_page') as facebook_count,
307
+ COUNT(csp.id) as total_accounts
308
+ FROM "clients" c
309
+ LEFT JOIN "clients_social_platforms" csp
310
+ ON csp."parentId" = c.id AND csp."isActive" = true
311
+ WHERE c."userId" = $1
312
+ GROUP BY c.id
313
+ ORDER BY c.name
314
+ `, [userId])
315
+
316
+ return result.rows
317
+ }
318
+ ```
319
+
320
+ ### Get Account with Client Context
321
+
322
+ ```typescript
323
+ async function getAccountWithClient(
324
+ accountId: string,
325
+ userId: string
326
+ ): Promise<AccountWithClient | null> {
327
+ const result = await query(`
328
+ SELECT
329
+ csp.*,
330
+ c.id as "clientId",
331
+ c.name as "clientName",
332
+ c.slug as "clientSlug"
333
+ FROM "clients_social_platforms" csp
334
+ JOIN "clients" c ON c.id = csp."parentId"
335
+ WHERE csp.id = $1
336
+ AND c."userId" = $2
337
+ AND csp."isActive" = true
338
+ `, [accountId, userId])
339
+
340
+ return result.rows[0] || null
341
+ }
342
+ ```
343
+
344
+ ## OAuth Flow with Per-Client Context
345
+
346
+ ### Initiating OAuth
347
+
348
+ **Include clientId in OAuth state:**
349
+ ```typescript
350
+ // Generate OAuth URL with clientId
351
+ const state = `${randomString}&platform=${platform}&clientId=${clientId}`
352
+
353
+ const oauthUrl = `https://www.facebook.com/v18.0/dialog/oauth?` +
354
+ `client_id=${FACEBOOK_CLIENT_ID}&` +
355
+ `redirect_uri=${callbackUrl}&` +
356
+ `state=${encodeURIComponent(state)}&` +
357
+ `scope=${scopes.join(',')}`
358
+
359
+ // Redirect to Facebook
360
+ ```
361
+
362
+ ### OAuth Callback
363
+
364
+ **Extract clientId from state and save accounts:**
365
+ ```typescript
366
+ // Parse state parameter
367
+ const [randomState, platformParam, clientIdParam] = state.split('&')
368
+ const platform = platformParam.split('=')[1]
369
+ const clientId = clientIdParam.split('=')[1]
370
+
371
+ // Verify user owns client
372
+ const client = await query(`
373
+ SELECT * FROM "clients"
374
+ WHERE id = $1 AND "userId" = $2
375
+ `, [clientId, userId])
376
+
377
+ if (client.rowCount === 0) {
378
+ return NextResponse.json({ error: 'Client not found' }, { status: 404 })
379
+ }
380
+
381
+ // Save social accounts under this client
382
+ for (const account of connectedAccounts) {
383
+ await query(`
384
+ INSERT INTO "clients_social_platforms" (
385
+ "parentId", -- Link to client
386
+ platform,
387
+ "platformAccountId",
388
+ "platformAccountName",
389
+ "accessToken",
390
+ "tokenExpiresAt",
391
+ permissions
392
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)
393
+ ON CONFLICT ("parentId", "platformAccountId")
394
+ DO UPDATE SET
395
+ "accessToken" = EXCLUDED."accessToken",
396
+ "tokenExpiresAt" = EXCLUDED."tokenExpiresAt",
397
+ permissions = EXCLUDED.permissions,
398
+ "isActive" = true,
399
+ "updatedAt" = NOW()
400
+ `, [
401
+ clientId, // Parent client ID
402
+ platform,
403
+ account.id,
404
+ account.username || account.name,
405
+ encryptedToken,
406
+ expiresAt,
407
+ JSON.stringify(permissions)
408
+ ])
409
+ }
410
+ ```
411
+
412
+ ## Multi-Client Management
413
+
414
+ ### Benefits of Per-Client Architecture
415
+
416
+ **1. Clear Ownership:**
417
+ - Social accounts belong to clients, not users
418
+ - Easy to understand "who owns what"
419
+ - Natural organization for agencies
420
+
421
+ **2. Scalability:**
422
+ ```typescript
423
+ // Agency with 100 clients, each with 5 social accounts
424
+ // = 500 social accounts, clearly organized by client
425
+ ```
426
+
427
+ **3. Easy Onboarding/Offboarding:**
428
+ ```typescript
429
+ // Delete client = all social accounts cascade deleted
430
+ await query(`DELETE FROM "clients" WHERE id = $1`, [clientId])
431
+ // All social platforms automatically removed
432
+ ```
433
+
434
+ **4. Team Collaboration:**
435
+ ```typescript
436
+ // Multiple users can manage same client's social accounts
437
+ // (requires role/permission system)
438
+ await query(`
439
+ INSERT INTO "client_collaborators"
440
+ ("clientId", "userId", role)
441
+ VALUES ($1, $2, 'editor')
442
+ `, [clientId, collaboratorUserId])
443
+ ```
444
+
445
+ ### Example: Agency Dashboard
446
+
447
+ ```typescript
448
+ // Get overview of all clients and their social accounts
449
+ async function getAgencyDashboard(userId: string) {
450
+ const clients = await query(`
451
+ SELECT
452
+ c.id,
453
+ c.name,
454
+ c.slug,
455
+ json_agg(
456
+ json_build_object(
457
+ 'id', csp.id,
458
+ 'platform', csp.platform,
459
+ 'accountName', csp."platformAccountName",
460
+ 'isActive', csp."isActive",
461
+ 'tokenExpiresAt', csp."tokenExpiresAt"
462
+ ) ORDER BY csp."createdAt" DESC
463
+ ) FILTER (WHERE csp.id IS NOT NULL) as social_accounts
464
+ FROM "clients" c
465
+ LEFT JOIN "clients_social_platforms" csp ON csp."parentId" = c.id
466
+ WHERE c."userId" = $1
467
+ GROUP BY c.id
468
+ ORDER BY c.name
469
+ `, [userId])
470
+
471
+ return clients.rows.map(client => ({
472
+ ...client,
473
+ totalAccounts: client.social_accounts?.length || 0,
474
+ activeAccounts: client.social_accounts?.filter(a => a.isActive).length || 0,
475
+ expiringTokens: client.social_accounts?.filter(a =>
476
+ new Date(a.tokenExpiresAt) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
477
+ ).length || 0
478
+ }))
479
+ }
480
+ ```
481
+
482
+ ## Migration from User-Based to Client-Based
483
+
484
+ If you have existing social accounts tied to users, migrate to client-based:
485
+
486
+ ```sql
487
+ -- Create default client for each user
488
+ INSERT INTO "clients" (id, "userId", name, slug)
489
+ SELECT
490
+ gen_random_uuid(),
491
+ "userId",
492
+ 'Default Client',
493
+ CONCAT('default-', "userId")
494
+ FROM "old_social_accounts"
495
+ GROUP BY "userId";
496
+
497
+ -- Migrate social accounts to clients
498
+ UPDATE "clients_social_platforms" csp
499
+ SET "parentId" = c.id
500
+ FROM "clients" c
501
+ WHERE c."userId" = csp."userId" -- Old column
502
+ AND c.name = 'Default Client';
503
+
504
+ -- Drop old userId column
505
+ ALTER TABLE "clients_social_platforms" DROP COLUMN "userId";
506
+ ```
507
+
508
+ ## Best Practices
509
+
510
+ ### Do's ✅
511
+
512
+ **1. Always Use RLS:**
513
+ ```typescript
514
+ // Set user context for all queries
515
+ await query(`SET LOCAL app.current_user_id = $1`, [userId])
516
+ ```
517
+
518
+ **2. Validate Client Ownership:**
519
+ ```typescript
520
+ // Verify user owns client before operations
521
+ const client = await query(`
522
+ SELECT * FROM "clients"
523
+ WHERE id = $1 AND "userId" = $2
524
+ `, [clientId, userId])
525
+
526
+ if (client.rowCount === 0) {
527
+ throw new Error('Client not found')
528
+ }
529
+ ```
530
+
531
+ **3. Use Cascading Deletes:**
532
+ ```sql
533
+ -- Let database handle cleanup
534
+ REFERENCES "clients"(id) ON DELETE CASCADE
535
+ ```
536
+
537
+ **4. Include Client Context in Audit Logs:**
538
+ ```typescript
539
+ await logAudit('post_published', {
540
+ accountId,
541
+ clientId: account.parentId,
542
+ clientName: client.name,
543
+ ...
544
+ })
545
+ ```
546
+
547
+ ### Don'ts ❌
548
+
549
+ **1. Don't Query Without Client Context:**
550
+ ```typescript
551
+ // ❌ Bad: Direct social platform query
552
+ SELECT * FROM "clients_social_platforms" WHERE id = $1
553
+
554
+ // ✅ Good: Join with clients for ownership check
555
+ SELECT csp.*
556
+ FROM "clients_social_platforms" csp
557
+ JOIN "clients" c ON c.id = csp."parentId"
558
+ WHERE csp.id = $1 AND c."userId" = $2
559
+ ```
560
+
561
+ **2. Don't Skip RLS:**
562
+ ```typescript
563
+ // ❌ Bad: Raw query without user context
564
+ await query(`SELECT * FROM "clients_social_platforms"`)
565
+
566
+ // ✅ Good: Set user context first
567
+ await query(`SET LOCAL app.current_user_id = $1`, [userId])
568
+ await query(`SELECT * FROM "clients_social_platforms"`)
569
+ ```
570
+
571
+ ## Next Steps
572
+
573
+ - **[OAuth Integration](../02-core-features/01-oauth-integration.md)** - OAuth with client context
574
+ - **[Custom Integrations](./02-custom-integrations.md)** - Build custom features
575
+ - **[Agency Management](../04-use-cases/01-agency-management.md)** - Real-world agency use case