backend-manager 5.0.203 → 5.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/CHANGELOG.md +56 -0
- package/CLAUDE.md +43 -1501
- package/TODO-CHARGEBLAST.md +32 -0
- package/TODO-email-auth.md +14 -0
- package/docs/admin-post-route.md +24 -0
- package/docs/ai-library.md +23 -0
- package/docs/architecture.md +31 -0
- package/docs/auth-hooks.md +74 -0
- package/docs/cli-firestore-auth.md +59 -0
- package/docs/cli-logs.md +67 -0
- package/docs/code-patterns.md +67 -0
- package/docs/common-operations.md +64 -0
- package/docs/directory-structure.md +119 -0
- package/docs/environment-detection.md +7 -0
- package/docs/file-naming.md +11 -0
- package/docs/marketing-campaigns.md +244 -0
- package/docs/marketing-fields.md +25 -0
- package/docs/mcp.md +95 -0
- package/docs/payment-system.md +325 -0
- package/docs/response-headers.md +7 -0
- package/docs/routes.md +126 -0
- package/docs/sanitization.md +61 -0
- package/docs/schemas.md +39 -0
- package/docs/stripe-webhook-forwarding.md +18 -0
- package/docs/testing.md +129 -0
- package/docs/usage-rate-limiting.md +67 -0
- package/package.json +8 -4
- package/src/defaults/CHANGELOG.md +15 -0
- package/src/defaults/CLAUDE.md +8 -4
- package/src/defaults/docs/README.md +17 -0
- package/src/defaults/test/README.md +33 -0
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +48 -8
- package/src/manager/functions/core/actions/api/admin/create-post.js +3 -27
- package/src/manager/helpers/utilities.js +21 -0
- package/src/manager/index.js +1 -1
- package/src/manager/libraries/ai/index.js +162 -0
- package/src/manager/libraries/ai/providers/anthropic.js +193 -0
- package/src/manager/libraries/ai/providers/claude-code.js +206 -0
- package/src/manager/libraries/ai/providers/openai.js +934 -0
- package/src/manager/libraries/disposable-domains.json +2 -0
- package/src/manager/libraries/email/generators/lib/filter.js +179 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +231 -0
- package/src/manager/libraries/email/generators/lib/mjml-template.js +83 -0
- package/src/manager/libraries/email/generators/lib/structure.js +278 -0
- package/src/manager/libraries/email/generators/lib/svg-illustrator.js +184 -0
- package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +63 -0
- package/src/manager/libraries/email/generators/lib/templates/clean.js +82 -0
- package/src/manager/libraries/email/generators/lib/templates/editorial-helpers.js +100 -0
- package/src/manager/libraries/email/generators/lib/templates/editorial.js +317 -0
- package/src/manager/libraries/email/generators/lib/templates/field-report-helpers.js +138 -0
- package/src/manager/libraries/email/generators/lib/templates/field-report.js +497 -0
- package/src/manager/libraries/email/generators/lib/templates/index.js +28 -0
- package/src/manager/libraries/email/generators/lib/templates/shared.js +534 -0
- package/src/manager/libraries/email/generators/newsletter.js +377 -95
- package/src/manager/libraries/email/marketing/index.js +5 -2
- package/src/manager/libraries/email/providers/beehiiv.js +7 -3
- package/src/manager/libraries/openai.js +13 -932
- package/src/manager/routes/admin/post/deduplicate-image-alts.js +52 -0
- package/src/manager/routes/admin/post/post.js +10 -17
- package/templates/_.env +4 -0
- package/templates/_.gitignore +1 -0
- package/templates/backend-manager-config.json +48 -4
- package/test/helpers/slugify.js +394 -0
- package/test/marketing/fixtures/clean.json +31 -0
- package/test/marketing/fixtures/editorial.json +31 -0
- package/test/marketing/fixtures/field-report.json +54 -0
- package/test/marketing/newsletter-generate.js +731 -0
- package/test/marketing/newsletter-templates.js +512 -0
- package/test/routes/admin/deduplicate-image-alts.js +190 -0
|
@@ -1,79 +1,361 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Newsletter generator — pulls content from parent server and assembles a branded newsletter.
|
|
3
3
|
*
|
|
4
|
-
* Called by the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Called by the daily pre-generation cron (and the iteration test) when a campaign has
|
|
5
|
+
* `generator: 'newsletter'`. Produces a fully rendered email-safe HTML newsletter ready
|
|
6
|
+
* to ship to Beehiiv / SendGrid.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* 1. Read newsletter categories from Manager.config.marketing.
|
|
10
|
-
* 2. Fetch ready sources from parent server (
|
|
11
|
-
* 3. AI
|
|
12
|
-
* 4.
|
|
13
|
-
* 5.
|
|
8
|
+
* Pipeline:
|
|
9
|
+
* 1. Read newsletter categories from Manager.config.marketing.beehiiv.content.categories
|
|
10
|
+
* 2. Fetch ready sources from parent server (atomic claim via claimFor=brandId)
|
|
11
|
+
* 3. structure.js → AI authors subject, preheader, intro, sections, signoff
|
|
12
|
+
* 4. svg-illustrator.js → AI authors one SVG per section, rasterize to PNG
|
|
13
|
+
* 5. mjml-template.js → compile MJML → email-safe HTML
|
|
14
|
+
* 6. Persist PNGs (caller-provided via opts.persistImage or default no-op)
|
|
15
|
+
* 7. Mark sources as used on parent server
|
|
16
|
+
*
|
|
17
|
+
* Returns { subject, preheader, content, contentHtml, structure, images } so the
|
|
18
|
+
* marketing library can either send the HTML directly (contentHtml) or fall back to
|
|
19
|
+
* the markdown pipeline (content) if needed.
|
|
14
20
|
*/
|
|
15
21
|
const fetch = require('wonderful-fetch');
|
|
16
22
|
|
|
23
|
+
const { filterSources } = require('./lib/filter.js');
|
|
24
|
+
const { generateStructure } = require('./lib/structure.js');
|
|
25
|
+
const { generateSectionImage } = require('./lib/svg-illustrator.js');
|
|
26
|
+
const { renderNewsletter } = require('./lib/mjml-template.js');
|
|
27
|
+
const { uploadAssets, RAW_BASE } = require('./lib/image-host.js');
|
|
28
|
+
|
|
17
29
|
/**
|
|
18
30
|
* Generate newsletter content from parent server sources.
|
|
19
31
|
*
|
|
20
32
|
* @param {object} Manager - BEM Manager instance
|
|
21
33
|
* @param {object} assistant - BEM assistant instance
|
|
22
34
|
* @param {object} settings - Campaign settings from the recurring template
|
|
23
|
-
* @
|
|
35
|
+
* @param {object} [opts] - Optional overrides used by the iteration test
|
|
36
|
+
* @param {function} [opts.persistImage] - async (image, idx) => imagePath (URL or relative path).
|
|
37
|
+
* When provided, takes precedence over imageHost — used by the
|
|
38
|
+
* iteration test to write PNGs locally for preview.
|
|
39
|
+
* @param {'github'|'local'} [opts.imageHost] - Where to host images for the final HTML.
|
|
40
|
+
* Defaults to 'github' (uploads to itw-creative-works/newsletter-assets).
|
|
41
|
+
* Local skips upload — only useful if persistImage is also set.
|
|
42
|
+
* @param {string} [opts.campaignId] - Stable ID used as the folder name in newsletter-assets.
|
|
43
|
+
* Defaults to the Firestore campaign doc ID if available.
|
|
44
|
+
* @param {object[]} [opts.sources] - Pre-fetched sources (bypasses parent server claim)
|
|
45
|
+
* @param {boolean} [opts.skipClaim] - Don't call PUT to mark sources as used
|
|
46
|
+
* @param {boolean} [opts.skipImages] - Skip SVG/PNG generation (use placeholders)
|
|
47
|
+
* @returns {object|null} Updated settings with content filled in, or null if unavailable
|
|
24
48
|
*/
|
|
25
|
-
async function generate(Manager, assistant, settings) {
|
|
26
|
-
|
|
49
|
+
async function generate(Manager, assistant, settings, opts = {}) {
|
|
50
|
+
// Content pipeline config lives under the provider that publishes the result.
|
|
51
|
+
// For newsletters, that's beehiiv (`marketing.beehiiv.content`). The whole
|
|
52
|
+
// pipeline is gated by beehiiv.enabled — disabling beehiiv disables newsletter
|
|
53
|
+
// generation as a side effect (correct, since there's nowhere for the
|
|
54
|
+
// generated content to land).
|
|
55
|
+
const beehiivConfig = Manager.config?.marketing?.beehiiv;
|
|
56
|
+
const config = beehiivConfig?.content;
|
|
27
57
|
|
|
28
|
-
if (!
|
|
29
|
-
assistant.log('Newsletter generator: disabled in config');
|
|
58
|
+
if (!beehiivConfig?.enabled) {
|
|
59
|
+
assistant.log('Newsletter generator: beehiiv disabled in config');
|
|
30
60
|
return null;
|
|
31
61
|
}
|
|
32
62
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (!categories.length) {
|
|
36
|
-
assistant.log('Newsletter generator: no categories configured');
|
|
63
|
+
if (!config) {
|
|
64
|
+
assistant.log('Newsletter generator: no marketing.beehiiv.content config block');
|
|
37
65
|
return null;
|
|
38
66
|
}
|
|
39
67
|
|
|
40
|
-
|
|
68
|
+
// Either use pre-fetched sources (iteration test) or fetch from parent
|
|
69
|
+
let sources = opts.sources;
|
|
41
70
|
|
|
42
|
-
if (!
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
71
|
+
if (!sources) {
|
|
72
|
+
const categories = config.categories || [];
|
|
73
|
+
|
|
74
|
+
if (!categories.length) {
|
|
75
|
+
assistant.log('Newsletter generator: no categories configured');
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const parentUrl = Manager.config?.parent;
|
|
80
|
+
|
|
81
|
+
if (!parentUrl) {
|
|
82
|
+
assistant.log('Newsletter generator: no parent URL configured');
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
46
85
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
86
|
+
const brandId = Manager.config?.brand?.id;
|
|
87
|
+
sources = await fetchSources(parentUrl, categories, brandId, assistant);
|
|
88
|
+
}
|
|
50
89
|
|
|
51
|
-
if (!sources
|
|
90
|
+
if (!sources?.length) {
|
|
52
91
|
assistant.log('Newsletter generator: no sources available');
|
|
53
92
|
return null;
|
|
54
93
|
}
|
|
55
94
|
|
|
56
|
-
assistant.log(`Newsletter generator: ${sources.length} sources found, assembling...`);
|
|
57
|
-
|
|
58
95
|
const brand = Manager.config?.brand;
|
|
96
|
+
const ai = Manager.AI(assistant);
|
|
97
|
+
const pipelineStart = Date.now();
|
|
59
98
|
|
|
60
|
-
//
|
|
61
|
-
const
|
|
99
|
+
// 1. Filter — drop sources that don't fit the brand
|
|
100
|
+
const { kept: filteredSources, scores, meta: filterMeta } = await filterSources({
|
|
101
|
+
sources,
|
|
102
|
+
brand,
|
|
103
|
+
newsletterConfig: config,
|
|
104
|
+
ai,
|
|
105
|
+
assistant,
|
|
106
|
+
threshold: opts.fitThreshold,
|
|
107
|
+
});
|
|
62
108
|
|
|
63
|
-
if (!
|
|
64
|
-
assistant.log('Newsletter generator:
|
|
109
|
+
if (!filteredSources.length) {
|
|
110
|
+
assistant.log('Newsletter generator: no sources passed brand-fit filter, skipping');
|
|
65
111
|
return null;
|
|
66
112
|
}
|
|
67
113
|
|
|
68
|
-
|
|
69
|
-
|
|
114
|
+
assistant.log(`Newsletter generator: assembling from ${filteredSources.length} brand-fit sources (out of ${sources.length})`);
|
|
115
|
+
|
|
116
|
+
// 2. Structure
|
|
117
|
+
const structure = await generateStructure({
|
|
118
|
+
sources: filteredSources,
|
|
119
|
+
brand,
|
|
120
|
+
newsletterConfig: config,
|
|
121
|
+
ai,
|
|
122
|
+
assistant,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
assistant.log(`Newsletter generator: structure ready (${structure.sections.length} sections)`);
|
|
126
|
+
|
|
127
|
+
// Asset hosting target — controls what URLs end up in <img src=...> AND
|
|
128
|
+
// whether the rendered HTML gets uploaded too. Two values:
|
|
129
|
+
// - 'github': upload PNGs + newsletter.html to itw-creative-works/newsletter-assets,
|
|
130
|
+
// embed raw.githubusercontent.com URLs. The production cron path.
|
|
131
|
+
// - 'local': use whatever persistImage returns (iteration test writes to disk).
|
|
132
|
+
// Defaults to 'github' so production cron path "just works" without flag fiddling.
|
|
133
|
+
const host = opts.imageHost || 'github';
|
|
134
|
+
|
|
135
|
+
// campaignId — used as the GitHub folder name (and as the doc ID in production).
|
|
136
|
+
// Resolution priority (most-specific first):
|
|
137
|
+
// 1. opts.campaignId — explicit override (test runs, cron paths)
|
|
138
|
+
// 2. settings.id — marketing-campaigns/{id} doc ID in production
|
|
139
|
+
// 3. sources[0].id — when exactly one source is being processed
|
|
140
|
+
// (iteration test pinning to a single source —
|
|
141
|
+
// means re-running against the same source
|
|
142
|
+
// overwrites the same folder, no churn)
|
|
143
|
+
// 4. generatePushId() — final fallback for ad-hoc runs with no anchor
|
|
144
|
+
const campaignId = opts.campaignId
|
|
145
|
+
|| settings?.id
|
|
146
|
+
|| (sources.length === 1 ? sources[0].id : null)
|
|
147
|
+
|| generatePushId();
|
|
148
|
+
|
|
149
|
+
// 2. SVG illustrations (parallel) + upload PNGs first so we have URLs
|
|
150
|
+
// available to embed in the HTML render below.
|
|
151
|
+
let imagePaths = [];
|
|
152
|
+
|
|
153
|
+
if (!opts.skipImages) {
|
|
154
|
+
const images = await Promise.all(
|
|
155
|
+
structure.sections.map((s) => generateSectionImage({
|
|
156
|
+
imagePrompt: s.image_prompt,
|
|
157
|
+
brand,
|
|
158
|
+
newsletterConfig: config,
|
|
159
|
+
ai,
|
|
160
|
+
assistant,
|
|
161
|
+
}))
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Run persistImage side-effects first (writes to disk, etc.). The local
|
|
165
|
+
// paths it returns are used as a fallback if no host produces URLs.
|
|
166
|
+
const persistedPaths = typeof opts.persistImage === 'function'
|
|
167
|
+
? await Promise.all(images.map((img, i) => opts.persistImage(img, i)))
|
|
168
|
+
: null;
|
|
169
|
+
|
|
170
|
+
if (host === 'github') {
|
|
171
|
+
try {
|
|
172
|
+
const { urls } = await uploadAssets({
|
|
173
|
+
images,
|
|
174
|
+
brandId: brand?.id,
|
|
175
|
+
campaignId,
|
|
176
|
+
subject: structure.subject,
|
|
177
|
+
assistant,
|
|
178
|
+
});
|
|
179
|
+
imagePaths = urls;
|
|
180
|
+
} catch (e) {
|
|
181
|
+
assistant.error(`Newsletter generator: image upload failed — ${e.message}`);
|
|
182
|
+
imagePaths = persistedPaths || images.map((_, i) => `about:blank#section-${i + 1}`);
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
// 'local' — use persisted paths if available, else placeholder
|
|
186
|
+
imagePaths = persistedPaths || images.map((_, i) => `about:blank#section-${i + 1}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
assistant.log(`Newsletter generator: ${images.length} images rendered`);
|
|
190
|
+
|
|
191
|
+
// Stash images on the return for callers that want to access raw buffers
|
|
192
|
+
opts._lastImages = images;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 3. MJML → HTML
|
|
196
|
+
// Sponsorships precedence: opts.sponsorships > campaign settings.sponsorships > config.sponsorships
|
|
197
|
+
const sponsorships = opts.sponsorships
|
|
198
|
+
|| settings?.sponsorships
|
|
199
|
+
|| config?.sponsorships
|
|
200
|
+
|| [];
|
|
201
|
+
|
|
202
|
+
const { html, mjml } = await renderNewsletter({
|
|
203
|
+
brand,
|
|
204
|
+
newsletterConfig: config,
|
|
205
|
+
structure,
|
|
206
|
+
imagePaths,
|
|
207
|
+
campaign: settings?.name || 'newsletter',
|
|
208
|
+
sponsorships,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// 3b. Upload the rendered HTML to GitHub alongside the images. Lives in the
|
|
212
|
+
// same {brandId}/{campaignId}/ folder as newsletter.html. The folder URL
|
|
213
|
+
// becomes the canonical archive of the issue, browseable + downloadable.
|
|
214
|
+
let assetsFolderUrl = null;
|
|
215
|
+
let htmlUrl = null;
|
|
216
|
+
|
|
217
|
+
if (host === 'github') {
|
|
218
|
+
try {
|
|
219
|
+
const upload = await uploadAssets({
|
|
220
|
+
html,
|
|
221
|
+
brandId: brand?.id,
|
|
222
|
+
campaignId,
|
|
223
|
+
subject: structure.subject,
|
|
224
|
+
assistant,
|
|
225
|
+
});
|
|
226
|
+
assetsFolderUrl = upload.folderUrl;
|
|
227
|
+
htmlUrl = upload.htmlUrl;
|
|
228
|
+
} catch (e) {
|
|
229
|
+
assistant.error(`Newsletter generator: HTML upload failed — ${e.message}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 3c. Upload to Beehiiv as a draft. Uses the same complete HTML with
|
|
234
|
+
// CDN image URLs already embedded. Today this will fail (Beehiiv's
|
|
235
|
+
// post-creation API requires Enterprise plan), but we ship it anyway
|
|
236
|
+
// so the day we upgrade to Enterprise it Just Works. Failure is logged,
|
|
237
|
+
// never thrown — the rest of the pipeline (GH archive, Firestore doc)
|
|
238
|
+
// succeeds regardless. beehiivConfig was already resolved at the top
|
|
239
|
+
// of the function for the initial enabled-check.
|
|
240
|
+
let beehiivPostId = null;
|
|
241
|
+
|
|
242
|
+
if (host === 'github' && beehiivConfig?.enabled) {
|
|
243
|
+
try {
|
|
244
|
+
const beehiivProvider = require('../providers/beehiiv.js');
|
|
245
|
+
const result = await beehiivProvider.createPost({
|
|
246
|
+
publicationId: beehiivConfig.publicationId, // explicit — avoids singleton-Manager dependency
|
|
247
|
+
title: structure.subject,
|
|
248
|
+
subject: structure.subject,
|
|
249
|
+
preheader: structure.preheader,
|
|
250
|
+
content: html,
|
|
251
|
+
status: 'draft',
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (result?.success && result.id) {
|
|
255
|
+
beehiivPostId = result.id;
|
|
256
|
+
assistant.log(`Newsletter generator: Beehiiv draft created — ${beehiivPostId}`);
|
|
257
|
+
} else {
|
|
258
|
+
// Expected today until Enterprise plan — log, do not throw.
|
|
259
|
+
assistant.log(`Newsletter generator: Beehiiv draft upload skipped/failed — ${result?.error || 'unknown'}`);
|
|
260
|
+
}
|
|
261
|
+
} catch (e) {
|
|
262
|
+
assistant.error(`Newsletter generator: Beehiiv draft upload threw — ${e.message}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 4. Mark sources as used on parent server (unless caller opted out)
|
|
267
|
+
if (!opts.skipClaim) {
|
|
268
|
+
const parentUrl = Manager.config?.parent;
|
|
269
|
+
|
|
270
|
+
if (parentUrl) {
|
|
271
|
+
await claimSources(parentUrl, sources, brand?.id, assistant);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Aggregate per-step metadata for telemetry / cost tracking
|
|
276
|
+
const meta = {
|
|
277
|
+
timestamp: new Date().toISOString(),
|
|
278
|
+
totalDurationMs: Date.now() - pipelineStart,
|
|
279
|
+
brand: { id: brand?.id, name: brand?.name },
|
|
280
|
+
config: {
|
|
281
|
+
categories: config.categories,
|
|
282
|
+
tone: config.tone,
|
|
283
|
+
template: config.template,
|
|
284
|
+
},
|
|
285
|
+
sources: {
|
|
286
|
+
total: sources.length,
|
|
287
|
+
filtered: filteredSources.length,
|
|
288
|
+
scores,
|
|
289
|
+
},
|
|
290
|
+
steps: {
|
|
291
|
+
filter: filterMeta || null,
|
|
292
|
+
structure: structure._meta || null,
|
|
293
|
+
images: (opts._lastImages || []).map((img, i) => ({
|
|
294
|
+
section: i + 1,
|
|
295
|
+
...(img.meta || {}),
|
|
296
|
+
})),
|
|
297
|
+
},
|
|
298
|
+
totals: aggregateTotals(filterMeta, structure._meta, opts._lastImages),
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// Public asset URLs — stamped onto the generated campaign doc by the cron
|
|
302
|
+
// so you can find the GitHub folder + downloadable HTML + image URLs without
|
|
303
|
+
// re-deriving them. Null entries mean GitHub upload was skipped (local mode)
|
|
304
|
+
// or failed (errors already logged above). beehiivPostId is the ID of the
|
|
305
|
+
// draft post created on Beehiiv (or null if creation failed — expected until
|
|
306
|
+
// we move off the free Beehiiv plan).
|
|
307
|
+
const assets = host === 'github' ? {
|
|
308
|
+
campaignId,
|
|
309
|
+
folderUrl: assetsFolderUrl,
|
|
310
|
+
htmlUrl,
|
|
311
|
+
imageUrls: imagePaths,
|
|
312
|
+
beehiivPostId,
|
|
313
|
+
} : null;
|
|
70
314
|
|
|
71
|
-
// Return updated settings — AI-generated fields override template placeholders
|
|
72
315
|
return {
|
|
73
316
|
...settings,
|
|
74
|
-
subject:
|
|
75
|
-
preheader:
|
|
76
|
-
content:
|
|
317
|
+
subject: structure.subject,
|
|
318
|
+
preheader: structure.preheader,
|
|
319
|
+
content: '', // legacy markdown field, unused when contentHtml is set
|
|
320
|
+
contentHtml: html, // pre-rendered email-safe HTML
|
|
321
|
+
structure, // structured copy for debugging / migration
|
|
322
|
+
mjml, // raw MJML for debugging
|
|
323
|
+
images: opts._lastImages || [], // image buffers for the iteration test to persist locally
|
|
324
|
+
assets, // GitHub asset URLs (folder, html, images) — null in local mode
|
|
325
|
+
meta, // per-step provider/model/cost/timing telemetry
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Sum tokens + cost across all steps (filter, structure, all SVGs).
|
|
331
|
+
*/
|
|
332
|
+
function aggregateTotals(filterMeta, structureMeta, images) {
|
|
333
|
+
let inputTokens = 0;
|
|
334
|
+
let outputTokens = 0;
|
|
335
|
+
let totalCost = 0;
|
|
336
|
+
let aiCalls = 0;
|
|
337
|
+
|
|
338
|
+
const collect = (m) => {
|
|
339
|
+
if (!m?.tokens) return;
|
|
340
|
+
inputTokens += m.tokens.input?.count || 0;
|
|
341
|
+
outputTokens += m.tokens.output?.count || 0;
|
|
342
|
+
totalCost += m.tokens.total?.price || 0;
|
|
343
|
+
aiCalls++;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
collect(filterMeta);
|
|
347
|
+
collect(structureMeta);
|
|
348
|
+
|
|
349
|
+
for (const img of images || []) {
|
|
350
|
+
collect(img.meta);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
aiCalls,
|
|
355
|
+
inputTokens,
|
|
356
|
+
outputTokens,
|
|
357
|
+
totalTokens: inputTokens + outputTokens,
|
|
358
|
+
totalCostUSD: Number(totalCost.toFixed(4)),
|
|
77
359
|
};
|
|
78
360
|
}
|
|
79
361
|
|
|
@@ -85,7 +367,7 @@ async function fetchSources(parentUrl, categories, brandId, assistant) {
|
|
|
85
367
|
|
|
86
368
|
for (const category of categories) {
|
|
87
369
|
try {
|
|
88
|
-
const data = await fetch(`${parentUrl}/
|
|
370
|
+
const data = await fetch(`${parentUrl}/newsletter-sources`, {
|
|
89
371
|
method: 'get',
|
|
90
372
|
response: 'json',
|
|
91
373
|
timeout: 15000,
|
|
@@ -101,71 +383,20 @@ async function fetchSources(parentUrl, categories, brandId, assistant) {
|
|
|
101
383
|
allSources.push(...data.sources);
|
|
102
384
|
}
|
|
103
385
|
} catch (e) {
|
|
104
|
-
assistant.error(`Newsletter generator: Failed to fetch ${category} sources
|
|
386
|
+
assistant.error(`Newsletter generator: Failed to fetch ${category} sources: ${e.message}`);
|
|
105
387
|
}
|
|
106
388
|
}
|
|
107
389
|
|
|
108
390
|
return allSources;
|
|
109
391
|
}
|
|
110
392
|
|
|
111
|
-
/**
|
|
112
|
-
* Assemble newsletter sources into a branded newsletter via AI.
|
|
113
|
-
* Returns { subject, preheader, content } or null on failure.
|
|
114
|
-
*/
|
|
115
|
-
async function assembleNewsletter(Manager, assistant, sources, brand) {
|
|
116
|
-
const ai = require('../../openai.js');
|
|
117
|
-
|
|
118
|
-
const sourceSummaries = sources.map((s, i) =>
|
|
119
|
-
`[${i + 1}] ${s.ai?.headline || s.subject}\n${s.ai?.summary || ''}\nTakeaways: ${(s.ai?.takeaways || []).join('; ')}`
|
|
120
|
-
).join('\n\n');
|
|
121
|
-
|
|
122
|
-
try {
|
|
123
|
-
const result = await ai.request({
|
|
124
|
-
model: 'gpt-4o-mini',
|
|
125
|
-
messages: [
|
|
126
|
-
{
|
|
127
|
-
role: 'system',
|
|
128
|
-
content: `You are a newsletter writer for ${brand?.name || 'a tech company'}. ${brand?.description || ''}
|
|
129
|
-
|
|
130
|
-
Given source articles, write a branded newsletter in markdown. Be concise, engaging, and professional.
|
|
131
|
-
|
|
132
|
-
Respond in JSON:
|
|
133
|
-
{
|
|
134
|
-
"subject": "Catchy email subject line (max 60 chars, no emojis)",
|
|
135
|
-
"preheader": "Preview text that complements the subject (max 100 chars)",
|
|
136
|
-
"content": "Full newsletter body in markdown with ## section headers"
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
Guidelines:
|
|
140
|
-
- Start with a brief intro (1-2 sentences)
|
|
141
|
-
- Each source becomes a section with ## header
|
|
142
|
-
- Rewrite in your own voice — don't copy verbatim
|
|
143
|
-
- End with a short sign-off
|
|
144
|
-
- Keep it scannable — use bold, bullets, short paragraphs`,
|
|
145
|
-
},
|
|
146
|
-
{
|
|
147
|
-
role: 'user',
|
|
148
|
-
content: `Write a newsletter from these ${sources.length} sources:\n\n${sourceSummaries}`,
|
|
149
|
-
},
|
|
150
|
-
],
|
|
151
|
-
response_format: { type: 'json_object' },
|
|
152
|
-
apiKey: process.env.BACKEND_MANAGER_OPENAI_API_KEY,
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
return result.content;
|
|
156
|
-
} catch (e) {
|
|
157
|
-
assistant.error('Newsletter AI assembly failed:', e.message);
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
393
|
/**
|
|
163
394
|
* Mark sources as used on the parent server.
|
|
164
395
|
*/
|
|
165
396
|
async function claimSources(parentUrl, sources, brandId, assistant) {
|
|
166
397
|
for (const source of sources) {
|
|
167
398
|
try {
|
|
168
|
-
await fetch(`${parentUrl}/
|
|
399
|
+
await fetch(`${parentUrl}/newsletter-sources`, {
|
|
169
400
|
method: 'put',
|
|
170
401
|
response: 'json',
|
|
171
402
|
timeout: 10000,
|
|
@@ -176,9 +407,60 @@ async function claimSources(parentUrl, sources, brandId, assistant) {
|
|
|
176
407
|
},
|
|
177
408
|
});
|
|
178
409
|
} catch (e) {
|
|
179
|
-
assistant.error(`Newsletter generator: Failed to claim source ${source.id}
|
|
410
|
+
assistant.error(`Newsletter generator: Failed to claim source ${source.id}: ${e.message}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Generate a 20-character Firebase push ID (RTDB-style).
|
|
417
|
+
*
|
|
418
|
+
* Format: 20 chars, starts with a timestamp-encoded prefix, lexicographically
|
|
419
|
+
* sortable by creation time. Matches the ID scheme used by Firebase Realtime
|
|
420
|
+
* Database `.push()` and by ITW's `newsletter-sources/{id}`.
|
|
421
|
+
*
|
|
422
|
+
* Used when no real `marketing-campaigns/{id}` doc exists yet — typically only
|
|
423
|
+
* the iteration test. Production cron passes the actual Firestore ID.
|
|
424
|
+
*
|
|
425
|
+
* Algorithm reference: https://gist.github.com/mikelehen/3596a30bd69384624c11
|
|
426
|
+
*/
|
|
427
|
+
let _lastPushTime = 0;
|
|
428
|
+
const _lastRandChars = [];
|
|
429
|
+
const PUSH_CHARS = '-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz';
|
|
430
|
+
|
|
431
|
+
function generatePushId() {
|
|
432
|
+
let now = Date.now();
|
|
433
|
+
const duplicateTime = (now === _lastPushTime);
|
|
434
|
+
_lastPushTime = now;
|
|
435
|
+
|
|
436
|
+
const timeStampChars = new Array(8);
|
|
437
|
+
for (let i = 7; i >= 0; i--) {
|
|
438
|
+
timeStampChars[i] = PUSH_CHARS.charAt(now % 64);
|
|
439
|
+
now = Math.floor(now / 64);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
let id = timeStampChars.join('');
|
|
443
|
+
|
|
444
|
+
if (!duplicateTime) {
|
|
445
|
+
for (let i = 0; i < 12; i++) {
|
|
446
|
+
_lastRandChars[i] = Math.floor(Math.random() * 64);
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
// Increment to ensure monotonicity within the same millisecond
|
|
450
|
+
let i = 11;
|
|
451
|
+
for (; i >= 0 && _lastRandChars[i] === 63; i--) {
|
|
452
|
+
_lastRandChars[i] = 0;
|
|
453
|
+
}
|
|
454
|
+
if (i >= 0) {
|
|
455
|
+
_lastRandChars[i]++;
|
|
180
456
|
}
|
|
181
457
|
}
|
|
458
|
+
|
|
459
|
+
for (let i = 0; i < 12; i++) {
|
|
460
|
+
id += PUSH_CHARS.charAt(_lastRandChars[i]);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return id;
|
|
182
464
|
}
|
|
183
465
|
|
|
184
|
-
module.exports = { generate };
|
|
466
|
+
module.exports = { generate, fetchSources, claimSources, generatePushId };
|
|
@@ -292,8 +292,11 @@ Marketing.prototype.sendCampaign = async function (settings) {
|
|
|
292
292
|
};
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
//
|
|
296
|
-
|
|
295
|
+
// Use pre-rendered HTML (from a generator) if present, otherwise render markdown.
|
|
296
|
+
// Generators like newsletter produce email-safe HTML via MJML — skip the markdown
|
|
297
|
+
// pipeline entirely. tagLinks() is still applied so UTM params get injected.
|
|
298
|
+
let contentHtml = resolvedSettings.contentHtml
|
|
299
|
+
|| (resolvedSettings.content ? md.render(resolvedSettings.content) : '');
|
|
297
300
|
|
|
298
301
|
if (contentHtml) {
|
|
299
302
|
contentHtml = tagLinks(contentHtml, {
|
|
@@ -151,8 +151,9 @@ async function getPublicationId() {
|
|
|
151
151
|
return configPubId;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
// Fuzzy-match by brand name
|
|
155
|
-
|
|
154
|
+
// Fuzzy-match by brand name (guard against uninitialized Manager singleton —
|
|
155
|
+
// happens in test stubs that build their own Manager without init()).
|
|
156
|
+
const brandName = Manager.config?.brand?.name;
|
|
156
157
|
|
|
157
158
|
if (!brandName) {
|
|
158
159
|
console.error('Beehiiv: Brand name is required to find publication');
|
|
@@ -322,6 +323,9 @@ async function resolveSegmentIds() {
|
|
|
322
323
|
*
|
|
323
324
|
* @param {object} options
|
|
324
325
|
* @param {string} options.title - Post title (required)
|
|
326
|
+
* @param {string} [options.publicationId] - Explicit publication ID (bypasses getPublicationId lookup).
|
|
327
|
+
* Preferred when the caller already knows it (e.g. newsletter.js
|
|
328
|
+
* reads it from marketing.beehiiv.publicationId).
|
|
325
329
|
* @param {string} [options.subject] - Email subject line (defaults to title)
|
|
326
330
|
* @param {string} [options.preheader] - Email preview text
|
|
327
331
|
* @param {string} [options.content] - HTML content body
|
|
@@ -332,7 +336,7 @@ async function resolveSegmentIds() {
|
|
|
332
336
|
* @returns {{ success: boolean, id?: string, scheduled?: boolean, error?: string }}
|
|
333
337
|
*/
|
|
334
338
|
async function createPost(options) {
|
|
335
|
-
const publicationId = await getPublicationId();
|
|
339
|
+
const publicationId = options.publicationId || await getPublicationId();
|
|
336
340
|
|
|
337
341
|
if (!publicationId) {
|
|
338
342
|
return { success: false, error: 'Publication not found' };
|