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.
- package/README.md +175 -0
- package/dist/bin/optimal.d.ts +2 -0
- package/dist/bin/optimal.js +995 -0
- package/dist/lib/budget/projections.d.ts +115 -0
- package/dist/lib/budget/projections.js +384 -0
- package/dist/lib/budget/scenarios.d.ts +93 -0
- package/dist/lib/budget/scenarios.js +214 -0
- package/dist/lib/cms/publish-blog.d.ts +62 -0
- package/dist/lib/cms/publish-blog.js +74 -0
- package/dist/lib/cms/strapi-client.d.ts +123 -0
- package/dist/lib/cms/strapi-client.js +213 -0
- package/dist/lib/config.d.ts +55 -0
- package/dist/lib/config.js +206 -0
- package/dist/lib/infra/deploy.d.ts +29 -0
- package/dist/lib/infra/deploy.js +58 -0
- package/dist/lib/infra/migrate.d.ts +34 -0
- package/dist/lib/infra/migrate.js +103 -0
- package/dist/lib/kanban.d.ts +46 -0
- package/dist/lib/kanban.js +118 -0
- package/dist/lib/newsletter/distribute.d.ts +52 -0
- package/dist/lib/newsletter/distribute.js +193 -0
- package/dist/lib/newsletter/generate-insurance.d.ts +42 -0
- package/dist/lib/newsletter/generate-insurance.js +36 -0
- package/dist/lib/newsletter/generate.d.ts +104 -0
- package/dist/lib/newsletter/generate.js +571 -0
- package/dist/lib/returnpro/anomalies.d.ts +64 -0
- package/dist/lib/returnpro/anomalies.js +166 -0
- package/dist/lib/returnpro/audit.d.ts +32 -0
- package/dist/lib/returnpro/audit.js +147 -0
- package/dist/lib/returnpro/diagnose.d.ts +52 -0
- package/dist/lib/returnpro/diagnose.js +281 -0
- package/dist/lib/returnpro/kpis.d.ts +32 -0
- package/dist/lib/returnpro/kpis.js +192 -0
- package/dist/lib/returnpro/templates.d.ts +48 -0
- package/dist/lib/returnpro/templates.js +229 -0
- package/dist/lib/returnpro/upload-income.d.ts +25 -0
- package/dist/lib/returnpro/upload-income.js +235 -0
- package/dist/lib/returnpro/upload-netsuite.d.ts +37 -0
- package/dist/lib/returnpro/upload-netsuite.js +566 -0
- package/dist/lib/returnpro/upload-r1.d.ts +48 -0
- package/dist/lib/returnpro/upload-r1.js +398 -0
- package/dist/lib/social/post-generator.d.ts +83 -0
- package/dist/lib/social/post-generator.js +333 -0
- package/dist/lib/social/publish.d.ts +66 -0
- package/dist/lib/social/publish.js +226 -0
- package/dist/lib/social/scraper.d.ts +67 -0
- package/dist/lib/social/scraper.js +361 -0
- package/dist/lib/supabase.d.ts +4 -0
- package/dist/lib/supabase.js +20 -0
- package/dist/lib/transactions/delete-batch.d.ts +60 -0
- package/dist/lib/transactions/delete-batch.js +203 -0
- package/dist/lib/transactions/ingest.d.ts +43 -0
- package/dist/lib/transactions/ingest.js +555 -0
- package/dist/lib/transactions/stamp.d.ts +51 -0
- package/dist/lib/transactions/stamp.js +524 -0
- 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 →
|
|
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} • ${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
|
+
© ${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>;
|