@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,155 @@
1
+ /**
2
+ * Social Media Publisher - Validation Schemas
3
+ *
4
+ * Zod schemas for request validation
5
+ */
6
+
7
+ import { z } from 'zod'
8
+
9
+ // ============================================
10
+ // PUBLISH REQUEST SCHEMAS
11
+ // ============================================
12
+
13
+ export const PublishPhotoSchema = z.object({
14
+ accountId: z.string().uuid('Invalid account ID'),
15
+ imageUrl: z.string().url('Invalid image URL').nullish(), // ✅ Accepts null/undefined - Facebook allows text-only
16
+ imageUrls: z.array(z.string().url('Invalid image URL')).optional(), // For carousels
17
+ caption: z.string().max(2200).optional(),
18
+ platform: z.enum(['instagram_business', 'facebook_page']),
19
+ }).refine(data => {
20
+ // Instagram requires at least one image
21
+ if (data.platform === 'instagram_business') {
22
+ return (data.imageUrl || (data.imageUrls && data.imageUrls.length > 0))
23
+ }
24
+ return true
25
+ }, { message: 'Instagram requires at least one image' }).refine(data => {
26
+ // Instagram allows maximum 10 images per carousel
27
+ if (data.platform === 'instagram_business' && data.imageUrls && data.imageUrls.length > 10) {
28
+ return false
29
+ }
30
+ return true
31
+ }, { message: 'Instagram allows maximum 10 images per carousel' })
32
+
33
+ export const PublishTextSchema = z.object({
34
+ accountId: z.string().uuid('Invalid account ID'),
35
+ message: z.string().min(1, 'Message is required').max(5000),
36
+ platform: z.literal('facebook_page'), // Only Facebook supports text-only posts
37
+ })
38
+
39
+ export const PublishLinkSchema = z.object({
40
+ accountId: z.string().uuid('Invalid account ID'),
41
+ message: z.string().max(5000).optional(),
42
+ linkUrl: z.string().url('Invalid link URL'),
43
+ platform: z.literal('facebook_page'),
44
+ })
45
+
46
+ // ============================================
47
+ // CONNECT ACCOUNT SCHEMAS
48
+ // ============================================
49
+
50
+ export const ConnectAccountSchema = z.object({
51
+ code: z.string().min(1, 'Authorization code is required'),
52
+ state: z.string().min(1, 'State parameter is required'),
53
+ platform: z.enum(['instagram_business', 'facebook_page']),
54
+ })
55
+
56
+ // ============================================
57
+ // DISCONNECT ACCOUNT SCHEMAS
58
+ // ============================================
59
+
60
+ export const DisconnectAccountSchema = z.object({
61
+ accountId: z.string().uuid('Invalid account ID'),
62
+ })
63
+
64
+ // ============================================
65
+ // VALIDATION HELPERS
66
+ // ============================================
67
+
68
+ /**
69
+ * Validate image URL requirements
70
+ */
71
+ export function validateImageUrl(url: string): {
72
+ valid: boolean
73
+ error?: string
74
+ } {
75
+ try {
76
+ const parsed = new URL(url)
77
+
78
+ // Must be HTTPS
79
+ if (parsed.protocol !== 'https:') {
80
+ return {
81
+ valid: false,
82
+ error: 'Image URL must use HTTPS protocol',
83
+ }
84
+ }
85
+
86
+ // Must not be localhost
87
+ if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {
88
+ return {
89
+ valid: false,
90
+ error: 'Image URL cannot be localhost',
91
+ }
92
+ }
93
+
94
+ // Check file extension
95
+ const validExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
96
+ const hasValidExtension = validExtensions.some(ext =>
97
+ parsed.pathname.toLowerCase().endsWith(ext)
98
+ )
99
+
100
+ if (!hasValidExtension) {
101
+ return {
102
+ valid: false,
103
+ error: `Image must be one of: ${validExtensions.join(', ')}`,
104
+ }
105
+ }
106
+
107
+ return { valid: true }
108
+ } catch (error) {
109
+ return {
110
+ valid: false,
111
+ error: 'Invalid URL format',
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Validate caption length for platform
118
+ */
119
+ export function validateCaption(
120
+ caption: string,
121
+ platform: 'instagram_business' | 'facebook_page'
122
+ ): {
123
+ valid: boolean
124
+ error?: string
125
+ } {
126
+ const maxLengths = {
127
+ instagram_business: 2200,
128
+ facebook_page: 63206,
129
+ }
130
+
131
+ const maxLength = maxLengths[platform]
132
+
133
+ if (caption.length > maxLength) {
134
+ return {
135
+ valid: false,
136
+ error: `Caption exceeds maximum length of ${maxLength} characters for ${platform}`,
137
+ }
138
+ }
139
+
140
+ return { valid: true }
141
+ }
142
+
143
+ /**
144
+ * Check if a platform requires an image to publish
145
+ * Instagram: Always requires image/video
146
+ * Facebook: Allows text-only posts
147
+ */
148
+ export function platformRequiresImage(platform: string): boolean {
149
+ const platformsRequiringImage = [
150
+ 'instagram_business',
151
+ 'tiktok',
152
+ 'pinterest',
153
+ ]
154
+ return platformsRequiringImage.includes(platform)
155
+ }
@@ -0,0 +1,167 @@
1
+ -- Migration: 009_social_media_publisher.sql
2
+ -- Description: Tables for social media publishing plugin
3
+ -- Date: 2025-10-20
4
+ -- Plugin: social-media-publisher
5
+
6
+ -- ============================================
7
+ -- TABLES
8
+ -- ============================================
9
+
10
+ -- Social Accounts Table
11
+ -- Stores OAuth-connected social media accounts for publishing
12
+ -- Supports multiple accounts per platform per user
13
+ CREATE TABLE IF NOT EXISTS "social_accounts" (
14
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
15
+ "userId" TEXT NOT NULL REFERENCES "users"(id) ON DELETE CASCADE,
16
+ platform TEXT NOT NULL CHECK (platform IN ('instagram_business', 'facebook_page')),
17
+ "platformAccountId" TEXT NOT NULL,
18
+ "username" TEXT NOT NULL,
19
+ "accessToken" TEXT NOT NULL, -- Encrypted (format: encrypted:iv:keyId)
20
+ "tokenExpiresAt" TIMESTAMPTZ NOT NULL,
21
+ permissions JSONB DEFAULT '[]'::jsonb,
22
+ "accountMetadata" JSONB DEFAULT '{}'::jsonb,
23
+ "isActive" BOOLEAN DEFAULT true,
24
+ "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
25
+ "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
26
+
27
+ -- Constraints
28
+ UNIQUE("platformAccountId") -- Prevent same account connected multiple times
29
+ );
30
+
31
+ -- Audit Logs Table
32
+ -- Tracks all actions performed through social media plugin
33
+ -- Note: accountId references clients_social_platforms.id (not enforced with FK to preserve historical logs)
34
+ CREATE TABLE IF NOT EXISTS "audit_logs" (
35
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
36
+ "userId" TEXT NOT NULL REFERENCES "users"(id) ON DELETE CASCADE,
37
+ "accountId" UUID, -- References clients_social_platforms.id (nullable, no FK to preserve historical logs)
38
+ action TEXT NOT NULL CHECK (action IN (
39
+ 'account_connected',
40
+ 'account_disconnected',
41
+ 'post_published',
42
+ 'post_failed',
43
+ 'token_refreshed',
44
+ 'token_refresh_failed'
45
+ )),
46
+ details JSONB DEFAULT '{}'::jsonb,
47
+ "ipAddress" TEXT,
48
+ "userAgent" TEXT,
49
+ "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()
50
+ );
51
+
52
+ -- ============================================
53
+ -- INDEXES
54
+ -- ============================================
55
+
56
+ -- Social Accounts indexes
57
+ CREATE INDEX IF NOT EXISTS idx_social_accounts_user_platform
58
+ ON "social_accounts"("userId", platform);
59
+
60
+ CREATE INDEX IF NOT EXISTS idx_social_accounts_platform_id_unique
61
+ ON "social_accounts"("platformAccountId");
62
+
63
+ CREATE INDEX IF NOT EXISTS idx_social_accounts_active
64
+ ON "social_accounts"("isActive");
65
+
66
+ CREATE INDEX IF NOT EXISTS idx_social_accounts_token_expiry
67
+ ON "social_accounts"("tokenExpiresAt");
68
+
69
+ -- Audit Logs indexes
70
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_user_created
71
+ ON "audit_logs"("userId", "createdAt");
72
+
73
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_account_action
74
+ ON "audit_logs"("accountId", action);
75
+
76
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_action_created
77
+ ON "audit_logs"(action, "createdAt");
78
+
79
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_created_desc
80
+ ON "audit_logs"("createdAt" DESC);
81
+
82
+ -- ============================================
83
+ -- RLS (Enable)
84
+ -- ============================================
85
+
86
+ ALTER TABLE "social_accounts" ENABLE ROW LEVEL SECURITY;
87
+ ALTER TABLE "audit_logs" ENABLE ROW LEVEL SECURITY;
88
+
89
+ -- ============================================
90
+ -- POLICIES
91
+ -- ============================================
92
+
93
+ -- Social Accounts Policies
94
+ -- Users can only see and manage their own accounts
95
+ CREATE POLICY "social_accounts_select_own"
96
+ ON "social_accounts" FOR SELECT
97
+ USING ("userId" = current_setting('app.current_user_id', true));
98
+
99
+ CREATE POLICY "social_accounts_insert_own"
100
+ ON "social_accounts" FOR INSERT
101
+ WITH CHECK ("userId" = current_setting('app.current_user_id', true));
102
+
103
+ CREATE POLICY "social_accounts_update_own"
104
+ ON "social_accounts" FOR UPDATE
105
+ USING ("userId" = current_setting('app.current_user_id', true));
106
+
107
+ CREATE POLICY "social_accounts_delete_own"
108
+ ON "social_accounts" FOR DELETE
109
+ USING ("userId" = current_setting('app.current_user_id', true));
110
+
111
+ -- Audit Logs Policies
112
+ -- Users can only view their own audit logs
113
+ CREATE POLICY "audit_logs_select_own"
114
+ ON "audit_logs" FOR SELECT
115
+ USING ("userId" = current_setting('app.current_user_id', true));
116
+
117
+ -- Only system can insert audit logs
118
+ CREATE POLICY "audit_logs_insert_system"
119
+ ON "audit_logs" FOR INSERT
120
+ WITH CHECK (true); -- System role bypasses RLS
121
+
122
+ -- Audit logs are immutable (no updates)
123
+ -- No update policy = no one can update
124
+
125
+ -- Only admins can delete old audit logs
126
+ CREATE POLICY "audit_logs_delete_admin"
127
+ ON "audit_logs" FOR DELETE
128
+ USING (current_setting('app.current_user_role', true) = 'admin');
129
+
130
+ -- ============================================
131
+ -- TRIGGERS (updatedAt)
132
+ -- ============================================
133
+
134
+ DROP TRIGGER IF EXISTS social_accounts_set_updated_at ON "social_accounts";
135
+ CREATE TRIGGER social_accounts_set_updated_at
136
+ BEFORE UPDATE ON "social_accounts"
137
+ FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
138
+
139
+ -- Note: audit_logs doesn't need updatedAt trigger (immutable)
140
+
141
+ -- ============================================
142
+ -- COMMENTS (Documentation)
143
+ -- ============================================
144
+
145
+ COMMENT ON TABLE "social_accounts" IS
146
+ 'OAuth-connected social media accounts for publishing (Instagram Business & Facebook Pages)';
147
+
148
+ COMMENT ON COLUMN "social_accounts"."accessToken" IS
149
+ 'Encrypted OAuth access token (format: encrypted:iv:keyId using AES-256-GCM)';
150
+
151
+ COMMENT ON COLUMN "social_accounts"."platformAccountId" IS
152
+ 'Instagram Business Account ID or Facebook Page ID from platform';
153
+
154
+ COMMENT ON COLUMN "social_accounts"."permissions" IS
155
+ 'Array of granted OAuth scopes (e.g., ["instagram_business_basic", "instagram_business_content_publish"])';
156
+
157
+ COMMENT ON COLUMN "social_accounts"."accountMetadata" IS
158
+ 'Platform-specific metadata: profile picture URL, follower count, linked page info, etc.';
159
+
160
+ COMMENT ON TABLE "audit_logs" IS
161
+ 'Immutable audit trail for all social media actions (security & compliance)';
162
+
163
+ COMMENT ON COLUMN "audit_logs"."accountId" IS
164
+ 'References social platform account ID (clients_social_platforms.id). Nullable to preserve historical logs even after account deletion.';
165
+
166
+ COMMENT ON COLUMN "audit_logs".details IS
167
+ 'Action details: platform, success status, error messages, post IDs, etc.';
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@nextsparkjs/plugin-social-media-publisher",
3
+ "version": "0.1.0-beta.1",
4
+ "private": false,
5
+ "main": "./plugin.config.ts",
6
+ "requiredPlugins": [],
7
+ "dependencies": {},
8
+ "peerDependencies": {
9
+ "@nextsparkjs/core": "workspace:*",
10
+ "next": "^15.0.0",
11
+ "react": "^19.0.0",
12
+ "react-dom": "^19.0.0",
13
+ "zod": "^4.0.0"
14
+ }
15
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Social Media Publisher Plugin Configuration
3
+ *
4
+ * Enables publishing to Instagram Business & Facebook Pages
5
+ * with OAuth token management and multi-account support
6
+ */
7
+
8
+ import type { PluginConfig } from '@nextsparkjs/core/types/plugin'
9
+
10
+ // OAuth Providers configuration (plugin-specific metadata)
11
+ const OAUTH_PROVIDERS = {
12
+ facebook: {
13
+ name: 'Facebook',
14
+ authEndpoint: 'https://www.facebook.com/v18.0/dialog/oauth',
15
+ tokenEndpoint: 'https://graph.facebook.com/v18.0/oauth/access_token',
16
+ apiVersion: 'v18.0',
17
+ scopes: {
18
+ minimal: ['email', 'public_profile'],
19
+ publishing: [
20
+ 'pages_show_list',
21
+ 'pages_manage_posts',
22
+ 'pages_read_engagement'
23
+ ]
24
+ }
25
+ },
26
+ instagram: {
27
+ name: 'Instagram Business',
28
+ authEndpoint: 'https://www.facebook.com/v18.0/dialog/oauth', // Uses Facebook OAuth
29
+ tokenEndpoint: 'https://graph.facebook.com/v18.0/oauth/access_token',
30
+ apiVersion: 'v18.0',
31
+ scopes: {
32
+ minimal: ['instagram_basic'],
33
+ publishing: [
34
+ 'instagram_basic',
35
+ 'instagram_content_publish',
36
+ 'instagram_manage_insights'
37
+ ]
38
+ }
39
+ }
40
+ } as const
41
+
42
+ // Feature flags (plugin-specific metadata)
43
+ const PLUGIN_FEATURES = {
44
+ multiAccountSupport: true,
45
+ tokenAutoRefresh: true,
46
+ auditLogging: true,
47
+ permissionValidation: true
48
+ } as const
49
+
50
+ /**
51
+ * Social Media Publisher Plugin Configuration
52
+ * Follows PluginConfig interface for registry compatibility
53
+ */
54
+ export const socialMediaPublisherPluginConfig: PluginConfig = {
55
+ name: 'social-media-publisher',
56
+ displayName: 'Social Media Publisher',
57
+ version: '1.0.0',
58
+ description: 'Publish content to Instagram Business & Facebook Pages with OAuth integration',
59
+ enabled: true,
60
+ dependencies: [], // No plugin dependencies (uses core OAuth infrastructure)
61
+
62
+ // Plugin API - exports metadata for themes/plugins to use
63
+ api: {
64
+ providers: OAUTH_PROVIDERS,
65
+ features: PLUGIN_FEATURES,
66
+ entities: ['audit-logs']
67
+ },
68
+
69
+ // Plugin lifecycle hooks
70
+ hooks: {
71
+ onLoad: async () => {
72
+ console.log('[Social Media Publisher] Plugin loaded - OAuth publishing ready')
73
+ }
74
+ }
75
+ }
76
+
77
+ // Default export for compatibility
78
+ export default socialMediaPublisherPluginConfig
79
+
80
+ // Type exports
81
+ export type SocialMediaPublisherConfig = typeof socialMediaPublisherPluginConfig
package/tsconfig.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "baseUrl": ".",
6
+ "moduleResolution": "node",
7
+ "paths": {
8
+ "@/*": ["../../../*"],
9
+ "@/core/*": ["../../../core/*"],
10
+ "@/contents/*": ["../../../contents/*"],
11
+ "~/*": ["./*"]
12
+ }
13
+ },
14
+ "watchOptions": {
15
+ "watchFile": "useFsEvents",
16
+ "watchDirectory": "useFsEvents",
17
+ "fallbackPolling": "dynamicPriority",
18
+ "synchronousWatchDirectory": false,
19
+ "excludeDirectories": [
20
+ "**/node_modules",
21
+ "**/.next",
22
+ "**/dist",
23
+ "**/build",
24
+ "**/.turbo",
25
+ "**/coverage",
26
+ "**/.git"
27
+ ]
28
+ },
29
+ "include": [
30
+ "**/*.ts",
31
+ "**/*.tsx",
32
+ "plugin.config.ts"
33
+ ],
34
+ "exclude": [
35
+ "node_modules",
36
+ ".next",
37
+ "dist",
38
+ "build",
39
+ ".turbo",
40
+ "coverage",
41
+ ".git",
42
+ "**/*.test.ts",
43
+ "**/*.test.tsx",
44
+ "**/__tests__/**",
45
+ "**/tsconfig.tsbuildinfo"
46
+ ]
47
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Social Media Publisher - TypeScript Types
3
+ */
4
+
5
+ // ============================================
6
+ // PLATFORM TYPES
7
+ // ============================================
8
+
9
+ export type SocialPlatform = 'instagram_business' | 'facebook_page'
10
+
11
+ export type FacebookScope =
12
+ | 'email'
13
+ | 'public_profile'
14
+ | 'pages_show_list'
15
+ | 'pages_manage_posts'
16
+ | 'pages_read_engagement'
17
+ | 'read_insights'
18
+
19
+ export type InstagramScope =
20
+ | 'instagram_business_basic'
21
+ | 'instagram_business_content_publish'
22
+ | 'instagram_manage_insights'
23
+
24
+ export type OAuthScope = FacebookScope | InstagramScope
25
+
26
+ // ============================================
27
+ // ACCOUNT TYPES
28
+ // ============================================
29
+
30
+ export interface SocialAccount {
31
+ id: string
32
+ userId: string
33
+ platform: SocialPlatform
34
+ platformAccountId: string
35
+ username: string
36
+ accessToken: string // Encrypted
37
+ tokenExpiresAt: Date
38
+ permissions: OAuthScope[]
39
+ accountMetadata: SocialAccountMetadata
40
+ isActive: boolean
41
+ createdAt: Date
42
+ updatedAt: Date
43
+ }
44
+
45
+ export interface SocialAccountMetadata {
46
+ profilePictureUrl?: string
47
+ followersCount?: number
48
+ postsCount?: number
49
+ lastSyncAt?: string
50
+ additionalData?: Record<string, unknown>
51
+ }
52
+
53
+ // ============================================
54
+ // OAUTH TYPES
55
+ // ============================================
56
+
57
+ export interface OAuthCallbackParams {
58
+ code: string
59
+ state: string
60
+ error?: string
61
+ error_description?: string
62
+ }
63
+
64
+ export interface OAuthTokenResponse {
65
+ access_token: string
66
+ token_type: string
67
+ expires_in: number
68
+ refresh_token?: string
69
+ scope?: string
70
+ }
71
+
72
+ export interface FacebookPageInfo {
73
+ id: string
74
+ name: string
75
+ category: string
76
+ access_token: string
77
+ tasks: string[]
78
+ }
79
+
80
+ export interface InstagramBusinessAccount {
81
+ id: string
82
+ username: string
83
+ profile_picture_url?: string
84
+ followers_count?: number
85
+ follows_count?: number
86
+ media_count?: number
87
+ }
88
+
89
+ // ============================================
90
+ // PUBLISHING TYPES
91
+ // ============================================
92
+
93
+ export interface PublishRequest {
94
+ accountId: string
95
+ content: PublishContent
96
+ }
97
+
98
+ export interface PublishContent {
99
+ // Common fields
100
+ caption?: string
101
+
102
+ // Media
103
+ imageUrl?: string
104
+ videoUrl?: string
105
+
106
+ // Scheduling
107
+ scheduledAt?: Date
108
+
109
+ // Platform-specific
110
+ platformOptions?: Record<string, unknown>
111
+ }
112
+
113
+ export interface PublishResult {
114
+ success: boolean
115
+ platform: SocialPlatform
116
+ postId?: string
117
+ postUrl?: string
118
+ error?: string
119
+ }
120
+
121
+ // ============================================
122
+ // AUDIT LOG TYPES
123
+ // ============================================
124
+
125
+ export type AuditAction =
126
+ | 'account_connected'
127
+ | 'account_disconnected'
128
+ | 'post_published'
129
+ | 'post_failed'
130
+ | 'token_refreshed'
131
+ | 'token_refresh_failed'
132
+
133
+ export interface AuditLog {
134
+ id: string
135
+ userId: string
136
+ accountId?: string
137
+ action: AuditAction
138
+ details: AuditLogDetails
139
+ ipAddress?: string
140
+ userAgent?: string
141
+ createdAt: Date
142
+ }
143
+
144
+ export interface AuditLogDetails {
145
+ platform?: SocialPlatform
146
+ success: boolean
147
+ errorMessage?: string
148
+ metadata?: Record<string, unknown>
149
+ }
150
+
151
+ // ============================================
152
+ // API RESPONSE TYPES
153
+ // ============================================
154
+
155
+ export interface ConnectAccountResponse {
156
+ success: boolean
157
+ account?: SocialAccount
158
+ error?: string
159
+ }
160
+
161
+ export interface DisconnectAccountResponse {
162
+ success: boolean
163
+ accountId: string
164
+ message?: string
165
+ }
166
+
167
+ export interface GetAccountsResponse {
168
+ success: boolean
169
+ accounts: SocialAccount[]
170
+ total: number
171
+ }