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.
- package/agents/.gitkeep +0 -0
- package/agents/content-ops.md +227 -0
- package/agents/financial-ops.md +184 -0
- package/agents/infra-ops.md +206 -0
- package/agents/profiles.json +5 -0
- package/bin/optimal.ts +1731 -0
- package/docs/CLI-REFERENCE.md +361 -0
- package/lib/assets/index.ts +225 -0
- package/lib/assets.ts +124 -0
- package/lib/auth/index.ts +189 -0
- package/lib/board/index.ts +309 -0
- package/lib/board/types.ts +124 -0
- package/lib/bot/claim.ts +43 -0
- package/lib/bot/coordinator.ts +254 -0
- package/lib/bot/heartbeat.ts +37 -0
- package/lib/bot/index.ts +9 -0
- package/lib/bot/protocol.ts +99 -0
- package/lib/bot/reporter.ts +42 -0
- package/lib/bot/skills.ts +81 -0
- package/lib/budget/projections.ts +561 -0
- package/lib/budget/scenarios.ts +312 -0
- package/lib/cms/publish-blog.ts +129 -0
- package/lib/cms/strapi-client.ts +302 -0
- package/lib/config/registry.ts +228 -0
- package/lib/config/schema.ts +58 -0
- package/lib/config.ts +247 -0
- package/lib/errors.ts +129 -0
- package/lib/format.ts +120 -0
- package/lib/infra/.gitkeep +0 -0
- package/lib/infra/deploy.ts +70 -0
- package/lib/infra/migrate.ts +141 -0
- package/lib/newsletter/.gitkeep +0 -0
- package/lib/newsletter/distribute.ts +256 -0
- package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
- package/lib/newsletter/generate.ts +735 -0
- package/lib/returnpro/.gitkeep +0 -0
- package/lib/returnpro/anomalies.ts +258 -0
- package/lib/returnpro/audit.ts +194 -0
- package/lib/returnpro/diagnose.ts +400 -0
- package/lib/returnpro/kpis.ts +255 -0
- package/lib/returnpro/templates.ts +323 -0
- package/lib/returnpro/upload-income.ts +311 -0
- package/lib/returnpro/upload-netsuite.ts +696 -0
- package/lib/returnpro/upload-r1.ts +563 -0
- package/lib/returnpro/validate.ts +154 -0
- package/lib/social/meta.ts +228 -0
- package/lib/social/post-generator.ts +468 -0
- package/lib/social/publish.ts +301 -0
- package/lib/social/scraper.ts +503 -0
- package/lib/supabase.ts +25 -0
- package/lib/transactions/delete-batch.ts +258 -0
- package/lib/transactions/ingest.ts +659 -0
- package/lib/transactions/stamp.ts +654 -0
- package/package.json +15 -25
- package/dist/bin/optimal.d.ts +0 -2
- package/dist/bin/optimal.js +0 -995
- package/dist/lib/budget/projections.d.ts +0 -115
- package/dist/lib/budget/projections.js +0 -384
- package/dist/lib/budget/scenarios.d.ts +0 -93
- package/dist/lib/budget/scenarios.js +0 -214
- package/dist/lib/cms/publish-blog.d.ts +0 -62
- package/dist/lib/cms/publish-blog.js +0 -74
- package/dist/lib/cms/strapi-client.d.ts +0 -123
- package/dist/lib/cms/strapi-client.js +0 -213
- package/dist/lib/config.d.ts +0 -55
- package/dist/lib/config.js +0 -206
- package/dist/lib/infra/deploy.d.ts +0 -29
- package/dist/lib/infra/deploy.js +0 -58
- package/dist/lib/infra/migrate.d.ts +0 -34
- package/dist/lib/infra/migrate.js +0 -103
- package/dist/lib/kanban.d.ts +0 -46
- package/dist/lib/kanban.js +0 -118
- package/dist/lib/newsletter/distribute.d.ts +0 -52
- package/dist/lib/newsletter/distribute.js +0 -193
- package/dist/lib/newsletter/generate-insurance.js +0 -36
- package/dist/lib/newsletter/generate.d.ts +0 -104
- package/dist/lib/newsletter/generate.js +0 -571
- package/dist/lib/returnpro/anomalies.d.ts +0 -64
- package/dist/lib/returnpro/anomalies.js +0 -166
- package/dist/lib/returnpro/audit.d.ts +0 -32
- package/dist/lib/returnpro/audit.js +0 -147
- package/dist/lib/returnpro/diagnose.d.ts +0 -52
- package/dist/lib/returnpro/diagnose.js +0 -281
- package/dist/lib/returnpro/kpis.d.ts +0 -32
- package/dist/lib/returnpro/kpis.js +0 -192
- package/dist/lib/returnpro/templates.d.ts +0 -48
- package/dist/lib/returnpro/templates.js +0 -229
- package/dist/lib/returnpro/upload-income.d.ts +0 -25
- package/dist/lib/returnpro/upload-income.js +0 -235
- package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
- package/dist/lib/returnpro/upload-netsuite.js +0 -566
- package/dist/lib/returnpro/upload-r1.d.ts +0 -48
- package/dist/lib/returnpro/upload-r1.js +0 -398
- package/dist/lib/social/post-generator.d.ts +0 -83
- package/dist/lib/social/post-generator.js +0 -333
- package/dist/lib/social/publish.d.ts +0 -66
- package/dist/lib/social/publish.js +0 -226
- package/dist/lib/social/scraper.d.ts +0 -67
- package/dist/lib/social/scraper.js +0 -361
- package/dist/lib/supabase.d.ts +0 -4
- package/dist/lib/supabase.js +0 -20
- package/dist/lib/transactions/delete-batch.d.ts +0 -60
- package/dist/lib/transactions/delete-batch.js +0 -203
- package/dist/lib/transactions/ingest.d.ts +0 -43
- package/dist/lib/transactions/ingest.js +0 -555
- package/dist/lib/transactions/stamp.d.ts +0 -51
- 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
|
+
}
|