optimal-cli 0.1.0 → 1.0.0

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