optimal-cli 1.0.1 → 1.1.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 (185) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +10 -0
  3. package/.env.example +17 -0
  4. package/CLAUDE.md +67 -0
  5. package/COMMANDS.md +264 -0
  6. package/PUBLISH.md +70 -0
  7. package/agents/content-ops.md +2 -2
  8. package/agents/financial-ops.md +2 -2
  9. package/agents/infra-ops.md +2 -2
  10. package/apps/.gitkeep +0 -0
  11. package/bin/optimal.ts +1418 -0
  12. package/docs/MIGRATION_NEEDED.md +37 -0
  13. package/docs/plans/.gitkeep +0 -0
  14. package/docs/plans/optimal-cli-config-registry-v1.md +71 -0
  15. package/hooks/.gitkeep +0 -0
  16. package/lib/budget/projections.ts +561 -0
  17. package/lib/budget/scenarios.ts +312 -0
  18. package/lib/cms/publish-blog.ts +129 -0
  19. package/lib/cms/strapi-client.ts +302 -0
  20. package/lib/config/registry.ts +229 -0
  21. package/lib/config/schema.ts +58 -0
  22. package/lib/config.ts +247 -0
  23. package/lib/infra/.gitkeep +0 -0
  24. package/lib/infra/deploy.ts +70 -0
  25. package/lib/infra/migrate.ts +141 -0
  26. package/lib/kanban-obsidian.ts +232 -0
  27. package/lib/kanban-sync.ts +258 -0
  28. package/lib/kanban.ts +239 -0
  29. package/lib/newsletter/.gitkeep +0 -0
  30. package/lib/newsletter/distribute.ts +256 -0
  31. package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
  32. package/lib/newsletter/generate.ts +735 -0
  33. package/lib/obsidian-tasks.ts +231 -0
  34. package/lib/returnpro/.gitkeep +0 -0
  35. package/lib/returnpro/anomalies.ts +258 -0
  36. package/lib/returnpro/audit.ts +194 -0
  37. package/lib/returnpro/diagnose.ts +400 -0
  38. package/lib/returnpro/kpis.ts +255 -0
  39. package/lib/returnpro/templates.ts +323 -0
  40. package/lib/returnpro/upload-income.ts +311 -0
  41. package/lib/returnpro/upload-netsuite.ts +696 -0
  42. package/lib/returnpro/upload-r1.ts +563 -0
  43. package/lib/social/post-generator.ts +468 -0
  44. package/lib/social/publish.ts +301 -0
  45. package/lib/social/scraper.ts +503 -0
  46. package/lib/supabase.ts +25 -0
  47. package/lib/transactions/delete-batch.ts +258 -0
  48. package/lib/transactions/ingest.ts +659 -0
  49. package/lib/transactions/stamp.ts +654 -0
  50. package/package.json +5 -18
  51. package/pnpm-workspace.yaml +3 -0
  52. package/scripts/check-table.ts +24 -0
  53. package/scripts/create-tables.ts +94 -0
  54. package/scripts/migrate-kanban.sh +28 -0
  55. package/scripts/migrate-v2.ts +78 -0
  56. package/scripts/migrate.ts +79 -0
  57. package/scripts/run-migration.ts +59 -0
  58. package/scripts/seed-board.ts +203 -0
  59. package/scripts/test-kanban.ts +21 -0
  60. package/skills/audit-financials/SKILL.md +33 -0
  61. package/skills/board-create/SKILL.md +28 -0
  62. package/skills/board-update/SKILL.md +27 -0
  63. package/skills/board-view/SKILL.md +27 -0
  64. package/skills/delete-batch/SKILL.md +77 -0
  65. package/skills/deploy/SKILL.md +40 -0
  66. package/skills/diagnose-months/SKILL.md +68 -0
  67. package/skills/distribute-newsletter/SKILL.md +58 -0
  68. package/skills/export-budget/SKILL.md +44 -0
  69. package/skills/export-kpis/SKILL.md +52 -0
  70. package/skills/generate-netsuite-template/SKILL.md +51 -0
  71. package/skills/generate-newsletter/SKILL.md +53 -0
  72. package/skills/generate-newsletter-insurance/SKILL.md +59 -0
  73. package/skills/generate-social-posts/SKILL.md +67 -0
  74. package/skills/health-check/SKILL.md +42 -0
  75. package/skills/ingest-transactions/SKILL.md +51 -0
  76. package/skills/manage-cms/SKILL.md +50 -0
  77. package/skills/manage-scenarios/SKILL.md +83 -0
  78. package/skills/migrate-db/SKILL.md +79 -0
  79. package/skills/preview-newsletter/SKILL.md +50 -0
  80. package/skills/project-budget/SKILL.md +60 -0
  81. package/skills/publish-blog/SKILL.md +70 -0
  82. package/skills/publish-social-posts/SKILL.md +70 -0
  83. package/skills/rate-anomalies/SKILL.md +62 -0
  84. package/skills/scrape-ads/SKILL.md +49 -0
  85. package/skills/stamp-transactions/SKILL.md +62 -0
  86. package/skills/upload-income-statements/SKILL.md +54 -0
  87. package/skills/upload-netsuite/SKILL.md +56 -0
  88. package/skills/upload-r1/SKILL.md +45 -0
  89. package/supabase/.temp/cli-latest +1 -0
  90. package/supabase/migrations/.gitkeep +0 -0
  91. package/supabase/migrations/20250305000001_create_agent_configs.sql +36 -0
  92. package/supabase/migrations/20260305111300_create_cli_config_registry.sql +22 -0
  93. package/supabase/migrations/20260306195000_create_kanban_tables.sql +97 -0
  94. package/tests/config-command-smoke.test.ts +395 -0
  95. package/tests/config-registry.test.ts +173 -0
  96. package/tsconfig.json +19 -0
  97. package/agents/profiles.json +0 -5
  98. package/dist/bin/optimal.d.ts +0 -2
  99. package/dist/bin/optimal.js +0 -1590
  100. package/dist/lib/assets/index.d.ts +0 -79
  101. package/dist/lib/assets/index.js +0 -153
  102. package/dist/lib/assets.d.ts +0 -20
  103. package/dist/lib/assets.js +0 -112
  104. package/dist/lib/auth/index.d.ts +0 -83
  105. package/dist/lib/auth/index.js +0 -146
  106. package/dist/lib/board/index.d.ts +0 -39
  107. package/dist/lib/board/index.js +0 -285
  108. package/dist/lib/board/types.d.ts +0 -111
  109. package/dist/lib/board/types.js +0 -1
  110. package/dist/lib/bot/claim.d.ts +0 -3
  111. package/dist/lib/bot/claim.js +0 -20
  112. package/dist/lib/bot/coordinator.d.ts +0 -27
  113. package/dist/lib/bot/coordinator.js +0 -178
  114. package/dist/lib/bot/heartbeat.d.ts +0 -6
  115. package/dist/lib/bot/heartbeat.js +0 -30
  116. package/dist/lib/bot/index.d.ts +0 -9
  117. package/dist/lib/bot/index.js +0 -6
  118. package/dist/lib/bot/protocol.d.ts +0 -12
  119. package/dist/lib/bot/protocol.js +0 -74
  120. package/dist/lib/bot/reporter.d.ts +0 -3
  121. package/dist/lib/bot/reporter.js +0 -27
  122. package/dist/lib/bot/skills.d.ts +0 -26
  123. package/dist/lib/bot/skills.js +0 -69
  124. package/dist/lib/budget/projections.d.ts +0 -115
  125. package/dist/lib/budget/projections.js +0 -384
  126. package/dist/lib/budget/scenarios.d.ts +0 -93
  127. package/dist/lib/budget/scenarios.js +0 -214
  128. package/dist/lib/cms/publish-blog.d.ts +0 -62
  129. package/dist/lib/cms/publish-blog.js +0 -74
  130. package/dist/lib/cms/strapi-client.d.ts +0 -123
  131. package/dist/lib/cms/strapi-client.js +0 -213
  132. package/dist/lib/config/registry.d.ts +0 -17
  133. package/dist/lib/config/registry.js +0 -182
  134. package/dist/lib/config/schema.d.ts +0 -31
  135. package/dist/lib/config/schema.js +0 -25
  136. package/dist/lib/config.d.ts +0 -55
  137. package/dist/lib/config.js +0 -206
  138. package/dist/lib/errors.d.ts +0 -25
  139. package/dist/lib/errors.js +0 -91
  140. package/dist/lib/format.d.ts +0 -28
  141. package/dist/lib/format.js +0 -98
  142. package/dist/lib/infra/deploy.d.ts +0 -29
  143. package/dist/lib/infra/deploy.js +0 -58
  144. package/dist/lib/infra/migrate.d.ts +0 -34
  145. package/dist/lib/infra/migrate.js +0 -103
  146. package/dist/lib/newsletter/distribute.d.ts +0 -52
  147. package/dist/lib/newsletter/distribute.js +0 -193
  148. package/dist/lib/newsletter/generate-insurance.js +0 -36
  149. package/dist/lib/newsletter/generate.d.ts +0 -104
  150. package/dist/lib/newsletter/generate.js +0 -571
  151. package/dist/lib/returnpro/anomalies.d.ts +0 -64
  152. package/dist/lib/returnpro/anomalies.js +0 -166
  153. package/dist/lib/returnpro/audit.d.ts +0 -32
  154. package/dist/lib/returnpro/audit.js +0 -147
  155. package/dist/lib/returnpro/diagnose.d.ts +0 -52
  156. package/dist/lib/returnpro/diagnose.js +0 -281
  157. package/dist/lib/returnpro/kpis.d.ts +0 -32
  158. package/dist/lib/returnpro/kpis.js +0 -192
  159. package/dist/lib/returnpro/templates.d.ts +0 -48
  160. package/dist/lib/returnpro/templates.js +0 -229
  161. package/dist/lib/returnpro/upload-income.d.ts +0 -25
  162. package/dist/lib/returnpro/upload-income.js +0 -235
  163. package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
  164. package/dist/lib/returnpro/upload-netsuite.js +0 -566
  165. package/dist/lib/returnpro/upload-r1.d.ts +0 -48
  166. package/dist/lib/returnpro/upload-r1.js +0 -398
  167. package/dist/lib/returnpro/validate.d.ts +0 -37
  168. package/dist/lib/returnpro/validate.js +0 -124
  169. package/dist/lib/social/meta.d.ts +0 -90
  170. package/dist/lib/social/meta.js +0 -160
  171. package/dist/lib/social/post-generator.d.ts +0 -83
  172. package/dist/lib/social/post-generator.js +0 -333
  173. package/dist/lib/social/publish.d.ts +0 -66
  174. package/dist/lib/social/publish.js +0 -226
  175. package/dist/lib/social/scraper.d.ts +0 -67
  176. package/dist/lib/social/scraper.js +0 -361
  177. package/dist/lib/supabase.d.ts +0 -4
  178. package/dist/lib/supabase.js +0 -20
  179. package/dist/lib/transactions/delete-batch.d.ts +0 -60
  180. package/dist/lib/transactions/delete-batch.js +0 -203
  181. package/dist/lib/transactions/ingest.d.ts +0 -43
  182. package/dist/lib/transactions/ingest.js +0 -555
  183. package/dist/lib/transactions/stamp.d.ts +0 -51
  184. package/dist/lib/transactions/stamp.js +0 -524
  185. package/docs/CLI-REFERENCE.md +0 -361
@@ -0,0 +1,735 @@
1
+ /**
2
+ * Newsletter Generation Pipeline
3
+ *
4
+ * Ported from Python: ~/projects/newsletter-automation/generate-newsletter-v2.py
5
+ *
6
+ * Pipeline: Excel properties -> NewsAPI -> Groq AI -> HTML build -> Strapi push
7
+ *
8
+ * Functions:
9
+ * fetchNews() — fetch articles from NewsAPI
10
+ * generateAiContent() — call Groq (OpenAI-compatible) for AI summaries
11
+ * readExcelProperties() — parse columnar Excel (col A = labels, B-N = properties)
12
+ * buildPropertyCardHtml() — render a single property card
13
+ * buildNewsItemHtml() — render a single news item
14
+ * buildHtml() — assemble full newsletter HTML
15
+ * buildStrapiPayload() — build the structured Strapi newsletter payload
16
+ * generateNewsletter() — orchestrator that runs the full pipeline
17
+ */
18
+
19
+ import { strapiPost } from '../cms/strapi-client.js'
20
+
21
+ // ── Types ────────────────────────────────────────────────────────────
22
+
23
+ export interface Property {
24
+ name?: string
25
+ address?: string
26
+ price?: string | number
27
+ propertyType?: string
28
+ size?: string | number
29
+ region?: string
30
+ highlights?: string[] | string
31
+ imageUrl?: string
32
+ listingUrl?: string
33
+ contact?: string
34
+ notes?: string
35
+ analysis?: string
36
+ [key: string]: unknown
37
+ }
38
+
39
+ export interface NewsArticle {
40
+ title: string
41
+ source: string
42
+ date: string
43
+ description: string
44
+ url: string
45
+ }
46
+
47
+ export interface AiContent {
48
+ market_overview: string
49
+ property_analyses: Array<{ name: string; analysis: string }>
50
+ news_summaries: Array<{ title: string; analysis: string }>
51
+ }
52
+
53
+ export interface NewsletterPayload {
54
+ data: {
55
+ title: string
56
+ slug: string
57
+ brand: string
58
+ edition_date: string
59
+ subject_line: string
60
+ market_overview: string
61
+ featured_properties: Property[]
62
+ news_items: Array<NewsArticle & { analysis?: string }>
63
+ html_body: string
64
+ sender_email: string
65
+ }
66
+ }
67
+
68
+ export interface BrandConfig {
69
+ brand: string
70
+ displayName: string
71
+ newsQuery: string
72
+ senderEmail: string
73
+ contactEmail: string
74
+ titlePrefix: string
75
+ subjectPrefix: string
76
+ /** Whether this brand uses Excel property listings */
77
+ hasProperties: boolean
78
+ }
79
+
80
+ export interface GenerateOptions {
81
+ brand: string
82
+ date?: string
83
+ excelPath?: string
84
+ dryRun?: boolean
85
+ }
86
+
87
+ export interface GenerateResult {
88
+ properties: Property[]
89
+ newsArticles: NewsArticle[]
90
+ aiContent: AiContent | null
91
+ html: string
92
+ payload: NewsletterPayload
93
+ strapiDocumentId: string | null
94
+ }
95
+
96
+ // ── Brand Configs ────────────────────────────────────────────────────
97
+
98
+ const BRAND_CONFIGS: Record<string, BrandConfig> = {
99
+ 'CRE-11TRUST': {
100
+ brand: 'CRE-11TRUST',
101
+ displayName: 'ElevenTrust Commercial Real Estate',
102
+ newsQuery: process.env.NEWSAPI_QUERY ?? 'south florida commercial real estate',
103
+ senderEmail: 'newsletter@eleventrust.com',
104
+ contactEmail: 'contact@eleventrust.com',
105
+ titlePrefix: 'South Florida CRE Weekly',
106
+ subjectPrefix: 'South Florida CRE Weekly',
107
+ hasProperties: true,
108
+ },
109
+ 'LIFEINSUR': {
110
+ brand: 'LIFEINSUR',
111
+ displayName: 'Anchor Point Insurance Co.',
112
+ newsQuery: process.env.LIFEINSUR_NEWSAPI_QUERY ?? 'life insurance coverage policy florida texas alabama',
113
+ senderEmail: 'newsletter@anchorpointinsurance.com',
114
+ contactEmail: 'contact@anchorpointinsurance.com',
115
+ titlePrefix: 'Life Insurance Weekly',
116
+ subjectPrefix: 'Life Insurance Weekly',
117
+ hasProperties: false,
118
+ },
119
+ }
120
+
121
+ export function getBrandConfig(brand: string): BrandConfig {
122
+ const config = BRAND_CONFIGS[brand]
123
+ if (!config) {
124
+ throw new Error(`Unknown brand "${brand}". Valid brands: ${Object.keys(BRAND_CONFIGS).join(', ')}`)
125
+ }
126
+ return config
127
+ }
128
+
129
+ // ── Environment helpers ──────────────────────────────────────────────
130
+
131
+ function requireEnv(name: string): string {
132
+ const val = process.env[name]
133
+ if (!val) throw new Error(`Missing env var: ${name}`)
134
+ return val
135
+ }
136
+
137
+ // ── 1. Fetch News from NewsAPI ───────────────────────────────────────
138
+
139
+ export async function fetchNews(query?: string): Promise<NewsArticle[]> {
140
+ const apiKey = requireEnv('NEWSAPI_KEY')
141
+ const q = query ?? process.env.NEWSAPI_QUERY ?? 'south florida commercial real estate'
142
+
143
+ const params = new URLSearchParams({
144
+ q,
145
+ sortBy: 'publishedAt',
146
+ pageSize: '5',
147
+ apiKey,
148
+ })
149
+
150
+ const response = await fetch(`https://newsapi.org/v2/everything?${params}`)
151
+ const data = await response.json() as {
152
+ status: string
153
+ message?: string
154
+ articles?: Array<{
155
+ title: string
156
+ source: { name: string }
157
+ publishedAt: string
158
+ description: string | null
159
+ url: string
160
+ }>
161
+ }
162
+
163
+ if (data.status !== 'ok') {
164
+ console.error(`NewsAPI error: ${data.message ?? 'unknown'}`)
165
+ return []
166
+ }
167
+
168
+ return (data.articles ?? []).slice(0, 5).map((a) => ({
169
+ title: a.title,
170
+ source: a.source.name,
171
+ date: a.publishedAt.slice(0, 10),
172
+ description: a.description ?? '',
173
+ url: a.url,
174
+ }))
175
+ }
176
+
177
+ // ── 2. Generate AI Content via Groq ──────────────────────────────────
178
+
179
+ export async function generateAiContent(
180
+ properties: Property[],
181
+ newsArticles: NewsArticle[],
182
+ ): Promise<AiContent | null> {
183
+ const apiKey = requireEnv('GROQ_API_KEY')
184
+ const model = process.env.GROQ_MODEL ?? 'llama-3.3-70b-versatile'
185
+
186
+ const prompt = `You are a commercial real estate analyst writing brief, professional content for the ElevenTrust newsletter.
187
+
188
+ PROPERTIES (${properties.length} listings):
189
+ ${JSON.stringify(properties, null, 2)}
190
+
191
+ NEWS ARTICLES:
192
+ ${JSON.stringify(newsArticles, null, 2)}
193
+
194
+ Generate the following content in JSON format:
195
+
196
+ 1. "market_overview": 2-3 sentences about South Florida CRE market trends based on the news
197
+ 2. "property_analyses": Array of objects (one per property, in order) with:
198
+ - "name": property name
199
+ - "analysis": 2-3 sentences on why this property is attractive to investors
200
+ 3. "news_summaries": Array of objects (top 3 most relevant news) with:
201
+ - "title": article title
202
+ - "analysis": 1-2 sentences of CRE-focused analysis
203
+
204
+ Return ONLY valid JSON, no markdown:
205
+ {"market_overview": "...", "property_analyses": [{"name": "...", "analysis": "..."}, ...], "news_summaries": [{"title": "...", "analysis": "..."}, ...]}`
206
+
207
+ const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
208
+ method: 'POST',
209
+ headers: {
210
+ Authorization: `Bearer ${apiKey}`,
211
+ 'Content-Type': 'application/json',
212
+ },
213
+ body: JSON.stringify({
214
+ model,
215
+ messages: [{ role: 'user', content: prompt }],
216
+ max_tokens: 2000,
217
+ temperature: 0.7,
218
+ }),
219
+ })
220
+
221
+ const data = await response.json() as {
222
+ choices?: Array<{ message: { content: string } }>
223
+ error?: { message: string }
224
+ }
225
+
226
+ if (!data.choices) {
227
+ console.error(`Groq error: ${JSON.stringify(data.error ?? data)}`)
228
+ return null
229
+ }
230
+
231
+ let content = data.choices[0].message.content.trim()
232
+
233
+ // Strip markdown fences if present
234
+ if (content.startsWith('```')) {
235
+ const firstNewline = content.indexOf('\n')
236
+ content = firstNewline !== -1 ? content.slice(firstNewline + 1) : content.slice(3)
237
+ }
238
+ if (content.endsWith('```')) {
239
+ content = content.slice(0, -3)
240
+ }
241
+
242
+ try {
243
+ return JSON.parse(content.trim()) as AiContent
244
+ } catch (err) {
245
+ console.error(`Failed to parse AI response as JSON: ${(err as Error).message}`)
246
+ console.error(`Raw content: ${content.slice(0, 200)}`)
247
+ return null
248
+ }
249
+ }
250
+
251
+ // ── 3. Read Excel Properties (columnar format) ──────────────────────
252
+
253
+ /**
254
+ * Read multiple properties from columnar Excel format.
255
+ * Column A = field labels, Columns B-N = one property per column.
256
+ *
257
+ * Uses exceljs (dynamically imported to keep the dependency optional).
258
+ */
259
+ export async function readExcelProperties(filePath: string): Promise<Property[]> {
260
+ // Dynamic import so exceljs is only loaded when needed
261
+ const ExcelJS = await import('exceljs')
262
+ const workbook = new ExcelJS.Workbook()
263
+ await workbook.xlsx.readFile(filePath)
264
+
265
+ const ws = workbook.worksheets[0]
266
+ if (!ws) throw new Error(`No worksheets found in ${filePath}`)
267
+
268
+ // Ordered matchers: order matters for disambiguation
269
+ // "contact info" must match before "name" since contact label contains "name"
270
+ const fieldMatchers: Array<[string, keyof Property]> = [
271
+ ['region', 'region'],
272
+ ['property address', 'address'],
273
+ ['asking price', 'price'],
274
+ ['property type', 'propertyType'],
275
+ ['size', 'size'],
276
+ ['highlights', 'highlights'],
277
+ ['image link', 'imageUrl'],
278
+ ['listing', 'listingUrl'],
279
+ ['contact info', 'contact'],
280
+ ['special notes', 'notes'],
281
+ ['name', 'name'], // Last — most generic match
282
+ ]
283
+
284
+ // Map row numbers to field names from column A
285
+ const rowFields = new Map<number, keyof Property>()
286
+ const colCount = ws.columnCount
287
+
288
+ ws.getColumn(1).eachCell((cell, rowNumber) => {
289
+ if (cell.value != null) {
290
+ const label = String(cell.value).toLowerCase().trim()
291
+ for (const [searchTerm, fieldName] of fieldMatchers) {
292
+ if (label.includes(searchTerm)) {
293
+ rowFields.set(rowNumber, fieldName)
294
+ break
295
+ }
296
+ }
297
+ }
298
+ })
299
+
300
+ // Read each property column (B onwards, i.e., col index 2+)
301
+ const properties: Property[] = []
302
+
303
+ for (let colIdx = 2; colIdx <= colCount; colIdx++) {
304
+ const prop: Property = {}
305
+ let hasData = false
306
+
307
+ for (const [rowNum, fieldName] of rowFields) {
308
+ const cell = ws.getCell(rowNum, colIdx)
309
+ if (cell.value != null) {
310
+ prop[fieldName] = cell.value as string | number
311
+ hasData = true
312
+ }
313
+ }
314
+
315
+ if (!hasData) continue
316
+
317
+ // Format price
318
+ if (prop.price != null && typeof prop.price === 'number') {
319
+ prop.price = `$${prop.price.toLocaleString('en-US', { maximumFractionDigits: 0 })}`
320
+ }
321
+
322
+ // Parse highlights into list
323
+ if (typeof prop.highlights === 'string') {
324
+ const raw = prop.highlights
325
+ const items = raw
326
+ .replace(/\n/g, '|')
327
+ .split('|')
328
+ .map((h) => h.trim().replace(/^[-\u2022|]/, '').trim())
329
+ .filter(Boolean)
330
+ prop.highlights = items
331
+ } else if (!prop.highlights) {
332
+ prop.highlights = []
333
+ }
334
+
335
+ // Resolve listing URL
336
+ const url = prop.listingUrl
337
+ if (!url || (typeof url === 'string' && ['crexi', ''].includes(url.toLowerCase().trim()))) {
338
+ prop.listingUrl = 'https://www.crexi.com'
339
+ } else if (typeof url === 'string' && url.toLowerCase().trim() === 'property not online') {
340
+ prop.listingUrl = ''
341
+ }
342
+
343
+ properties.push(prop)
344
+ }
345
+
346
+ return properties
347
+ }
348
+
349
+ // ── 4. Build HTML ────────────────────────────────────────────────────
350
+
351
+ // Inline HTML templates — ported from the external .html template files
352
+ // to keep the CLI self-contained without needing template file dependencies.
353
+
354
+ function buildPropertyCardHtml(prop: Property, analysis: string): string {
355
+ // Property image
356
+ const imageUrl = prop.imageUrl
357
+ let imageHtml = ''
358
+ if (imageUrl && typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
359
+ imageHtml = `<tr>
360
+ <td style="padding: 0; line-height: 0;">
361
+ <img src="${imageUrl}" alt="${prop.name ?? 'Property'}" style="width: 100%; height: auto; display: block; max-height: 250px; object-fit: cover;" />
362
+ </td>
363
+ </tr>`
364
+ }
365
+
366
+ // Highlights
367
+ const highlights = Array.isArray(prop.highlights) ? prop.highlights : []
368
+ const highlightsHtml = highlights.length > 0
369
+ ? highlights.map((h) => `<li>${h}</li>`).join('\n')
370
+ : '<li>Contact agent for details</li>'
371
+
372
+ return `<!-- Property Card -->
373
+ <table role="presentation" style="width: 100%; border-collapse: collapse; border: 2px solid #d4a574; border-radius: 8px; overflow: hidden; margin-bottom: 25px;">
374
+ ${imageHtml}
375
+ <tr>
376
+ <td style="background-color: #faf9f7; padding: 25px;">
377
+ <span style="display: inline-block; background-color: #1a365d; color: #ffffff; padding: 5px 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; border-radius: 3px; margin-bottom: 15px;">
378
+ ${prop.propertyType ?? 'Commercial'}
379
+ </span>
380
+ <h3 style="margin: 10px 0 2px 0; color: #1a365d; font-size: 20px; font-weight: 700;">
381
+ ${prop.name ?? 'Unnamed Property'}
382
+ </h3>
383
+ <p style="margin: 0 0 3px 0; color: #1a365d; font-size: 24px; font-weight: 700;">
384
+ ${prop.price ?? 'Contact for Price'}
385
+ </p>
386
+ <p style="margin: 0 0 20px 0; color: #4a5568; font-size: 15px;">
387
+ ${prop.address ?? 'South Florida'}
388
+ </p>
389
+ <table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
390
+ <tr>
391
+ <td style="padding: 8px 0; border-bottom: 1px solid #e5e5e5;">
392
+ <span style="color: #718096; font-size: 13px;">Size</span><br>
393
+ <span style="color: #1a365d; font-size: 15px; font-weight: 600;">${String(prop.size ?? 'N/A')}</span>
394
+ </td>
395
+ <td style="padding: 8px 0; border-bottom: 1px solid #e5e5e5;">
396
+ <span style="color: #718096; font-size: 13px;">Market</span><br>
397
+ <span style="color: #1a365d; font-size: 15px; font-weight: 600;">${prop.region ?? 'South Florida'}</span>
398
+ </td>
399
+ </tr>
400
+ </table>
401
+ <h4 style="margin: 0 0 10px 0; color: #1a365d; font-size: 14px; font-weight: 600;">
402
+ Key Highlights
403
+ </h4>
404
+ <ul style="margin: 0 0 20px 0; padding-left: 20px; color: #4a5568; font-size: 14px; line-height: 1.8;">
405
+ ${highlightsHtml}
406
+ </ul>
407
+ <div style="background-color: #edf2f7; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
408
+ <p style="margin: 0; color: #4a5568; font-size: 14px; line-height: 1.6; font-style: italic;">
409
+ <strong style="color: #1a365d;">Analysis:</strong> ${analysis}
410
+ </p>
411
+ </div>
412
+ <a href="${prop.listingUrl ?? '#'}" target="_blank" style="display: inline-block; background-color: #d4a574; color: #1a365d; padding: 12px 24px; font-size: 13px; font-weight: 600; text-decoration: none; border-radius: 5px; text-transform: uppercase; letter-spacing: 1px;">
413
+ View Listing &rarr;
414
+ </a>
415
+ <p style="margin: 15px 0 0 0; color: #718096; font-size: 13px;">
416
+ <strong>Contact:</strong> ${prop.contact ?? 'Contact agent for details'}
417
+ </p>
418
+ </td>
419
+ </tr>
420
+ </table>`
421
+ }
422
+
423
+ function buildNewsItemHtml(article: NewsArticle, analysis: string): string {
424
+ return `<!-- News Item -->
425
+ <table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
426
+ <tr>
427
+ <td style="background-color: #ffffff; padding: 20px; border-left: 4px solid #d4a574;">
428
+ <p style="margin: 0 0 5px 0; color: #718096; font-size: 12px; text-transform: uppercase;">
429
+ ${article.source} &bull; ${article.date}
430
+ </p>
431
+ <h4 style="margin: 0 0 8px 0; color: #1a365d; font-size: 15px; font-weight: 600;">
432
+ <a href="${article.url}" style="color: #1a365d; text-decoration: none;">
433
+ ${article.title}
434
+ </a>
435
+ </h4>
436
+ <p style="margin: 0; color: #4a5568; font-size: 14px; line-height: 1.5;">
437
+ ${analysis}
438
+ </p>
439
+ </td>
440
+ </tr>
441
+ </table>`
442
+ }
443
+
444
+ export function buildHtml(
445
+ properties: Property[],
446
+ newsArticles: NewsArticle[],
447
+ aiContent: AiContent | null,
448
+ brandConfig: BrandConfig,
449
+ ): string {
450
+ const propertyAnalyses = aiContent?.property_analyses ?? []
451
+ const newsSummaries = aiContent?.news_summaries ?? []
452
+
453
+ // Build property cards
454
+ const cardsHtml = properties.map((prop, i) => {
455
+ const analysis = i < propertyAnalyses.length
456
+ ? propertyAnalyses[i].analysis
457
+ : 'Prime investment opportunity in a growing market.'
458
+ return buildPropertyCardHtml(prop, analysis)
459
+ }).join('\n')
460
+
461
+ // Build news items (top 3)
462
+ const newsHtml = newsArticles.slice(0, 3).map((article, i) => {
463
+ const analysis = i < newsSummaries.length
464
+ ? newsSummaries[i].analysis
465
+ : `${article.description.slice(0, 120)}...`
466
+ return buildNewsItemHtml(article, analysis)
467
+ }).join('\n')
468
+
469
+ const now = new Date()
470
+ const dateStr = now.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
471
+ const year = String(now.getFullYear())
472
+ const marketOverview = aiContent?.market_overview
473
+ ?? 'South Florida commercial real estate continues to attract investor interest.'
474
+
475
+ // Determine brand-specific styling
476
+ const isCRE = brandConfig.brand === 'CRE-11TRUST'
477
+ const headerBg = isCRE ? '#1a365d' : '#44403E'
478
+ const accentColor = isCRE ? '#d4a574' : '#C8A97E'
479
+ const headingColor = isCRE ? '#1a365d' : '#44403E'
480
+ const sectionBg = isCRE ? '#f8f9fa' : '#FAF9F6'
481
+ const footerBg = isCRE ? '#2d3748' : '#2d2926'
482
+
483
+ // Properties section (only for CRE brand)
484
+ const propertiesSection = brandConfig.hasProperties && properties.length > 0 ? `
485
+ <!-- Featured Properties -->
486
+ <tr>
487
+ <td style="padding: 30px 40px;">
488
+ <h2 style="margin: 0 0 20px 0; color: ${headingColor}; font-size: 18px; font-weight: 600;">
489
+ Featured Properties (${properties.length})
490
+ </h2>
491
+ ${cardsHtml}
492
+ </td>
493
+ </tr>` : ''
494
+
495
+ // CTA copy varies by brand
496
+ const ctaHeadline = isCRE
497
+ ? 'Looking to Buy or Sell in South Florida?'
498
+ : 'Protect What Matters Most'
499
+ const ctaBody = isCRE
500
+ ? 'Get expert guidance on your next commercial real estate transaction.'
501
+ : 'Get a free, no-obligation life insurance quote in minutes.'
502
+ const ctaButton = isCRE ? 'Contact Us Today' : 'Get Your Free Quote'
503
+
504
+ return `<!DOCTYPE html>
505
+ <html lang="en">
506
+ <head>
507
+ <meta charset="UTF-8">
508
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
509
+ <title>${brandConfig.titlePrefix} - ${dateStr}</title>
510
+ </head>
511
+ <body style="margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f5f5f5;">
512
+ <table role="presentation" style="width: 100%; border-collapse: collapse;">
513
+ <tr>
514
+ <td align="center" style="padding: 20px 10px;">
515
+ <table role="presentation" style="max-width: 600px; width: 100%; background-color: #ffffff; border-collapse: collapse; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
516
+
517
+ <!-- Header -->
518
+ <tr>
519
+ <td style="background-color: ${headerBg}; padding: 30px 40px; text-align: center;">
520
+ <h1 style="margin: 0; color: #ffffff; font-size: 26px; font-weight: 600; letter-spacing: 1px;">
521
+ ${brandConfig.titlePrefix}
522
+ </h1>
523
+ <p style="margin: 8px 0 0 0; color: ${accentColor}; font-size: 13px; text-transform: uppercase; letter-spacing: 2px;">
524
+ ${brandConfig.displayName}
525
+ </p>
526
+ <p style="margin: 8px 0 0 0; color: #a0aec0; font-size: 12px;">
527
+ ${dateStr}
528
+ </p>
529
+ </td>
530
+ </tr>
531
+
532
+ <!-- Market Overview -->
533
+ <tr>
534
+ <td style="padding: 30px 40px; border-bottom: 1px solid #e5e5e5;">
535
+ <h2 style="margin: 0 0 15px 0; color: ${headingColor}; font-size: 18px; font-weight: 600;">
536
+ Market Overview
537
+ </h2>
538
+ <p style="margin: 0; color: #4a5568; font-size: 15px; line-height: 1.6;">
539
+ ${marketOverview}
540
+ </p>
541
+ </td>
542
+ </tr>
543
+
544
+ <!-- News -->
545
+ <tr>
546
+ <td style="padding: 30px 40px; background-color: ${sectionBg};">
547
+ <h2 style="margin: 0 0 20px 0; color: ${headingColor}; font-size: 18px; font-weight: 600;">
548
+ ${isCRE ? 'Market News & Analysis' : 'Industry News & Analysis'}
549
+ </h2>
550
+ ${newsHtml}
551
+ </td>
552
+ </tr>
553
+ ${propertiesSection}
554
+
555
+ <!-- CTA -->
556
+ <tr>
557
+ <td style="padding: 40px; background-color: ${headerBg}; text-align: center;">
558
+ <h3 style="margin: 0 0 10px 0; color: #ffffff; font-size: 20px; font-weight: 600;">
559
+ ${ctaHeadline}
560
+ </h3>
561
+ <p style="margin: 0 0 25px 0; color: #a0aec0; font-size: 15px;">
562
+ ${ctaBody}
563
+ </p>
564
+ <a href="mailto:${brandConfig.contactEmail}" style="display: inline-block; background-color: ${accentColor}; color: ${headerBg}; padding: 14px 35px; font-size: 14px; font-weight: 600; text-decoration: none; border-radius: 5px; text-transform: uppercase; letter-spacing: 1px;">
565
+ ${ctaButton}
566
+ </a>
567
+ </td>
568
+ </tr>
569
+
570
+ <!-- Footer -->
571
+ <tr>
572
+ <td style="padding: 25px 40px; background-color: ${footerBg}; text-align: center;">
573
+ <p style="margin: 0 0 10px 0; color: #a0aec0; font-size: 13px;">
574
+ &copy; ${year} ${brandConfig.displayName}. All rights reserved.
575
+ </p>
576
+ <p style="margin: 0; color: #718096; font-size: 12px;">
577
+ <a href="#unsubscribe" style="color: #718096; text-decoration: underline;">Unsubscribe</a> |
578
+ <a href="#web-version" style="color: #718096; text-decoration: underline;">View in Browser</a>
579
+ </p>
580
+ </td>
581
+ </tr>
582
+
583
+ </table>
584
+ </td>
585
+ </tr>
586
+ </table>
587
+ </body>
588
+ </html>`
589
+ }
590
+
591
+ // ── 5. Build Strapi Payload ──────────────────────────────────────────
592
+
593
+ export function buildStrapiPayload(
594
+ properties: Property[],
595
+ newsArticles: NewsArticle[],
596
+ aiContent: AiContent | null,
597
+ html: string,
598
+ brandConfig: BrandConfig,
599
+ editionDate?: string,
600
+ ): NewsletterPayload {
601
+ const now = new Date()
602
+ const today = editionDate ?? now.toISOString().slice(0, 10)
603
+ const dateDisplay = now.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
604
+ const dateShort = now.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
605
+ const timestamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 15)
606
+
607
+ const propertyAnalyses = aiContent?.property_analyses ?? []
608
+ const newsSummaries = aiContent?.news_summaries ?? []
609
+
610
+ // Merge AI analyses into property objects
611
+ const featured: Property[] = properties.map((prop, i) => ({
612
+ ...prop,
613
+ ...(i < propertyAnalyses.length ? { analysis: propertyAnalyses[i].analysis } : {}),
614
+ }))
615
+
616
+ // Build news items with AI summaries
617
+ const news = newsArticles.slice(0, 3).map((article, i) => ({
618
+ ...article,
619
+ ...(i < newsSummaries.length ? { analysis: newsSummaries[i].analysis } : {}),
620
+ }))
621
+
622
+ const marketOverview = aiContent?.market_overview ?? ''
623
+
624
+ // Slug includes timestamp for uniqueness (same-day reruns)
625
+ const slugBrand = brandConfig.brand.toLowerCase().replace(/-/g, '')
626
+ const subjectLine = brandConfig.hasProperties
627
+ ? `${brandConfig.subjectPrefix}: ${properties.length} New Listings - ${dateShort}`
628
+ : `${brandConfig.subjectPrefix}: ${dateShort}`
629
+
630
+ return {
631
+ data: {
632
+ title: `${brandConfig.titlePrefix} - ${dateDisplay}`,
633
+ slug: `${slugBrand}-weekly-${timestamp}`,
634
+ brand: brandConfig.brand,
635
+ edition_date: today,
636
+ subject_line: subjectLine,
637
+ market_overview: marketOverview,
638
+ featured_properties: featured,
639
+ news_items: news,
640
+ html_body: html,
641
+ sender_email: brandConfig.senderEmail,
642
+ },
643
+ }
644
+ }
645
+
646
+ // ── 6. Push to Strapi ────────────────────────────────────────────────
647
+
648
+ async function pushToStrapi(payload: NewsletterPayload): Promise<string | null> {
649
+ try {
650
+ const item = await strapiPost('/api/newsletters', payload.data)
651
+ const docId = item.documentId ?? 'unknown'
652
+ console.log(` Pushed to Strapi as DRAFT (documentId: ${docId})`)
653
+ return docId
654
+ } catch (err) {
655
+ const msg = err instanceof Error ? err.message : String(err)
656
+ console.error(` Strapi error: ${msg}`)
657
+ return null
658
+ }
659
+ }
660
+
661
+ // ── 7. Orchestrator ──────────────────────────────────────────────────
662
+
663
+ export async function generateNewsletter(opts: GenerateOptions): Promise<GenerateResult> {
664
+ const brandConfig = getBrandConfig(opts.brand)
665
+
666
+ console.log('='.repeat(60))
667
+ console.log(`NEWSLETTER GENERATOR — ${brandConfig.displayName}`)
668
+ console.log('='.repeat(60))
669
+
670
+ // Step 1: Read properties from Excel (if applicable)
671
+ let properties: Property[] = []
672
+ if (brandConfig.hasProperties && opts.excelPath) {
673
+ console.log(`\n1. Reading properties from: ${opts.excelPath}`)
674
+ properties = await readExcelProperties(opts.excelPath)
675
+ console.log(` Found ${properties.length} properties:`)
676
+ for (const p of properties) {
677
+ console.log(` - ${p.name ?? 'Unnamed'}: ${p.price ?? 'N/A'} (${p.region ?? '?'})`)
678
+ }
679
+ } else if (brandConfig.hasProperties) {
680
+ console.log('\n1. No Excel file provided — skipping property extraction')
681
+ } else {
682
+ console.log(`\n1. Brand ${brandConfig.brand} does not use property listings — skipping`)
683
+ }
684
+
685
+ // Step 2: Image extraction skipped (EMF/WMF conversion too complex for CLI)
686
+ console.log('\n2. Image extraction — skipped (use Python pipeline for embedded images)')
687
+
688
+ // Step 3: Fetch news
689
+ console.log(`\n3. Fetching news for: ${brandConfig.newsQuery}`)
690
+ const newsArticles = await fetchNews(brandConfig.newsQuery)
691
+ console.log(` Found ${newsArticles.length} articles`)
692
+
693
+ // Step 4: Generate AI content
694
+ const model = process.env.GROQ_MODEL ?? 'llama-3.3-70b-versatile'
695
+ console.log(`\n4. Generating AI content via Groq (${model})`)
696
+ const aiContent = await generateAiContent(properties, newsArticles)
697
+ if (aiContent) {
698
+ console.log(' Generated: market overview, property analyses, news summaries')
699
+ } else {
700
+ console.log(' WARNING: AI generation failed, using fallback content')
701
+ }
702
+
703
+ // Step 5: Build HTML
704
+ console.log('\n5. Building newsletter HTML')
705
+ const html = buildHtml(properties, newsArticles, aiContent, brandConfig)
706
+ console.log(` HTML length: ${html.length} chars`)
707
+
708
+ // Step 6: Build Strapi payload
709
+ const payload = buildStrapiPayload(properties, newsArticles, aiContent, html, brandConfig, opts.date)
710
+ console.log(` Payload: "${payload.data.title}" (slug: ${payload.data.slug})`)
711
+
712
+ // Step 7: Push to Strapi (unless dry-run)
713
+ let strapiDocumentId: string | null = null
714
+ if (opts.dryRun) {
715
+ console.log('\n6. DRY RUN — skipping Strapi push')
716
+ console.log(` Would create: "${payload.data.title}"`)
717
+ console.log(` Subject line: "${payload.data.subject_line}"`)
718
+ } else {
719
+ console.log('\n6. Pushing to Strapi CMS')
720
+ strapiDocumentId = await pushToStrapi(payload)
721
+ }
722
+
723
+ console.log('\n' + '='.repeat(60))
724
+ console.log('DONE!')
725
+ console.log('='.repeat(60))
726
+
727
+ return {
728
+ properties,
729
+ newsArticles,
730
+ aiContent,
731
+ html,
732
+ payload,
733
+ strapiDocumentId,
734
+ }
735
+ }