optimal-cli 0.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 (56) hide show
  1. package/README.md +175 -0
  2. package/dist/bin/optimal.d.ts +2 -0
  3. package/dist/bin/optimal.js +995 -0
  4. package/dist/lib/budget/projections.d.ts +115 -0
  5. package/dist/lib/budget/projections.js +384 -0
  6. package/dist/lib/budget/scenarios.d.ts +93 -0
  7. package/dist/lib/budget/scenarios.js +214 -0
  8. package/dist/lib/cms/publish-blog.d.ts +62 -0
  9. package/dist/lib/cms/publish-blog.js +74 -0
  10. package/dist/lib/cms/strapi-client.d.ts +123 -0
  11. package/dist/lib/cms/strapi-client.js +213 -0
  12. package/dist/lib/config.d.ts +55 -0
  13. package/dist/lib/config.js +206 -0
  14. package/dist/lib/infra/deploy.d.ts +29 -0
  15. package/dist/lib/infra/deploy.js +58 -0
  16. package/dist/lib/infra/migrate.d.ts +34 -0
  17. package/dist/lib/infra/migrate.js +103 -0
  18. package/dist/lib/kanban.d.ts +46 -0
  19. package/dist/lib/kanban.js +118 -0
  20. package/dist/lib/newsletter/distribute.d.ts +52 -0
  21. package/dist/lib/newsletter/distribute.js +193 -0
  22. package/dist/lib/newsletter/generate-insurance.d.ts +42 -0
  23. package/dist/lib/newsletter/generate-insurance.js +36 -0
  24. package/dist/lib/newsletter/generate.d.ts +104 -0
  25. package/dist/lib/newsletter/generate.js +571 -0
  26. package/dist/lib/returnpro/anomalies.d.ts +64 -0
  27. package/dist/lib/returnpro/anomalies.js +166 -0
  28. package/dist/lib/returnpro/audit.d.ts +32 -0
  29. package/dist/lib/returnpro/audit.js +147 -0
  30. package/dist/lib/returnpro/diagnose.d.ts +52 -0
  31. package/dist/lib/returnpro/diagnose.js +281 -0
  32. package/dist/lib/returnpro/kpis.d.ts +32 -0
  33. package/dist/lib/returnpro/kpis.js +192 -0
  34. package/dist/lib/returnpro/templates.d.ts +48 -0
  35. package/dist/lib/returnpro/templates.js +229 -0
  36. package/dist/lib/returnpro/upload-income.d.ts +25 -0
  37. package/dist/lib/returnpro/upload-income.js +235 -0
  38. package/dist/lib/returnpro/upload-netsuite.d.ts +37 -0
  39. package/dist/lib/returnpro/upload-netsuite.js +566 -0
  40. package/dist/lib/returnpro/upload-r1.d.ts +48 -0
  41. package/dist/lib/returnpro/upload-r1.js +398 -0
  42. package/dist/lib/social/post-generator.d.ts +83 -0
  43. package/dist/lib/social/post-generator.js +333 -0
  44. package/dist/lib/social/publish.d.ts +66 -0
  45. package/dist/lib/social/publish.js +226 -0
  46. package/dist/lib/social/scraper.d.ts +67 -0
  47. package/dist/lib/social/scraper.js +361 -0
  48. package/dist/lib/supabase.d.ts +4 -0
  49. package/dist/lib/supabase.js +20 -0
  50. package/dist/lib/transactions/delete-batch.d.ts +60 -0
  51. package/dist/lib/transactions/delete-batch.js +203 -0
  52. package/dist/lib/transactions/ingest.d.ts +43 -0
  53. package/dist/lib/transactions/ingest.js +555 -0
  54. package/dist/lib/transactions/stamp.d.ts +51 -0
  55. package/dist/lib/transactions/stamp.js +524 -0
  56. package/package.json +50 -0
@@ -0,0 +1,571 @@
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
+ import { strapiPost } from '../cms/strapi-client.js';
19
+ // ── Brand Configs ────────────────────────────────────────────────────
20
+ const BRAND_CONFIGS = {
21
+ 'CRE-11TRUST': {
22
+ brand: 'CRE-11TRUST',
23
+ displayName: 'ElevenTrust Commercial Real Estate',
24
+ newsQuery: process.env.NEWSAPI_QUERY ?? 'south florida commercial real estate',
25
+ senderEmail: 'newsletter@eleventrust.com',
26
+ contactEmail: 'contact@eleventrust.com',
27
+ titlePrefix: 'South Florida CRE Weekly',
28
+ subjectPrefix: 'South Florida CRE Weekly',
29
+ hasProperties: true,
30
+ },
31
+ 'LIFEINSUR': {
32
+ brand: 'LIFEINSUR',
33
+ displayName: 'Anchor Point Insurance Co.',
34
+ newsQuery: process.env.LIFEINSUR_NEWSAPI_QUERY ?? 'life insurance coverage policy florida texas alabama',
35
+ senderEmail: 'newsletter@anchorpointinsurance.com',
36
+ contactEmail: 'contact@anchorpointinsurance.com',
37
+ titlePrefix: 'Life Insurance Weekly',
38
+ subjectPrefix: 'Life Insurance Weekly',
39
+ hasProperties: false,
40
+ },
41
+ };
42
+ export function getBrandConfig(brand) {
43
+ const config = BRAND_CONFIGS[brand];
44
+ if (!config) {
45
+ throw new Error(`Unknown brand "${brand}". Valid brands: ${Object.keys(BRAND_CONFIGS).join(', ')}`);
46
+ }
47
+ return config;
48
+ }
49
+ // ── Environment helpers ──────────────────────────────────────────────
50
+ function requireEnv(name) {
51
+ const val = process.env[name];
52
+ if (!val)
53
+ throw new Error(`Missing env var: ${name}`);
54
+ return val;
55
+ }
56
+ // ── 1. Fetch News from NewsAPI ───────────────────────────────────────
57
+ export async function fetchNews(query) {
58
+ const apiKey = requireEnv('NEWSAPI_KEY');
59
+ const q = query ?? process.env.NEWSAPI_QUERY ?? 'south florida commercial real estate';
60
+ const params = new URLSearchParams({
61
+ q,
62
+ sortBy: 'publishedAt',
63
+ pageSize: '5',
64
+ apiKey,
65
+ });
66
+ const response = await fetch(`https://newsapi.org/v2/everything?${params}`);
67
+ const data = await response.json();
68
+ if (data.status !== 'ok') {
69
+ console.error(`NewsAPI error: ${data.message ?? 'unknown'}`);
70
+ return [];
71
+ }
72
+ return (data.articles ?? []).slice(0, 5).map((a) => ({
73
+ title: a.title,
74
+ source: a.source.name,
75
+ date: a.publishedAt.slice(0, 10),
76
+ description: a.description ?? '',
77
+ url: a.url,
78
+ }));
79
+ }
80
+ // ── 2. Generate AI Content via Groq ──────────────────────────────────
81
+ export async function generateAiContent(properties, newsArticles) {
82
+ const apiKey = requireEnv('GROQ_API_KEY');
83
+ const model = process.env.GROQ_MODEL ?? 'llama-3.3-70b-versatile';
84
+ const prompt = `You are a commercial real estate analyst writing brief, professional content for the ElevenTrust newsletter.
85
+
86
+ PROPERTIES (${properties.length} listings):
87
+ ${JSON.stringify(properties, null, 2)}
88
+
89
+ NEWS ARTICLES:
90
+ ${JSON.stringify(newsArticles, null, 2)}
91
+
92
+ Generate the following content in JSON format:
93
+
94
+ 1. "market_overview": 2-3 sentences about South Florida CRE market trends based on the news
95
+ 2. "property_analyses": Array of objects (one per property, in order) with:
96
+ - "name": property name
97
+ - "analysis": 2-3 sentences on why this property is attractive to investors
98
+ 3. "news_summaries": Array of objects (top 3 most relevant news) with:
99
+ - "title": article title
100
+ - "analysis": 1-2 sentences of CRE-focused analysis
101
+
102
+ Return ONLY valid JSON, no markdown:
103
+ {"market_overview": "...", "property_analyses": [{"name": "...", "analysis": "..."}, ...], "news_summaries": [{"title": "...", "analysis": "..."}, ...]}`;
104
+ const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
105
+ method: 'POST',
106
+ headers: {
107
+ Authorization: `Bearer ${apiKey}`,
108
+ 'Content-Type': 'application/json',
109
+ },
110
+ body: JSON.stringify({
111
+ model,
112
+ messages: [{ role: 'user', content: prompt }],
113
+ max_tokens: 2000,
114
+ temperature: 0.7,
115
+ }),
116
+ });
117
+ const data = await response.json();
118
+ if (!data.choices) {
119
+ console.error(`Groq error: ${JSON.stringify(data.error ?? data)}`);
120
+ return null;
121
+ }
122
+ let content = data.choices[0].message.content.trim();
123
+ // Strip markdown fences if present
124
+ if (content.startsWith('```')) {
125
+ const firstNewline = content.indexOf('\n');
126
+ content = firstNewline !== -1 ? content.slice(firstNewline + 1) : content.slice(3);
127
+ }
128
+ if (content.endsWith('```')) {
129
+ content = content.slice(0, -3);
130
+ }
131
+ try {
132
+ return JSON.parse(content.trim());
133
+ }
134
+ catch (err) {
135
+ console.error(`Failed to parse AI response as JSON: ${err.message}`);
136
+ console.error(`Raw content: ${content.slice(0, 200)}`);
137
+ return null;
138
+ }
139
+ }
140
+ // ── 3. Read Excel Properties (columnar format) ──────────────────────
141
+ /**
142
+ * Read multiple properties from columnar Excel format.
143
+ * Column A = field labels, Columns B-N = one property per column.
144
+ *
145
+ * Uses exceljs (dynamically imported to keep the dependency optional).
146
+ */
147
+ export async function readExcelProperties(filePath) {
148
+ // Dynamic import so exceljs is only loaded when needed
149
+ const ExcelJS = await import('exceljs');
150
+ const workbook = new ExcelJS.Workbook();
151
+ await workbook.xlsx.readFile(filePath);
152
+ const ws = workbook.worksheets[0];
153
+ if (!ws)
154
+ throw new Error(`No worksheets found in ${filePath}`);
155
+ // Ordered matchers: order matters for disambiguation
156
+ // "contact info" must match before "name" since contact label contains "name"
157
+ const fieldMatchers = [
158
+ ['region', 'region'],
159
+ ['property address', 'address'],
160
+ ['asking price', 'price'],
161
+ ['property type', 'propertyType'],
162
+ ['size', 'size'],
163
+ ['highlights', 'highlights'],
164
+ ['image link', 'imageUrl'],
165
+ ['listing', 'listingUrl'],
166
+ ['contact info', 'contact'],
167
+ ['special notes', 'notes'],
168
+ ['name', 'name'], // Last — most generic match
169
+ ];
170
+ // Map row numbers to field names from column A
171
+ const rowFields = new Map();
172
+ const colCount = ws.columnCount;
173
+ ws.getColumn(1).eachCell((cell, rowNumber) => {
174
+ if (cell.value != null) {
175
+ const label = String(cell.value).toLowerCase().trim();
176
+ for (const [searchTerm, fieldName] of fieldMatchers) {
177
+ if (label.includes(searchTerm)) {
178
+ rowFields.set(rowNumber, fieldName);
179
+ break;
180
+ }
181
+ }
182
+ }
183
+ });
184
+ // Read each property column (B onwards, i.e., col index 2+)
185
+ const properties = [];
186
+ for (let colIdx = 2; colIdx <= colCount; colIdx++) {
187
+ const prop = {};
188
+ let hasData = false;
189
+ for (const [rowNum, fieldName] of rowFields) {
190
+ const cell = ws.getCell(rowNum, colIdx);
191
+ if (cell.value != null) {
192
+ prop[fieldName] = cell.value;
193
+ hasData = true;
194
+ }
195
+ }
196
+ if (!hasData)
197
+ continue;
198
+ // Format price
199
+ if (prop.price != null && typeof prop.price === 'number') {
200
+ prop.price = `$${prop.price.toLocaleString('en-US', { maximumFractionDigits: 0 })}`;
201
+ }
202
+ // Parse highlights into list
203
+ if (typeof prop.highlights === 'string') {
204
+ const raw = prop.highlights;
205
+ const items = raw
206
+ .replace(/\n/g, '|')
207
+ .split('|')
208
+ .map((h) => h.trim().replace(/^[-\u2022|]/, '').trim())
209
+ .filter(Boolean);
210
+ prop.highlights = items;
211
+ }
212
+ else if (!prop.highlights) {
213
+ prop.highlights = [];
214
+ }
215
+ // Resolve listing URL
216
+ const url = prop.listingUrl;
217
+ if (!url || (typeof url === 'string' && ['crexi', ''].includes(url.toLowerCase().trim()))) {
218
+ prop.listingUrl = 'https://www.crexi.com';
219
+ }
220
+ else if (typeof url === 'string' && url.toLowerCase().trim() === 'property not online') {
221
+ prop.listingUrl = '';
222
+ }
223
+ properties.push(prop);
224
+ }
225
+ return properties;
226
+ }
227
+ // ── 4. Build HTML ────────────────────────────────────────────────────
228
+ // Inline HTML templates — ported from the external .html template files
229
+ // to keep the CLI self-contained without needing template file dependencies.
230
+ function buildPropertyCardHtml(prop, analysis) {
231
+ // Property image
232
+ const imageUrl = prop.imageUrl;
233
+ let imageHtml = '';
234
+ if (imageUrl && typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
235
+ imageHtml = `<tr>
236
+ <td style="padding: 0; line-height: 0;">
237
+ <img src="${imageUrl}" alt="${prop.name ?? 'Property'}" style="width: 100%; height: auto; display: block; max-height: 250px; object-fit: cover;" />
238
+ </td>
239
+ </tr>`;
240
+ }
241
+ // Highlights
242
+ const highlights = Array.isArray(prop.highlights) ? prop.highlights : [];
243
+ const highlightsHtml = highlights.length > 0
244
+ ? highlights.map((h) => `<li>${h}</li>`).join('\n')
245
+ : '<li>Contact agent for details</li>';
246
+ return `<!-- Property Card -->
247
+ <table role="presentation" style="width: 100%; border-collapse: collapse; border: 2px solid #d4a574; border-radius: 8px; overflow: hidden; margin-bottom: 25px;">
248
+ ${imageHtml}
249
+ <tr>
250
+ <td style="background-color: #faf9f7; padding: 25px;">
251
+ <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;">
252
+ ${prop.propertyType ?? 'Commercial'}
253
+ </span>
254
+ <h3 style="margin: 10px 0 2px 0; color: #1a365d; font-size: 20px; font-weight: 700;">
255
+ ${prop.name ?? 'Unnamed Property'}
256
+ </h3>
257
+ <p style="margin: 0 0 3px 0; color: #1a365d; font-size: 24px; font-weight: 700;">
258
+ ${prop.price ?? 'Contact for Price'}
259
+ </p>
260
+ <p style="margin: 0 0 20px 0; color: #4a5568; font-size: 15px;">
261
+ ${prop.address ?? 'South Florida'}
262
+ </p>
263
+ <table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
264
+ <tr>
265
+ <td style="padding: 8px 0; border-bottom: 1px solid #e5e5e5;">
266
+ <span style="color: #718096; font-size: 13px;">Size</span><br>
267
+ <span style="color: #1a365d; font-size: 15px; font-weight: 600;">${String(prop.size ?? 'N/A')}</span>
268
+ </td>
269
+ <td style="padding: 8px 0; border-bottom: 1px solid #e5e5e5;">
270
+ <span style="color: #718096; font-size: 13px;">Market</span><br>
271
+ <span style="color: #1a365d; font-size: 15px; font-weight: 600;">${prop.region ?? 'South Florida'}</span>
272
+ </td>
273
+ </tr>
274
+ </table>
275
+ <h4 style="margin: 0 0 10px 0; color: #1a365d; font-size: 14px; font-weight: 600;">
276
+ Key Highlights
277
+ </h4>
278
+ <ul style="margin: 0 0 20px 0; padding-left: 20px; color: #4a5568; font-size: 14px; line-height: 1.8;">
279
+ ${highlightsHtml}
280
+ </ul>
281
+ <div style="background-color: #edf2f7; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
282
+ <p style="margin: 0; color: #4a5568; font-size: 14px; line-height: 1.6; font-style: italic;">
283
+ <strong style="color: #1a365d;">Analysis:</strong> ${analysis}
284
+ </p>
285
+ </div>
286
+ <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;">
287
+ View Listing &rarr;
288
+ </a>
289
+ <p style="margin: 15px 0 0 0; color: #718096; font-size: 13px;">
290
+ <strong>Contact:</strong> ${prop.contact ?? 'Contact agent for details'}
291
+ </p>
292
+ </td>
293
+ </tr>
294
+ </table>`;
295
+ }
296
+ function buildNewsItemHtml(article, analysis) {
297
+ return `<!-- News Item -->
298
+ <table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
299
+ <tr>
300
+ <td style="background-color: #ffffff; padding: 20px; border-left: 4px solid #d4a574;">
301
+ <p style="margin: 0 0 5px 0; color: #718096; font-size: 12px; text-transform: uppercase;">
302
+ ${article.source} &bull; ${article.date}
303
+ </p>
304
+ <h4 style="margin: 0 0 8px 0; color: #1a365d; font-size: 15px; font-weight: 600;">
305
+ <a href="${article.url}" style="color: #1a365d; text-decoration: none;">
306
+ ${article.title}
307
+ </a>
308
+ </h4>
309
+ <p style="margin: 0; color: #4a5568; font-size: 14px; line-height: 1.5;">
310
+ ${analysis}
311
+ </p>
312
+ </td>
313
+ </tr>
314
+ </table>`;
315
+ }
316
+ export function buildHtml(properties, newsArticles, aiContent, brandConfig) {
317
+ const propertyAnalyses = aiContent?.property_analyses ?? [];
318
+ const newsSummaries = aiContent?.news_summaries ?? [];
319
+ // Build property cards
320
+ const cardsHtml = properties.map((prop, i) => {
321
+ const analysis = i < propertyAnalyses.length
322
+ ? propertyAnalyses[i].analysis
323
+ : 'Prime investment opportunity in a growing market.';
324
+ return buildPropertyCardHtml(prop, analysis);
325
+ }).join('\n');
326
+ // Build news items (top 3)
327
+ const newsHtml = newsArticles.slice(0, 3).map((article, i) => {
328
+ const analysis = i < newsSummaries.length
329
+ ? newsSummaries[i].analysis
330
+ : `${article.description.slice(0, 120)}...`;
331
+ return buildNewsItemHtml(article, analysis);
332
+ }).join('\n');
333
+ const now = new Date();
334
+ const dateStr = now.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
335
+ const year = String(now.getFullYear());
336
+ const marketOverview = aiContent?.market_overview
337
+ ?? 'South Florida commercial real estate continues to attract investor interest.';
338
+ // Determine brand-specific styling
339
+ const isCRE = brandConfig.brand === 'CRE-11TRUST';
340
+ const headerBg = isCRE ? '#1a365d' : '#44403E';
341
+ const accentColor = isCRE ? '#d4a574' : '#C8A97E';
342
+ const headingColor = isCRE ? '#1a365d' : '#44403E';
343
+ const sectionBg = isCRE ? '#f8f9fa' : '#FAF9F6';
344
+ const footerBg = isCRE ? '#2d3748' : '#2d2926';
345
+ // Properties section (only for CRE brand)
346
+ const propertiesSection = brandConfig.hasProperties && properties.length > 0 ? `
347
+ <!-- Featured Properties -->
348
+ <tr>
349
+ <td style="padding: 30px 40px;">
350
+ <h2 style="margin: 0 0 20px 0; color: ${headingColor}; font-size: 18px; font-weight: 600;">
351
+ Featured Properties (${properties.length})
352
+ </h2>
353
+ ${cardsHtml}
354
+ </td>
355
+ </tr>` : '';
356
+ // CTA copy varies by brand
357
+ const ctaHeadline = isCRE
358
+ ? 'Looking to Buy or Sell in South Florida?'
359
+ : 'Protect What Matters Most';
360
+ const ctaBody = isCRE
361
+ ? 'Get expert guidance on your next commercial real estate transaction.'
362
+ : 'Get a free, no-obligation life insurance quote in minutes.';
363
+ const ctaButton = isCRE ? 'Contact Us Today' : 'Get Your Free Quote';
364
+ return `<!DOCTYPE html>
365
+ <html lang="en">
366
+ <head>
367
+ <meta charset="UTF-8">
368
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
369
+ <title>${brandConfig.titlePrefix} - ${dateStr}</title>
370
+ </head>
371
+ <body style="margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f5f5f5;">
372
+ <table role="presentation" style="width: 100%; border-collapse: collapse;">
373
+ <tr>
374
+ <td align="center" style="padding: 20px 10px;">
375
+ <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);">
376
+
377
+ <!-- Header -->
378
+ <tr>
379
+ <td style="background-color: ${headerBg}; padding: 30px 40px; text-align: center;">
380
+ <h1 style="margin: 0; color: #ffffff; font-size: 26px; font-weight: 600; letter-spacing: 1px;">
381
+ ${brandConfig.titlePrefix}
382
+ </h1>
383
+ <p style="margin: 8px 0 0 0; color: ${accentColor}; font-size: 13px; text-transform: uppercase; letter-spacing: 2px;">
384
+ ${brandConfig.displayName}
385
+ </p>
386
+ <p style="margin: 8px 0 0 0; color: #a0aec0; font-size: 12px;">
387
+ ${dateStr}
388
+ </p>
389
+ </td>
390
+ </tr>
391
+
392
+ <!-- Market Overview -->
393
+ <tr>
394
+ <td style="padding: 30px 40px; border-bottom: 1px solid #e5e5e5;">
395
+ <h2 style="margin: 0 0 15px 0; color: ${headingColor}; font-size: 18px; font-weight: 600;">
396
+ Market Overview
397
+ </h2>
398
+ <p style="margin: 0; color: #4a5568; font-size: 15px; line-height: 1.6;">
399
+ ${marketOverview}
400
+ </p>
401
+ </td>
402
+ </tr>
403
+
404
+ <!-- News -->
405
+ <tr>
406
+ <td style="padding: 30px 40px; background-color: ${sectionBg};">
407
+ <h2 style="margin: 0 0 20px 0; color: ${headingColor}; font-size: 18px; font-weight: 600;">
408
+ ${isCRE ? 'Market News & Analysis' : 'Industry News & Analysis'}
409
+ </h2>
410
+ ${newsHtml}
411
+ </td>
412
+ </tr>
413
+ ${propertiesSection}
414
+
415
+ <!-- CTA -->
416
+ <tr>
417
+ <td style="padding: 40px; background-color: ${headerBg}; text-align: center;">
418
+ <h3 style="margin: 0 0 10px 0; color: #ffffff; font-size: 20px; font-weight: 600;">
419
+ ${ctaHeadline}
420
+ </h3>
421
+ <p style="margin: 0 0 25px 0; color: #a0aec0; font-size: 15px;">
422
+ ${ctaBody}
423
+ </p>
424
+ <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;">
425
+ ${ctaButton}
426
+ </a>
427
+ </td>
428
+ </tr>
429
+
430
+ <!-- Footer -->
431
+ <tr>
432
+ <td style="padding: 25px 40px; background-color: ${footerBg}; text-align: center;">
433
+ <p style="margin: 0 0 10px 0; color: #a0aec0; font-size: 13px;">
434
+ &copy; ${year} ${brandConfig.displayName}. All rights reserved.
435
+ </p>
436
+ <p style="margin: 0; color: #718096; font-size: 12px;">
437
+ <a href="#unsubscribe" style="color: #718096; text-decoration: underline;">Unsubscribe</a> |
438
+ <a href="#web-version" style="color: #718096; text-decoration: underline;">View in Browser</a>
439
+ </p>
440
+ </td>
441
+ </tr>
442
+
443
+ </table>
444
+ </td>
445
+ </tr>
446
+ </table>
447
+ </body>
448
+ </html>`;
449
+ }
450
+ // ── 5. Build Strapi Payload ──────────────────────────────────────────
451
+ export function buildStrapiPayload(properties, newsArticles, aiContent, html, brandConfig, editionDate) {
452
+ const now = new Date();
453
+ const today = editionDate ?? now.toISOString().slice(0, 10);
454
+ const dateDisplay = now.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
455
+ const dateShort = now.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
456
+ const timestamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 15);
457
+ const propertyAnalyses = aiContent?.property_analyses ?? [];
458
+ const newsSummaries = aiContent?.news_summaries ?? [];
459
+ // Merge AI analyses into property objects
460
+ const featured = properties.map((prop, i) => ({
461
+ ...prop,
462
+ ...(i < propertyAnalyses.length ? { analysis: propertyAnalyses[i].analysis } : {}),
463
+ }));
464
+ // Build news items with AI summaries
465
+ const news = newsArticles.slice(0, 3).map((article, i) => ({
466
+ ...article,
467
+ ...(i < newsSummaries.length ? { analysis: newsSummaries[i].analysis } : {}),
468
+ }));
469
+ const marketOverview = aiContent?.market_overview ?? '';
470
+ // Slug includes timestamp for uniqueness (same-day reruns)
471
+ const slugBrand = brandConfig.brand.toLowerCase().replace(/-/g, '');
472
+ const subjectLine = brandConfig.hasProperties
473
+ ? `${brandConfig.subjectPrefix}: ${properties.length} New Listings - ${dateShort}`
474
+ : `${brandConfig.subjectPrefix}: ${dateShort}`;
475
+ return {
476
+ data: {
477
+ title: `${brandConfig.titlePrefix} - ${dateDisplay}`,
478
+ slug: `${slugBrand}-weekly-${timestamp}`,
479
+ brand: brandConfig.brand,
480
+ edition_date: today,
481
+ subject_line: subjectLine,
482
+ market_overview: marketOverview,
483
+ featured_properties: featured,
484
+ news_items: news,
485
+ html_body: html,
486
+ sender_email: brandConfig.senderEmail,
487
+ },
488
+ };
489
+ }
490
+ // ── 6. Push to Strapi ────────────────────────────────────────────────
491
+ async function pushToStrapi(payload) {
492
+ try {
493
+ const item = await strapiPost('/api/newsletters', payload.data);
494
+ const docId = item.documentId ?? 'unknown';
495
+ console.log(` Pushed to Strapi as DRAFT (documentId: ${docId})`);
496
+ return docId;
497
+ }
498
+ catch (err) {
499
+ const msg = err instanceof Error ? err.message : String(err);
500
+ console.error(` Strapi error: ${msg}`);
501
+ return null;
502
+ }
503
+ }
504
+ // ── 7. Orchestrator ──────────────────────────────────────────────────
505
+ export async function generateNewsletter(opts) {
506
+ const brandConfig = getBrandConfig(opts.brand);
507
+ console.log('='.repeat(60));
508
+ console.log(`NEWSLETTER GENERATOR — ${brandConfig.displayName}`);
509
+ console.log('='.repeat(60));
510
+ // Step 1: Read properties from Excel (if applicable)
511
+ let properties = [];
512
+ if (brandConfig.hasProperties && opts.excelPath) {
513
+ console.log(`\n1. Reading properties from: ${opts.excelPath}`);
514
+ properties = await readExcelProperties(opts.excelPath);
515
+ console.log(` Found ${properties.length} properties:`);
516
+ for (const p of properties) {
517
+ console.log(` - ${p.name ?? 'Unnamed'}: ${p.price ?? 'N/A'} (${p.region ?? '?'})`);
518
+ }
519
+ }
520
+ else if (brandConfig.hasProperties) {
521
+ console.log('\n1. No Excel file provided — skipping property extraction');
522
+ }
523
+ else {
524
+ console.log(`\n1. Brand ${brandConfig.brand} does not use property listings — skipping`);
525
+ }
526
+ // Step 2: Image extraction skipped (EMF/WMF conversion too complex for CLI)
527
+ console.log('\n2. Image extraction — skipped (use Python pipeline for embedded images)');
528
+ // Step 3: Fetch news
529
+ console.log(`\n3. Fetching news for: ${brandConfig.newsQuery}`);
530
+ const newsArticles = await fetchNews(brandConfig.newsQuery);
531
+ console.log(` Found ${newsArticles.length} articles`);
532
+ // Step 4: Generate AI content
533
+ const model = process.env.GROQ_MODEL ?? 'llama-3.3-70b-versatile';
534
+ console.log(`\n4. Generating AI content via Groq (${model})`);
535
+ const aiContent = await generateAiContent(properties, newsArticles);
536
+ if (aiContent) {
537
+ console.log(' Generated: market overview, property analyses, news summaries');
538
+ }
539
+ else {
540
+ console.log(' WARNING: AI generation failed, using fallback content');
541
+ }
542
+ // Step 5: Build HTML
543
+ console.log('\n5. Building newsletter HTML');
544
+ const html = buildHtml(properties, newsArticles, aiContent, brandConfig);
545
+ console.log(` HTML length: ${html.length} chars`);
546
+ // Step 6: Build Strapi payload
547
+ const payload = buildStrapiPayload(properties, newsArticles, aiContent, html, brandConfig, opts.date);
548
+ console.log(` Payload: "${payload.data.title}" (slug: ${payload.data.slug})`);
549
+ // Step 7: Push to Strapi (unless dry-run)
550
+ let strapiDocumentId = null;
551
+ if (opts.dryRun) {
552
+ console.log('\n6. DRY RUN — skipping Strapi push');
553
+ console.log(` Would create: "${payload.data.title}"`);
554
+ console.log(` Subject line: "${payload.data.subject_line}"`);
555
+ }
556
+ else {
557
+ console.log('\n6. Pushing to Strapi CMS');
558
+ strapiDocumentId = await pushToStrapi(payload);
559
+ }
560
+ console.log('\n' + '='.repeat(60));
561
+ console.log('DONE!');
562
+ console.log('='.repeat(60));
563
+ return {
564
+ properties,
565
+ newsArticles,
566
+ aiContent,
567
+ html,
568
+ payload,
569
+ strapiDocumentId,
570
+ };
571
+ }
@@ -0,0 +1,64 @@
1
+ export interface RateAnomaly {
2
+ /** Master program name (e.g., "Bass Pro Shops Liquidation") */
3
+ master_program: string;
4
+ /** Program code (e.g., "BRTON-BPS-LIQ") */
5
+ program_code: string | null;
6
+ /** Numeric program ID key from dim_program_id */
7
+ program_id: number | null;
8
+ /** Client ID from dim_client */
9
+ client_id: number | null;
10
+ /** Client display name */
11
+ client_name: string | null;
12
+ /** YYYY-MM period */
13
+ month: string;
14
+ /** Service Check In Fee dollars for this program+month */
15
+ checkin_fee_dollars: number;
16
+ /** Checked-in units for this program+month */
17
+ units: number;
18
+ /** Dollars per unit = checkin_fee_dollars / units */
19
+ rate_per_unit: number;
20
+ /** Prior month's rate_per_unit for comparison */
21
+ prev_month_rate: number | null;
22
+ /** % change in rate vs prior month */
23
+ rate_delta_pct: number | null;
24
+ /** % change in units vs prior month */
25
+ units_change_pct: number | null;
26
+ /** % change in dollars vs prior month */
27
+ dollars_change_pct: number | null;
28
+ /** Z-score of rate_per_unit within the portfolio cross-section */
29
+ zscore: number;
30
+ /**
31
+ * The [mean - 2σ, mean + 2σ] interval computed from all program rates
32
+ * in the same period. Rates outside this window are flagged.
33
+ */
34
+ expected_range: [number, number];
35
+ }
36
+ export interface AnomalyResult {
37
+ /** Anomalies that exceed the z-score threshold */
38
+ anomalies: RateAnomaly[];
39
+ /** Total rows fetched from v_rate_anomaly_analysis before filtering */
40
+ totalRows: number;
41
+ /** Z-score threshold used (default 2.0) */
42
+ threshold: number;
43
+ /** Months included in the analysis */
44
+ months: string[];
45
+ }
46
+ /**
47
+ * Detect $/unit rate outliers across all programs in stg_financials_raw.
48
+ *
49
+ * Method:
50
+ * 1. Fetch all rows from v_rate_anomaly_analysis (paginated) filtered to
51
+ * the requested months (or fiscal YTD if omitted).
52
+ * 2. For each month, compute mean and population stddev of rate_per_unit
53
+ * across all programs with valid rates.
54
+ * 3. Flag any program-month where |z-score| > threshold (default 2.0).
55
+ * 4. Return the flagged rows sorted by |z-score| descending.
56
+ *
57
+ * @param options.months - YYYY-MM strings to analyse. If omitted, uses fiscal
58
+ * YTD (April of current/previous fiscal year → today).
59
+ * @param options.threshold - Z-score magnitude threshold. Default 2.0.
60
+ */
61
+ export declare function detectRateAnomalies(options?: {
62
+ months?: string[];
63
+ threshold?: number;
64
+ }): Promise<AnomalyResult>;