optimal-cli 1.0.0 → 1.0.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.
Files changed (135) hide show
  1. package/dist/bin/optimal.d.ts +2 -0
  2. package/dist/bin/optimal.js +1590 -0
  3. package/dist/lib/assets/index.d.ts +79 -0
  4. package/dist/lib/assets/index.js +153 -0
  5. package/dist/lib/assets.d.ts +20 -0
  6. package/dist/lib/assets.js +112 -0
  7. package/dist/lib/auth/index.d.ts +83 -0
  8. package/dist/lib/auth/index.js +146 -0
  9. package/dist/lib/board/index.d.ts +39 -0
  10. package/dist/lib/board/index.js +285 -0
  11. package/dist/lib/board/types.d.ts +111 -0
  12. package/dist/lib/board/types.js +1 -0
  13. package/dist/lib/bot/claim.d.ts +3 -0
  14. package/dist/lib/bot/claim.js +20 -0
  15. package/dist/lib/bot/coordinator.d.ts +27 -0
  16. package/dist/lib/bot/coordinator.js +178 -0
  17. package/dist/lib/bot/heartbeat.d.ts +6 -0
  18. package/dist/lib/bot/heartbeat.js +30 -0
  19. package/dist/lib/bot/index.d.ts +9 -0
  20. package/dist/lib/bot/index.js +6 -0
  21. package/dist/lib/bot/protocol.d.ts +12 -0
  22. package/dist/lib/bot/protocol.js +74 -0
  23. package/dist/lib/bot/reporter.d.ts +3 -0
  24. package/dist/lib/bot/reporter.js +27 -0
  25. package/dist/lib/bot/skills.d.ts +26 -0
  26. package/dist/lib/bot/skills.js +69 -0
  27. package/dist/lib/budget/projections.d.ts +115 -0
  28. package/dist/lib/budget/projections.js +384 -0
  29. package/dist/lib/budget/scenarios.d.ts +93 -0
  30. package/dist/lib/budget/scenarios.js +214 -0
  31. package/dist/lib/cms/publish-blog.d.ts +62 -0
  32. package/dist/lib/cms/publish-blog.js +74 -0
  33. package/dist/lib/cms/strapi-client.d.ts +123 -0
  34. package/dist/lib/cms/strapi-client.js +213 -0
  35. package/dist/lib/config/registry.d.ts +17 -0
  36. package/dist/lib/config/registry.js +182 -0
  37. package/dist/lib/config/schema.d.ts +31 -0
  38. package/dist/lib/config/schema.js +25 -0
  39. package/dist/lib/config.d.ts +55 -0
  40. package/dist/lib/config.js +206 -0
  41. package/dist/lib/errors.d.ts +25 -0
  42. package/dist/lib/errors.js +91 -0
  43. package/dist/lib/format.d.ts +28 -0
  44. package/dist/lib/format.js +98 -0
  45. package/dist/lib/infra/deploy.d.ts +29 -0
  46. package/dist/lib/infra/deploy.js +58 -0
  47. package/dist/lib/infra/migrate.d.ts +34 -0
  48. package/dist/lib/infra/migrate.js +103 -0
  49. package/dist/lib/newsletter/distribute.d.ts +52 -0
  50. package/dist/lib/newsletter/distribute.js +193 -0
  51. package/{lib/newsletter/generate-insurance.ts → dist/lib/newsletter/generate-insurance.d.ts} +7 -24
  52. package/dist/lib/newsletter/generate-insurance.js +36 -0
  53. package/dist/lib/newsletter/generate.d.ts +104 -0
  54. package/dist/lib/newsletter/generate.js +571 -0
  55. package/dist/lib/returnpro/anomalies.d.ts +64 -0
  56. package/dist/lib/returnpro/anomalies.js +166 -0
  57. package/dist/lib/returnpro/audit.d.ts +32 -0
  58. package/dist/lib/returnpro/audit.js +147 -0
  59. package/dist/lib/returnpro/diagnose.d.ts +52 -0
  60. package/dist/lib/returnpro/diagnose.js +281 -0
  61. package/dist/lib/returnpro/kpis.d.ts +32 -0
  62. package/dist/lib/returnpro/kpis.js +192 -0
  63. package/dist/lib/returnpro/templates.d.ts +48 -0
  64. package/dist/lib/returnpro/templates.js +229 -0
  65. package/dist/lib/returnpro/upload-income.d.ts +25 -0
  66. package/dist/lib/returnpro/upload-income.js +235 -0
  67. package/dist/lib/returnpro/upload-netsuite.d.ts +37 -0
  68. package/dist/lib/returnpro/upload-netsuite.js +566 -0
  69. package/dist/lib/returnpro/upload-r1.d.ts +48 -0
  70. package/dist/lib/returnpro/upload-r1.js +398 -0
  71. package/dist/lib/returnpro/validate.d.ts +37 -0
  72. package/dist/lib/returnpro/validate.js +124 -0
  73. package/dist/lib/social/meta.d.ts +90 -0
  74. package/dist/lib/social/meta.js +160 -0
  75. package/dist/lib/social/post-generator.d.ts +83 -0
  76. package/dist/lib/social/post-generator.js +333 -0
  77. package/dist/lib/social/publish.d.ts +66 -0
  78. package/dist/lib/social/publish.js +226 -0
  79. package/dist/lib/social/scraper.d.ts +67 -0
  80. package/dist/lib/social/scraper.js +361 -0
  81. package/dist/lib/supabase.d.ts +4 -0
  82. package/dist/lib/supabase.js +20 -0
  83. package/dist/lib/transactions/delete-batch.d.ts +60 -0
  84. package/dist/lib/transactions/delete-batch.js +203 -0
  85. package/dist/lib/transactions/ingest.d.ts +43 -0
  86. package/dist/lib/transactions/ingest.js +555 -0
  87. package/dist/lib/transactions/stamp.d.ts +51 -0
  88. package/dist/lib/transactions/stamp.js +524 -0
  89. package/package.json +3 -4
  90. package/bin/optimal.ts +0 -1731
  91. package/lib/assets/index.ts +0 -225
  92. package/lib/assets.ts +0 -124
  93. package/lib/auth/index.ts +0 -189
  94. package/lib/board/index.ts +0 -309
  95. package/lib/board/types.ts +0 -124
  96. package/lib/bot/claim.ts +0 -43
  97. package/lib/bot/coordinator.ts +0 -254
  98. package/lib/bot/heartbeat.ts +0 -37
  99. package/lib/bot/index.ts +0 -9
  100. package/lib/bot/protocol.ts +0 -99
  101. package/lib/bot/reporter.ts +0 -42
  102. package/lib/bot/skills.ts +0 -81
  103. package/lib/budget/projections.ts +0 -561
  104. package/lib/budget/scenarios.ts +0 -312
  105. package/lib/cms/publish-blog.ts +0 -129
  106. package/lib/cms/strapi-client.ts +0 -302
  107. package/lib/config/registry.ts +0 -228
  108. package/lib/config/schema.ts +0 -58
  109. package/lib/config.ts +0 -247
  110. package/lib/errors.ts +0 -129
  111. package/lib/format.ts +0 -120
  112. package/lib/infra/.gitkeep +0 -0
  113. package/lib/infra/deploy.ts +0 -70
  114. package/lib/infra/migrate.ts +0 -141
  115. package/lib/newsletter/.gitkeep +0 -0
  116. package/lib/newsletter/distribute.ts +0 -256
  117. package/lib/newsletter/generate.ts +0 -735
  118. package/lib/returnpro/.gitkeep +0 -0
  119. package/lib/returnpro/anomalies.ts +0 -258
  120. package/lib/returnpro/audit.ts +0 -194
  121. package/lib/returnpro/diagnose.ts +0 -400
  122. package/lib/returnpro/kpis.ts +0 -255
  123. package/lib/returnpro/templates.ts +0 -323
  124. package/lib/returnpro/upload-income.ts +0 -311
  125. package/lib/returnpro/upload-netsuite.ts +0 -696
  126. package/lib/returnpro/upload-r1.ts +0 -563
  127. package/lib/returnpro/validate.ts +0 -154
  128. package/lib/social/meta.ts +0 -228
  129. package/lib/social/post-generator.ts +0 -468
  130. package/lib/social/publish.ts +0 -301
  131. package/lib/social/scraper.ts +0 -503
  132. package/lib/supabase.ts +0 -25
  133. package/lib/transactions/delete-batch.ts +0 -258
  134. package/lib/transactions/ingest.ts +0 -659
  135. package/lib/transactions/stamp.ts +0 -654
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Meta Graph API — Instagram Content Publishing
3
+ *
4
+ * Direct Instagram publishing via Meta's Content Publishing API.
5
+ * Replaces n8n webhook intermediary for IG posts.
6
+ *
7
+ * Functions:
8
+ * publishIgPhoto() — Publish a single image post to Instagram
9
+ * publishIgCarousel() — Publish a carousel (multi-image) post to Instagram
10
+ * getMetaConfig() — Read Meta credentials from env vars
11
+ * getMetaConfigForBrand() — Read brand-specific Meta credentials
12
+ */
13
+ export class MetaApiError extends Error {
14
+ status;
15
+ metaError;
16
+ constructor(message, status, metaError) {
17
+ super(message);
18
+ this.status = status;
19
+ this.metaError = metaError;
20
+ this.name = 'MetaApiError';
21
+ }
22
+ }
23
+ // ── Config ───────────────────────────────────────────────────────────
24
+ const GRAPH_API_BASE = 'https://graph.facebook.com/v21.0';
25
+ // Injectable fetch for testing
26
+ let _fetch = globalThis.fetch;
27
+ export function setFetchForTests(fn) {
28
+ _fetch = fn;
29
+ }
30
+ export function resetFetchForTests() {
31
+ _fetch = globalThis.fetch;
32
+ }
33
+ // ── Internal helpers ─────────────────────────────────────────────────
34
+ async function graphPost(path, body) {
35
+ const res = await _fetch(`${GRAPH_API_BASE}${path}`, {
36
+ method: 'POST',
37
+ headers: { 'Content-Type': 'application/json' },
38
+ body: JSON.stringify(body),
39
+ });
40
+ const data = await res.json();
41
+ if (!res.ok) {
42
+ const err = data.error;
43
+ throw new MetaApiError(err?.message ?? `Meta API ${res.status}: ${res.statusText}`, res.status, err ? { message: err.message ?? '', type: err.type, code: err.code } : undefined);
44
+ }
45
+ return data;
46
+ }
47
+ // ── Config readers ───────────────────────────────────────────────────
48
+ /**
49
+ * Read Meta API credentials from environment variables.
50
+ * Requires: META_ACCESS_TOKEN, META_IG_ACCOUNT_ID
51
+ */
52
+ export function getMetaConfig() {
53
+ const accessToken = process.env.META_ACCESS_TOKEN;
54
+ const igAccountId = process.env.META_IG_ACCOUNT_ID;
55
+ if (!accessToken) {
56
+ throw new Error('Missing env var: META_ACCESS_TOKEN\n' +
57
+ 'Get a long-lived page access token from Meta Business Suite:\n' +
58
+ ' https://business.facebook.com/settings/system-users');
59
+ }
60
+ if (!igAccountId) {
61
+ throw new Error('Missing env var: META_IG_ACCOUNT_ID\n' +
62
+ 'Find your IG Business account ID via Graph API Explorer:\n' +
63
+ ' GET /me/accounts → page_id → GET /{page_id}?fields=instagram_business_account');
64
+ }
65
+ return { accessToken, igAccountId };
66
+ }
67
+ /**
68
+ * Read Meta API credentials for a specific brand.
69
+ * Looks for META_IG_ACCOUNT_ID_{BRAND} first, falls back to META_IG_ACCOUNT_ID.
70
+ * Brand key is normalized: hyphens become underscores (CRE-11TRUST → CRE_11TRUST).
71
+ */
72
+ export function getMetaConfigForBrand(brand) {
73
+ const accessToken = process.env.META_ACCESS_TOKEN;
74
+ if (!accessToken) {
75
+ throw new Error('Missing env var: META_ACCESS_TOKEN');
76
+ }
77
+ const envKey = `META_IG_ACCOUNT_ID_${brand.replace(/-/g, '_')}`;
78
+ const igAccountId = process.env[envKey] ?? process.env.META_IG_ACCOUNT_ID;
79
+ if (!igAccountId) {
80
+ throw new Error(`Missing env var: ${envKey} or META_IG_ACCOUNT_ID`);
81
+ }
82
+ return { accessToken, igAccountId };
83
+ }
84
+ // ── Publishing ───────────────────────────────────────────────────────
85
+ /**
86
+ * Publish a single photo to Instagram.
87
+ *
88
+ * Two-step process per Meta Content Publishing API:
89
+ * 1. Create media container with image_url + caption
90
+ * 2. Publish the container
91
+ *
92
+ * @example
93
+ * const result = await publishIgPhoto(config, {
94
+ * imageUrl: 'https://cdn.example.com/photo.jpg',
95
+ * caption: 'Check out our latest listing! #realestate',
96
+ * })
97
+ * console.log(`Published: ${result.mediaId}`)
98
+ */
99
+ export async function publishIgPhoto(config, opts) {
100
+ // Step 1: Create media container
101
+ const container = await graphPost(`/${config.igAccountId}/media`, {
102
+ image_url: opts.imageUrl,
103
+ caption: opts.caption,
104
+ access_token: config.accessToken,
105
+ });
106
+ // Step 2: Publish the container
107
+ const published = await graphPost(`/${config.igAccountId}/media_publish`, {
108
+ creation_id: container.id,
109
+ access_token: config.accessToken,
110
+ });
111
+ return {
112
+ containerId: container.id,
113
+ mediaId: published.id,
114
+ };
115
+ }
116
+ /**
117
+ * Publish a carousel (multi-image) post to Instagram.
118
+ *
119
+ * Three-step process:
120
+ * 1. Create individual item containers (is_carousel_item=true)
121
+ * 2. Create carousel container referencing all item IDs
122
+ * 3. Publish the carousel container
123
+ *
124
+ * @example
125
+ * const result = await publishIgCarousel(config, {
126
+ * caption: 'Property tour highlights',
127
+ * items: [
128
+ * { imageUrl: 'https://cdn.example.com/1.jpg' },
129
+ * { imageUrl: 'https://cdn.example.com/2.jpg' },
130
+ * ],
131
+ * })
132
+ */
133
+ export async function publishIgCarousel(config, opts) {
134
+ // Step 1: Create individual item containers
135
+ const itemIds = [];
136
+ for (const item of opts.items) {
137
+ const container = await graphPost(`/${config.igAccountId}/media`, {
138
+ image_url: item.imageUrl,
139
+ is_carousel_item: true,
140
+ access_token: config.accessToken,
141
+ });
142
+ itemIds.push(container.id);
143
+ }
144
+ // Step 2: Create carousel container
145
+ const carousel = await graphPost(`/${config.igAccountId}/media`, {
146
+ media_type: 'CAROUSEL',
147
+ children: itemIds,
148
+ caption: opts.caption,
149
+ access_token: config.accessToken,
150
+ });
151
+ // Step 3: Publish
152
+ const published = await graphPost(`/${config.igAccountId}/media_publish`, {
153
+ creation_id: carousel.id,
154
+ access_token: config.accessToken,
155
+ });
156
+ return {
157
+ containerId: carousel.id,
158
+ mediaId: published.id,
159
+ };
160
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Social Post Generation Pipeline
3
+ *
4
+ * Ported from Python: ~/projects/newsletter-automation social post pipeline
5
+ *
6
+ * Pipeline: Groq AI generates post ideas -> Unsplash image search -> Strapi push
7
+ *
8
+ * Functions:
9
+ * callGroq() — call Groq API (OpenAI-compatible) for AI content
10
+ * searchUnsplashImage() — search Unsplash NAPI for a stock photo URL
11
+ * generateSocialPosts() — orchestrator: generate posts and push to Strapi
12
+ */
13
+ import 'dotenv/config';
14
+ export interface GeneratePostsOptions {
15
+ brand: string;
16
+ /** Number of posts to generate (default: 9) */
17
+ count?: number;
18
+ /** Week start date in YYYY-MM-DD format */
19
+ weekOf?: string;
20
+ /** Platforms to target (default: ['instagram', 'facebook']) */
21
+ platforms?: string[];
22
+ /** Generate but do not push to Strapi */
23
+ dryRun?: boolean;
24
+ }
25
+ export interface SocialPostData {
26
+ headline: string;
27
+ body: string;
28
+ cta_text: string;
29
+ cta_url: string;
30
+ image_url: string | null;
31
+ overlay_style: 'dark-bottom' | 'brand-bottom' | 'brand-full' | 'dark-full';
32
+ template: string;
33
+ platform: string;
34
+ brand: string;
35
+ scheduled_date: string;
36
+ delivery_status: 'pending';
37
+ }
38
+ export interface GeneratePostsResult {
39
+ brand: string;
40
+ postsCreated: number;
41
+ posts: Array<{
42
+ documentId: string;
43
+ headline: string;
44
+ platform: string;
45
+ scheduled_date: string;
46
+ }>;
47
+ errors: string[];
48
+ }
49
+ /**
50
+ * Call the Groq API (OpenAI-compatible) and return the assistant's response text.
51
+ *
52
+ * @example
53
+ * const response = await callGroq('You are a copywriter.', 'Write a tagline.')
54
+ */
55
+ export declare function callGroq(systemPrompt: string, userPrompt: string): Promise<string>;
56
+ /**
57
+ * Search Unsplash NAPI for a themed stock photo URL.
58
+ * Returns the `.results[0].urls.regular` URL, or null if not found.
59
+ *
60
+ * Note: Uses the public NAPI endpoint — no auth required but may be rate-limited.
61
+ *
62
+ * @example
63
+ * const url = await searchUnsplashImage('life insurance family protection')
64
+ */
65
+ export declare function searchUnsplashImage(query: string): Promise<string | null>;
66
+ /**
67
+ * Main orchestrator: generate AI-powered social media posts and push to Strapi.
68
+ *
69
+ * Steps:
70
+ * 1. Call Groq to generate post ideas as JSON
71
+ * 2. For each post, search Unsplash for a themed image
72
+ * 3. Build SocialPostData and push to Strapi via POST /api/social-posts
73
+ * 4. Return summary of created posts and any errors
74
+ *
75
+ * @example
76
+ * const result = await generateSocialPosts({
77
+ * brand: 'LIFEINSUR',
78
+ * count: 9,
79
+ * weekOf: '2026-03-02',
80
+ * })
81
+ * console.log(`Created ${result.postsCreated} posts`)
82
+ */
83
+ export declare function generateSocialPosts(opts: GeneratePostsOptions): Promise<GeneratePostsResult>;
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Social Post Generation Pipeline
3
+ *
4
+ * Ported from Python: ~/projects/newsletter-automation social post pipeline
5
+ *
6
+ * Pipeline: Groq AI generates post ideas -> Unsplash image search -> Strapi push
7
+ *
8
+ * Functions:
9
+ * callGroq() — call Groq API (OpenAI-compatible) for AI content
10
+ * searchUnsplashImage() — search Unsplash NAPI for a stock photo URL
11
+ * generateSocialPosts() — orchestrator: generate posts and push to Strapi
12
+ */
13
+ import 'dotenv/config';
14
+ import { strapiPost } from '../cms/strapi-client.js';
15
+ // ── Constants ────────────────────────────────────────────────────────
16
+ const OVERLAY_STYLES = [
17
+ 'dark-bottom',
18
+ 'brand-bottom',
19
+ 'brand-full',
20
+ 'dark-full',
21
+ ];
22
+ const BRAND_CONFIGS = {
23
+ 'CRE-11TRUST': {
24
+ displayName: 'ElevenTrust Commercial Real Estate',
25
+ ctaUrl: 'https://eleventrust.com',
26
+ industry: 'commercial real estate in South Florida',
27
+ },
28
+ 'LIFEINSUR': {
29
+ displayName: 'Anchor Point Insurance Co.',
30
+ ctaUrl: 'https://anchorpointinsurance.com',
31
+ industry: 'life insurance and financial protection',
32
+ },
33
+ };
34
+ // ── Environment helper ───────────────────────────────────────────────
35
+ function requireEnv(name) {
36
+ const val = process.env[name];
37
+ if (!val)
38
+ throw new Error(`Missing env var: ${name}`);
39
+ return val;
40
+ }
41
+ // ── 1. Groq API Call ─────────────────────────────────────────────────
42
+ /**
43
+ * Call the Groq API (OpenAI-compatible) and return the assistant's response text.
44
+ *
45
+ * @example
46
+ * const response = await callGroq('You are a copywriter.', 'Write a tagline.')
47
+ */
48
+ export async function callGroq(systemPrompt, userPrompt) {
49
+ const apiKey = requireEnv('GROQ_API_KEY');
50
+ const model = process.env.GROQ_MODEL ?? 'llama-3.3-70b-versatile';
51
+ const messages = [
52
+ { role: 'system', content: systemPrompt },
53
+ { role: 'user', content: userPrompt },
54
+ ];
55
+ const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
56
+ method: 'POST',
57
+ headers: {
58
+ Authorization: `Bearer ${apiKey}`,
59
+ 'Content-Type': 'application/json',
60
+ },
61
+ body: JSON.stringify({
62
+ model,
63
+ messages,
64
+ temperature: 0.8,
65
+ }),
66
+ });
67
+ const data = await response.json();
68
+ if (!data.choices || data.choices.length === 0) {
69
+ throw new Error(`Groq error: ${JSON.stringify(data.error ?? data)}`);
70
+ }
71
+ return data.choices[0].message.content;
72
+ }
73
+ // ── 2. Unsplash Image Search ─────────────────────────────────────────
74
+ /**
75
+ * Search Unsplash NAPI for a themed stock photo URL.
76
+ * Returns the `.results[0].urls.regular` URL, or null if not found.
77
+ *
78
+ * Note: Uses the public NAPI endpoint — no auth required but may be rate-limited.
79
+ *
80
+ * @example
81
+ * const url = await searchUnsplashImage('life insurance family protection')
82
+ */
83
+ export async function searchUnsplashImage(query) {
84
+ try {
85
+ const encodedQuery = encodeURIComponent(query);
86
+ const response = await fetch(`https://unsplash.com/napi/search/photos?query=${encodedQuery}&per_page=3`, {
87
+ headers: {
88
+ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
89
+ Accept: 'application/json',
90
+ },
91
+ });
92
+ if (!response.ok) {
93
+ console.warn(` [Unsplash] HTTP ${response.status} for query: "${query}"`);
94
+ return null;
95
+ }
96
+ const data = await response.json();
97
+ const url = data.results?.[0]?.urls?.regular ?? null;
98
+ if (!url) {
99
+ console.warn(` [Unsplash] No results for query: "${query}"`);
100
+ }
101
+ return url;
102
+ }
103
+ catch (err) {
104
+ console.warn(` [Unsplash] Error searching for "${query}": ${err.message}`);
105
+ return null;
106
+ }
107
+ }
108
+ // ── 3. Build Weekly Schedule ─────────────────────────────────────────
109
+ /**
110
+ * Distribute post dates across a week (Mon-Fri + weekend for overflow).
111
+ * Returns an array of ISO date strings (YYYY-MM-DD).
112
+ */
113
+ function buildWeeklySchedule(weekOf, count) {
114
+ const start = new Date(weekOf);
115
+ // Ensure we start from Monday — find the Monday of the given week
116
+ const dayOfWeek = start.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat
117
+ const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
118
+ start.setDate(start.getDate() + daysToMonday);
119
+ // Build Mon-Sun sequence (7 days)
120
+ const weekDays = [];
121
+ for (let i = 0; i < 7; i++) {
122
+ const d = new Date(start);
123
+ d.setDate(start.getDate() + i);
124
+ weekDays.push(d.toISOString().slice(0, 10));
125
+ }
126
+ // Fill schedule: Mon-Fri first, then Sat-Sun for overflow
127
+ const schedule = [];
128
+ const weekdaySlots = weekDays.slice(0, 5); // Mon-Fri
129
+ const weekendSlots = weekDays.slice(5); // Sat-Sun
130
+ for (let i = 0; i < count; i++) {
131
+ if (i < weekdaySlots.length) {
132
+ schedule.push(weekdaySlots[i]);
133
+ }
134
+ else {
135
+ // Overflow into weekend, then repeat weekdays
136
+ const overflowSlots = [...weekendSlots, ...weekdaySlots];
137
+ schedule.push(overflowSlots[(i - weekdaySlots.length) % overflowSlots.length]);
138
+ }
139
+ }
140
+ return schedule;
141
+ }
142
+ // ── 4. Build AI Prompts ──────────────────────────────────────────────
143
+ function buildSystemPrompt(brand) {
144
+ const config = BRAND_CONFIGS[brand];
145
+ const displayName = config?.displayName ?? brand;
146
+ const industry = config?.industry ?? 'professional services';
147
+ return `You are an expert social media copywriter specializing in ${industry} for ${displayName}.
148
+
149
+ Your task is to generate engaging, conversion-focused social media ad posts. Each post must:
150
+ - Hook the viewer in the first line (no generic openers)
151
+ - Speak to a specific pain point or aspiration
152
+ - Include a clear, action-oriented CTA
153
+ - Be authentic and avoid jargon
154
+ - Be appropriate for paid social advertising (Instagram and Facebook)
155
+
156
+ Always respond with ONLY valid JSON — no markdown fences, no explanations, no extra text.`;
157
+ }
158
+ function buildUserPrompt(brand, platforms, weekStart, count) {
159
+ const config = BRAND_CONFIGS[brand];
160
+ const displayName = config?.displayName ?? brand;
161
+ const ctaUrl = config?.ctaUrl ?? 'https://example.com';
162
+ const industry = config?.industry ?? 'professional services';
163
+ return `Generate ${count} social media ad posts for ${displayName} (brand: ${brand}).
164
+
165
+ Target platforms: ${platforms.join(', ')}
166
+ Week of: ${weekStart}
167
+ Industry: ${industry}
168
+ CTA URL: ${ctaUrl}
169
+
170
+ Return a JSON array of exactly ${count} post objects. Each object must have these exact fields:
171
+ - "headline": string — attention-grabbing first line (max 60 chars)
172
+ - "body": string — 2-4 sentence post copy (max 280 chars)
173
+ - "cta_text": string — button label (e.g., "Get a Free Quote", "Learn More", "Schedule a Call")
174
+ - "cta_url": string — full URL for the CTA
175
+ - "image_search_query": string — 3-5 keyword search string to find a relevant stock photo on Unsplash
176
+ - "overlay_style": string — one of: "dark-bottom", "brand-bottom", "brand-full", "dark-full"
177
+ - "template": string — one of: "standard", "quote", "offer", "testimonial", "educational"
178
+
179
+ Vary the templates, tones, and angles across the ${count} posts. Mix benefit-focused, story-driven, and urgency-based approaches.
180
+
181
+ Return ONLY the JSON array, no other text.`;
182
+ }
183
+ // ── 5. Parse AI Response ─────────────────────────────────────────────
184
+ function parseAiPostIdeas(raw) {
185
+ let content = raw.trim();
186
+ // Strip markdown fences if present
187
+ if (content.startsWith('```')) {
188
+ const firstNewline = content.indexOf('\n');
189
+ content = firstNewline !== -1 ? content.slice(firstNewline + 1) : content.slice(3);
190
+ }
191
+ if (content.endsWith('```')) {
192
+ content = content.slice(0, -3).trim();
193
+ }
194
+ const parsed = JSON.parse(content);
195
+ if (!Array.isArray(parsed)) {
196
+ throw new Error('AI response is not a JSON array');
197
+ }
198
+ return parsed;
199
+ }
200
+ // ── 6. Orchestrator ──────────────────────────────────────────────────
201
+ /**
202
+ * Main orchestrator: generate AI-powered social media posts and push to Strapi.
203
+ *
204
+ * Steps:
205
+ * 1. Call Groq to generate post ideas as JSON
206
+ * 2. For each post, search Unsplash for a themed image
207
+ * 3. Build SocialPostData and push to Strapi via POST /api/social-posts
208
+ * 4. Return summary of created posts and any errors
209
+ *
210
+ * @example
211
+ * const result = await generateSocialPosts({
212
+ * brand: 'LIFEINSUR',
213
+ * count: 9,
214
+ * weekOf: '2026-03-02',
215
+ * })
216
+ * console.log(`Created ${result.postsCreated} posts`)
217
+ */
218
+ export async function generateSocialPosts(opts) {
219
+ const { brand, count = 9, weekOf = new Date().toISOString().slice(0, 10), platforms = ['instagram', 'facebook'], dryRun = false, } = opts;
220
+ const config = BRAND_CONFIGS[brand];
221
+ const displayName = config?.displayName ?? brand;
222
+ console.log('='.repeat(60));
223
+ console.log(`SOCIAL POST GENERATOR — ${displayName}`);
224
+ console.log('='.repeat(60));
225
+ console.log(`Brand: ${brand}`);
226
+ console.log(`Count: ${count}`);
227
+ console.log(`Week of: ${weekOf}`);
228
+ console.log(`Platforms: ${platforms.join(', ')}`);
229
+ if (dryRun)
230
+ console.log('DRY RUN — will not push to Strapi');
231
+ const result = {
232
+ brand,
233
+ postsCreated: 0,
234
+ posts: [],
235
+ errors: [],
236
+ };
237
+ // Step 1: Generate post ideas via Groq
238
+ console.log(`\n1. Calling Groq to generate ${count} post ideas...`);
239
+ let postIdeas;
240
+ try {
241
+ const systemPrompt = buildSystemPrompt(brand);
242
+ const userPrompt = buildUserPrompt(brand, platforms, weekOf, count);
243
+ const raw = await callGroq(systemPrompt, userPrompt);
244
+ postIdeas = parseAiPostIdeas(raw);
245
+ console.log(` Generated ${postIdeas.length} post ideas`);
246
+ }
247
+ catch (err) {
248
+ const msg = `Failed to generate post ideas: ${err.message}`;
249
+ console.error(` ERROR: ${msg}`);
250
+ result.errors.push(msg);
251
+ return result;
252
+ }
253
+ // Step 2: Build weekly schedule
254
+ const schedule = buildWeeklySchedule(weekOf, count);
255
+ console.log(`\n2. Scheduling posts: ${schedule[0]} → ${schedule[schedule.length - 1]}`);
256
+ // Step 3: Process each post idea
257
+ console.log(`\n3. Processing ${postIdeas.length} posts (image search + Strapi push)...`);
258
+ for (let i = 0; i < postIdeas.length; i++) {
259
+ const idea = postIdeas[i];
260
+ const platform = platforms[i % platforms.length];
261
+ const scheduled_date = schedule[i] ?? schedule[schedule.length - 1];
262
+ const overlay_style = OVERLAY_STYLES[i % OVERLAY_STYLES.length];
263
+ console.log(`\n [${i + 1}/${postIdeas.length}] "${idea.headline}"`);
264
+ console.log(` Platform: ${platform} | Date: ${scheduled_date}`);
265
+ // Search Unsplash for image
266
+ let image_url = null;
267
+ if (idea.image_search_query) {
268
+ console.log(` Searching Unsplash: "${idea.image_search_query}"`);
269
+ image_url = await searchUnsplashImage(idea.image_search_query);
270
+ if (image_url) {
271
+ console.log(` Image found: ${image_url.slice(0, 60)}...`);
272
+ }
273
+ else {
274
+ console.log(` No image found — posting without image`);
275
+ }
276
+ }
277
+ // Build post data
278
+ const postData = {
279
+ headline: idea.headline,
280
+ body: idea.body,
281
+ cta_text: idea.cta_text,
282
+ cta_url: idea.cta_url,
283
+ image_url,
284
+ overlay_style: idea.overlay_style ?? overlay_style,
285
+ template: idea.template ?? 'standard',
286
+ platform,
287
+ brand,
288
+ scheduled_date,
289
+ delivery_status: 'pending',
290
+ };
291
+ // Push to Strapi (unless dry run)
292
+ if (dryRun) {
293
+ console.log(` DRY RUN — would create post in Strapi`);
294
+ result.posts.push({
295
+ documentId: `dry-run-${i + 1}`,
296
+ headline: postData.headline,
297
+ platform: postData.platform,
298
+ scheduled_date: postData.scheduled_date,
299
+ });
300
+ result.postsCreated++;
301
+ }
302
+ else {
303
+ try {
304
+ const created = await strapiPost('/api/social-posts', postData);
305
+ const documentId = created.documentId ?? 'unknown';
306
+ console.log(` Created in Strapi (documentId: ${documentId})`);
307
+ result.posts.push({
308
+ documentId,
309
+ headline: postData.headline,
310
+ platform: postData.platform,
311
+ scheduled_date: postData.scheduled_date,
312
+ });
313
+ result.postsCreated++;
314
+ }
315
+ catch (err) {
316
+ const msg = `Failed to create post "${idea.headline}": ${err.message}`;
317
+ console.error(` ERROR: ${msg}`);
318
+ result.errors.push(msg);
319
+ }
320
+ }
321
+ }
322
+ // Summary
323
+ console.log('\n' + '='.repeat(60));
324
+ console.log(`DONE! Created ${result.postsCreated}/${count} posts`);
325
+ if (result.errors.length > 0) {
326
+ console.log(`Errors (${result.errors.length}):`);
327
+ for (const e of result.errors) {
328
+ console.log(` - ${e}`);
329
+ }
330
+ }
331
+ console.log('='.repeat(60));
332
+ return result;
333
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Social Post Publisher
3
+ *
4
+ * Handles publishing social posts from Strapi to platforms via n8n webhooks,
5
+ * with delivery status tracking written back to Strapi.
6
+ *
7
+ * Functions:
8
+ * publishSocialPosts() — Main orchestrator: fetch pending posts, publish to Strapi,
9
+ * trigger n8n webhook, update delivery_status
10
+ * getPublishQueue() — List posts ready to publish (pending + has scheduled_date)
11
+ * retryFailed() — Re-attempt posts with delivery_status = 'failed'
12
+ */
13
+ import 'dotenv/config';
14
+ export interface PublishOptions {
15
+ brand: string;
16
+ /** Max posts to publish (default: all pending) */
17
+ limit?: number;
18
+ /** Preview without actually publishing */
19
+ dryRun?: boolean;
20
+ }
21
+ export interface QueuedPost {
22
+ documentId: string;
23
+ headline: string;
24
+ platform: string;
25
+ brand: string;
26
+ scheduled_date: string;
27
+ }
28
+ export interface PublishResult {
29
+ published: number;
30
+ failed: number;
31
+ skipped: number;
32
+ details: Array<{
33
+ documentId: string;
34
+ headline: string;
35
+ status: 'published' | 'failed' | 'skipped';
36
+ error?: string;
37
+ }>;
38
+ }
39
+ /**
40
+ * Fetch pending social posts for a brand and publish them:
41
+ * 1. Publish in Strapi (set publishedAt)
42
+ * 2. Trigger n8n webhook
43
+ * 3. Update delivery_status to 'scheduled' (or 'failed' on error)
44
+ *
45
+ * @example
46
+ * const result = await publishSocialPosts({ brand: 'LIFEINSUR', limit: 3 })
47
+ * console.log(`Published: ${result.published}, Failed: ${result.failed}`)
48
+ */
49
+ export declare function publishSocialPosts(opts: PublishOptions): Promise<PublishResult>;
50
+ /**
51
+ * List posts ready to publish: delivery_status = 'pending' AND has a scheduled_date.
52
+ *
53
+ * @example
54
+ * const queue = await getPublishQueue('LIFEINSUR')
55
+ * queue.forEach(p => console.log(p.scheduled_date, p.headline))
56
+ */
57
+ export declare function getPublishQueue(brand: string): Promise<QueuedPost[]>;
58
+ /**
59
+ * Re-attempt publishing posts with delivery_status = 'failed'.
60
+ * Resets delivery_status to 'pending' on each post before re-processing.
61
+ *
62
+ * @example
63
+ * const result = await retryFailed('LIFEINSUR')
64
+ * console.log(`Re-published: ${result.published}, Still failing: ${result.failed}`)
65
+ */
66
+ export declare function retryFailed(brand: string): Promise<PublishResult>;