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