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,301 @@
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
+
14
+ import 'dotenv/config'
15
+ import {
16
+ strapiGet,
17
+ strapiPut,
18
+ publish,
19
+ type StrapiPage,
20
+ type StrapiItem,
21
+ } from '../cms/strapi-client.js'
22
+
23
+ // ── Types ─────────────────────────────────────────────────────────────
24
+
25
+ export interface PublishOptions {
26
+ brand: string
27
+ /** Max posts to publish (default: all pending) */
28
+ limit?: number
29
+ /** Preview without actually publishing */
30
+ dryRun?: boolean
31
+ }
32
+
33
+ export interface QueuedPost {
34
+ documentId: string
35
+ headline: string
36
+ platform: string
37
+ brand: string
38
+ scheduled_date: string
39
+ }
40
+
41
+ export interface PublishResult {
42
+ published: number
43
+ failed: number
44
+ skipped: number
45
+ details: Array<{
46
+ documentId: string
47
+ headline: string
48
+ status: 'published' | 'failed' | 'skipped'
49
+ error?: string
50
+ }>
51
+ }
52
+
53
+ // ── Config ────────────────────────────────────────────────────────────
54
+
55
+ function getN8nWebhookUrl(): string {
56
+ const url = process.env.N8N_WEBHOOK_URL
57
+ if (!url) {
58
+ throw new Error(
59
+ 'Missing env var: N8N_WEBHOOK_URL\n' +
60
+ 'Set it in your .env file, e.g.:\n' +
61
+ ' N8N_WEBHOOK_URL=https://n8n.op-hub.com',
62
+ )
63
+ }
64
+ return url.replace(/\/+$/, '')
65
+ }
66
+
67
+ // ── Internal helpers ──────────────────────────────────────────────────
68
+
69
+ /** Trigger n8n webhook for a single social post */
70
+ async function triggerN8nWebhook(
71
+ documentId: string,
72
+ platform: string,
73
+ brand: string,
74
+ ): Promise<void> {
75
+ const baseUrl = getN8nWebhookUrl()
76
+ const webhookUrl = `${baseUrl}/webhook/social-post-publish`
77
+
78
+ const res = await fetch(webhookUrl, {
79
+ method: 'POST',
80
+ headers: { 'Content-Type': 'application/json' },
81
+ body: JSON.stringify({ documentId, platform, brand }),
82
+ })
83
+
84
+ if (!res.ok) {
85
+ let detail = `HTTP ${res.status}: ${res.statusText}`
86
+ try {
87
+ const body = await res.text()
88
+ if (body) detail += ` — ${body.slice(0, 200)}`
89
+ } catch {
90
+ // non-text body, ignore
91
+ }
92
+ throw new Error(`n8n webhook failed: ${detail}`)
93
+ }
94
+ }
95
+
96
+ /** Fetch social posts by brand + delivery_status from Strapi */
97
+ async function fetchPostsByStatus(
98
+ brand: string,
99
+ deliveryStatus: 'pending' | 'failed',
100
+ ): Promise<StrapiItem[]> {
101
+ const result = await strapiGet<StrapiPage>('/api/social-posts', {
102
+ 'filters[brand][$eq]': brand,
103
+ 'filters[delivery_status][$eq]': deliveryStatus,
104
+ 'sort': 'scheduled_date:asc',
105
+ 'pagination[pageSize]': '250',
106
+ })
107
+ return result.data
108
+ }
109
+
110
+ /** Process a single post: publish in Strapi, trigger n8n, update status */
111
+ async function processPost(
112
+ post: StrapiItem,
113
+ dryRun: boolean,
114
+ ): Promise<{ status: 'published' | 'failed' | 'skipped'; error?: string }> {
115
+ const documentId = post.documentId
116
+ const headline = (post.headline as string | undefined) ?? '(no headline)'
117
+ const platform = (post.platform as string | undefined) ?? 'unknown'
118
+ const brand = (post.brand as string | undefined) ?? 'unknown'
119
+
120
+ if (dryRun) {
121
+ return { status: 'skipped' }
122
+ }
123
+
124
+ try {
125
+ // Step 1: Publish in Strapi (set publishedAt)
126
+ await publish('social-posts', documentId)
127
+
128
+ // Step 2: Trigger n8n webhook
129
+ try {
130
+ await triggerN8nWebhook(documentId, platform, brand)
131
+ } catch (webhookErr) {
132
+ // Webhook failure: mark failed, but don't rethrow — continue to next post
133
+ const errMsg = webhookErr instanceof Error ? webhookErr.message : String(webhookErr)
134
+ await strapiPut('/api/social-posts', documentId, {
135
+ delivery_status: 'failed',
136
+ delivery_errors: [{ timestamp: new Date().toISOString(), error: errMsg }],
137
+ })
138
+ return { status: 'failed', error: errMsg }
139
+ }
140
+
141
+ // Step 3: Update delivery_status to 'scheduled' on success
142
+ await strapiPut('/api/social-posts', documentId, {
143
+ delivery_status: 'scheduled',
144
+ })
145
+
146
+ return { status: 'published' }
147
+ } catch (err) {
148
+ const errMsg = err instanceof Error ? err.message : String(err)
149
+ // Best-effort status update on unexpected errors
150
+ try {
151
+ await strapiPut('/api/social-posts', documentId, {
152
+ delivery_status: 'failed',
153
+ delivery_errors: [{ timestamp: new Date().toISOString(), error: errMsg }],
154
+ })
155
+ } catch {
156
+ // Ignore secondary failure — original error is more important
157
+ }
158
+ return { status: 'failed', error: errMsg }
159
+ }
160
+ }
161
+
162
+ // ── Core orchestrator ─────────────────────────────────────────────────
163
+
164
+ /**
165
+ * Fetch pending social posts for a brand and publish them:
166
+ * 1. Publish in Strapi (set publishedAt)
167
+ * 2. Trigger n8n webhook
168
+ * 3. Update delivery_status to 'scheduled' (or 'failed' on error)
169
+ *
170
+ * @example
171
+ * const result = await publishSocialPosts({ brand: 'LIFEINSUR', limit: 3 })
172
+ * console.log(`Published: ${result.published}, Failed: ${result.failed}`)
173
+ */
174
+ export async function publishSocialPosts(
175
+ opts: PublishOptions,
176
+ ): Promise<PublishResult> {
177
+ const { brand, limit, dryRun = false } = opts
178
+
179
+ // Validate n8n URL up front (unless dry run)
180
+ if (!dryRun) {
181
+ getN8nWebhookUrl()
182
+ }
183
+
184
+ const posts = await fetchPostsByStatus(brand, 'pending')
185
+ const postsToProcess = limit !== undefined ? posts.slice(0, limit) : posts
186
+
187
+ const result: PublishResult = {
188
+ published: 0,
189
+ failed: 0,
190
+ skipped: 0,
191
+ details: [],
192
+ }
193
+
194
+ for (const post of postsToProcess) {
195
+ const documentId = post.documentId
196
+ const headline = (post.headline as string | undefined) ?? '(no headline)'
197
+
198
+ const outcome = await processPost(post, dryRun)
199
+
200
+ if (outcome.status === 'published') result.published++
201
+ else if (outcome.status === 'failed') result.failed++
202
+ else result.skipped++
203
+
204
+ result.details.push({
205
+ documentId,
206
+ headline,
207
+ status: outcome.status,
208
+ ...(outcome.error !== undefined && { error: outcome.error }),
209
+ })
210
+ }
211
+
212
+ return result
213
+ }
214
+
215
+ // ── Publish queue ─────────────────────────────────────────────────────
216
+
217
+ /**
218
+ * List posts ready to publish: delivery_status = 'pending' AND has a scheduled_date.
219
+ *
220
+ * @example
221
+ * const queue = await getPublishQueue('LIFEINSUR')
222
+ * queue.forEach(p => console.log(p.scheduled_date, p.headline))
223
+ */
224
+ export async function getPublishQueue(brand: string): Promise<QueuedPost[]> {
225
+ const posts = await fetchPostsByStatus(brand, 'pending')
226
+
227
+ return posts
228
+ .filter((post) => {
229
+ const scheduledDate = post.scheduled_date as string | null | undefined
230
+ return scheduledDate != null && scheduledDate !== ''
231
+ })
232
+ .map((post) => ({
233
+ documentId: post.documentId,
234
+ headline: (post.headline as string | undefined) ?? '(no headline)',
235
+ platform: (post.platform as string | undefined) ?? 'unknown',
236
+ brand: (post.brand as string | undefined) ?? brand,
237
+ scheduled_date: post.scheduled_date as string,
238
+ }))
239
+ }
240
+
241
+ // ── Retry failed ──────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * Re-attempt publishing posts with delivery_status = 'failed'.
245
+ * Resets delivery_status to 'pending' on each post before re-processing.
246
+ *
247
+ * @example
248
+ * const result = await retryFailed('LIFEINSUR')
249
+ * console.log(`Re-published: ${result.published}, Still failing: ${result.failed}`)
250
+ */
251
+ export async function retryFailed(brand: string): Promise<PublishResult> {
252
+ // Validate n8n URL up front
253
+ getN8nWebhookUrl()
254
+
255
+ const posts = await fetchPostsByStatus(brand, 'failed')
256
+
257
+ const result: PublishResult = {
258
+ published: 0,
259
+ failed: 0,
260
+ skipped: 0,
261
+ details: [],
262
+ }
263
+
264
+ for (const post of posts) {
265
+ const documentId = post.documentId
266
+ const headline = (post.headline as string | undefined) ?? '(no headline)'
267
+
268
+ // Reset to pending so processPost can re-publish cleanly
269
+ try {
270
+ await strapiPut('/api/social-posts', documentId, {
271
+ delivery_status: 'pending',
272
+ delivery_errors: null,
273
+ })
274
+ } catch (err) {
275
+ const errMsg = err instanceof Error ? err.message : String(err)
276
+ result.failed++
277
+ result.details.push({
278
+ documentId,
279
+ headline,
280
+ status: 'failed',
281
+ error: `Could not reset delivery_status: ${errMsg}`,
282
+ })
283
+ continue
284
+ }
285
+
286
+ const outcome = await processPost(post, false)
287
+
288
+ if (outcome.status === 'published') result.published++
289
+ else if (outcome.status === 'failed') result.failed++
290
+ else result.skipped++
291
+
292
+ result.details.push({
293
+ documentId,
294
+ headline,
295
+ status: outcome.status,
296
+ ...(outcome.error !== undefined && { error: outcome.error }),
297
+ })
298
+ }
299
+
300
+ return result
301
+ }