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.
Files changed (69) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/CLAUDE.md +43 -1501
  3. package/TODO-CHARGEBLAST.md +32 -0
  4. package/TODO-email-auth.md +14 -0
  5. package/docs/admin-post-route.md +24 -0
  6. package/docs/ai-library.md +23 -0
  7. package/docs/architecture.md +31 -0
  8. package/docs/auth-hooks.md +74 -0
  9. package/docs/cli-firestore-auth.md +59 -0
  10. package/docs/cli-logs.md +67 -0
  11. package/docs/code-patterns.md +67 -0
  12. package/docs/common-operations.md +64 -0
  13. package/docs/directory-structure.md +119 -0
  14. package/docs/environment-detection.md +7 -0
  15. package/docs/file-naming.md +11 -0
  16. package/docs/marketing-campaigns.md +244 -0
  17. package/docs/marketing-fields.md +25 -0
  18. package/docs/mcp.md +95 -0
  19. package/docs/payment-system.md +325 -0
  20. package/docs/response-headers.md +7 -0
  21. package/docs/routes.md +126 -0
  22. package/docs/sanitization.md +61 -0
  23. package/docs/schemas.md +39 -0
  24. package/docs/stripe-webhook-forwarding.md +18 -0
  25. package/docs/testing.md +129 -0
  26. package/docs/usage-rate-limiting.md +67 -0
  27. package/package.json +8 -4
  28. package/src/defaults/CHANGELOG.md +15 -0
  29. package/src/defaults/CLAUDE.md +8 -4
  30. package/src/defaults/docs/README.md +17 -0
  31. package/src/defaults/test/README.md +33 -0
  32. package/src/manager/events/cron/daily/marketing-newsletter-generate.js +48 -8
  33. package/src/manager/functions/core/actions/api/admin/create-post.js +3 -27
  34. package/src/manager/helpers/utilities.js +21 -0
  35. package/src/manager/index.js +1 -1
  36. package/src/manager/libraries/ai/index.js +162 -0
  37. package/src/manager/libraries/ai/providers/anthropic.js +193 -0
  38. package/src/manager/libraries/ai/providers/claude-code.js +206 -0
  39. package/src/manager/libraries/ai/providers/openai.js +934 -0
  40. package/src/manager/libraries/disposable-domains.json +2 -0
  41. package/src/manager/libraries/email/generators/lib/filter.js +179 -0
  42. package/src/manager/libraries/email/generators/lib/image-host.js +231 -0
  43. package/src/manager/libraries/email/generators/lib/mjml-template.js +83 -0
  44. package/src/manager/libraries/email/generators/lib/structure.js +278 -0
  45. package/src/manager/libraries/email/generators/lib/svg-illustrator.js +184 -0
  46. package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +63 -0
  47. package/src/manager/libraries/email/generators/lib/templates/clean.js +82 -0
  48. package/src/manager/libraries/email/generators/lib/templates/editorial-helpers.js +100 -0
  49. package/src/manager/libraries/email/generators/lib/templates/editorial.js +317 -0
  50. package/src/manager/libraries/email/generators/lib/templates/field-report-helpers.js +138 -0
  51. package/src/manager/libraries/email/generators/lib/templates/field-report.js +497 -0
  52. package/src/manager/libraries/email/generators/lib/templates/index.js +28 -0
  53. package/src/manager/libraries/email/generators/lib/templates/shared.js +534 -0
  54. package/src/manager/libraries/email/generators/newsletter.js +377 -95
  55. package/src/manager/libraries/email/marketing/index.js +5 -2
  56. package/src/manager/libraries/email/providers/beehiiv.js +7 -3
  57. package/src/manager/libraries/openai.js +13 -932
  58. package/src/manager/routes/admin/post/deduplicate-image-alts.js +52 -0
  59. package/src/manager/routes/admin/post/post.js +10 -17
  60. package/templates/_.env +4 -0
  61. package/templates/_.gitignore +1 -0
  62. package/templates/backend-manager-config.json +48 -4
  63. package/test/helpers/slugify.js +394 -0
  64. package/test/marketing/fixtures/clean.json +31 -0
  65. package/test/marketing/fixtures/editorial.json +31 -0
  66. package/test/marketing/fixtures/field-report.json +54 -0
  67. package/test/marketing/newsletter-generate.js +731 -0
  68. package/test/marketing/newsletter-templates.js +512 -0
  69. package/test/routes/admin/deduplicate-image-alts.js +190 -0
@@ -0,0 +1,731 @@
1
+ /**
2
+ * Newsletter generation iteration test.
3
+ *
4
+ * Two modes:
5
+ *
6
+ * 1. FIXTURE MODE (default — runs in every test invocation, no env required)
7
+ * Loads a hand-crafted structure JSON from test/marketing/fixtures/<template>.json
8
+ * and renders it through the active template. No AI, no source fetching,
9
+ * no images, ~25-50ms render time, $0 cost. This is what runs in CI and
10
+ * what you iterate against for layout/CSS changes.
11
+ *
12
+ * Fixture-name resolution priority:
13
+ * 1. NEWSLETTER_FIXTURE=<name> (explicit override)
14
+ * 2. NEWSLETTER_TEMPLATE=<name> (use the fixture for the override template)
15
+ * 3. config.marketing.beehiiv.content.template (use the active brand's template)
16
+ * 4. 'clean' (universal fallback)
17
+ *
18
+ * Add a new template? Drop a matching JSON in test/marketing/fixtures/<name>.json.
19
+ * The fixture's content shape MUST match the template's `schema` export.
20
+ *
21
+ * 2. AI PIPELINE MODE (set TEST_EXTENDED_MODE=1)
22
+ * Pulls real sources from the parent BEM server, runs them through the
23
+ * structure → SVG → MJML pipeline, and writes a preview HTML. Same code
24
+ * path the daily pre-generation cron uses. Costs money (AI tokens). Use
25
+ * this when you want to evaluate prompt quality against real sources.
26
+ *
27
+ * Output (both modes): <projectRoot>/.temp/newsletter/run-<timestamp>/ (one level above functions/)
28
+ *
29
+ * Run from somiibo-backend/functions:
30
+ * npx mgr test project:marketing/newsletter-generate.js # fixture (fast, free)
31
+ * NEWSLETTER_FIXTURE=editorial npx mgr test project:marketing/newsletter-generate.js
32
+ * TEST_EXTENDED_MODE=1 npx mgr test project:marketing/newsletter-generate.js # AI pipeline
33
+ *
34
+ * --- Env vars (most apply to AI mode only) ---
35
+ * NEWSLETTER_FIXTURE=<name> Load and render a specific fixture (fixture mode).
36
+ * NEWSLETTER_TEMPLATE=<name> Override the layout template for this run.
37
+ * NEWSLETTER_THEME_ONLY=1 Reuse the most recent AI run's structure.json + PNGs
38
+ * and re-render. Different from FIXTURE: this loads
39
+ * prior AI output, FIXTURE loads hand-crafted JSON.
40
+ * NEWSLETTER_REUSE_RUN=<dir> Pair with THEME_ONLY: reuse a specific run dir.
41
+ * NEWSLETTER_NO_OPEN=1 Don't auto-open the preview in the browser.
42
+ *
43
+ * --- AI-mode-only env vars (require TEST_EXTENDED_MODE=1) ---
44
+ * NEWSLETTER_PEEK=1 Fetch + list ready sources, do not claim, exit.
45
+ * NEWSLETTER_SOURCE_ID=<id> Generate from one specific source WITHOUT claiming it.
46
+ * NEWSLETTER_LIMIT=10 Sources per category for PEEK (default 10).
47
+ * NEWSLETTER_RELEASE=1 Reset locally-tracked claimed sources back to 'ready'.
48
+ * NEWSLETTER_NO_IMAGES=1 Skip SVG/PNG generation (fast iteration on copy only).
49
+ * NEWSLETTER_PROVIDER_STRUCTURE=X Override structure provider (openai|anthropic).
50
+ * NEWSLETTER_PROVIDER_SVG=X Override SVG provider (openai|anthropic).
51
+ * NEWSLETTER_CAMPAIGN_ID=<id> Override the auto-generated campaign ID (folder name in newsletter-assets).
52
+ *
53
+ * In EXTENDED mode, the test ALWAYS uploads PNGs + newsletter.html to GitHub
54
+ * and creates a Beehiiv draft — same side effects as the production cron.
55
+ * No opt-out, no per-side-effect flag. EXTENDED = production-equivalent run.
56
+ *
57
+ * --- THEME_ONLY-mode-only env vars ---
58
+ * NEWSLETTER_BEEHIIV_UPLOAD=1 After re-rendering, upload as a new Beehiiv draft (rare).
59
+ *
60
+ * AI mode requires:
61
+ * BACKEND_MANAGER_KEY — authenticates with parent as admin
62
+ * OPENAI_API_KEY — structure provider (or BACKEND_MANAGER_OPENAI_API_KEY)
63
+ * ANTHROPIC_API_KEY — SVG provider (or BACKEND_MANAGER_ANTHROPIC_API_KEY)
64
+ * PARENT_API_URL — or set `parent` in backend-manager-config.json
65
+ *
66
+ * Fixture mode requires: nothing.
67
+ */
68
+ const path = require('path');
69
+ const { execSync } = require('child_process');
70
+ const fetch = require('wonderful-fetch');
71
+ const jetpack = require('fs-jetpack');
72
+
73
+ module.exports = {
74
+ description: 'Generate a newsletter preview (fixture by default, full AI pipeline with TEST_EXTENDED_MODE)',
75
+ auth: 'none',
76
+ timeout: 180000, // 3 min — AI + parallel SVG calls + rasterization
77
+ // The test ALWAYS runs. By default it renders a hand-crafted fixture for the
78
+ // active template (fast, free, deterministic — catches layout regressions in
79
+ // CI). Set TEST_EXTENDED_MODE=1 to switch to the full AI pipeline that fetches
80
+ // real sources, calls the structure + SVG providers, and writes a preview.
81
+ // Other modes (FIXTURE, THEME_ONLY, RELEASE, PEEK) are also opt-in via env.
82
+ async run({ assert, config }) {
83
+ const env = process.env;
84
+
85
+ // --- Apply env overrides into newsletterConfig ---
86
+ // Newsletter config now lives under marketing.beehiiv.content.
87
+ const newsletterConfig = JSON.parse(JSON.stringify(config.marketing?.beehiiv?.content || {}));
88
+
89
+ if (env.NEWSLETTER_PROVIDER_STRUCTURE) {
90
+ newsletterConfig.provider = { ...(newsletterConfig.provider || {}), structure: env.NEWSLETTER_PROVIDER_STRUCTURE };
91
+ }
92
+ if (env.NEWSLETTER_PROVIDER_SVG) {
93
+ newsletterConfig.provider = { ...(newsletterConfig.provider || {}), svg: env.NEWSLETTER_PROVIDER_SVG };
94
+ }
95
+ if (env.NEWSLETTER_TEMPLATE) {
96
+ newsletterConfig.template = env.NEWSLETTER_TEMPLATE;
97
+ }
98
+
99
+ // --- Build the output dir ---
100
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/-(\d{3})Z$/, '');
101
+ // .temp lives at the CONSUMER PROJECT ROOT (e.g. somiibo-backend/.temp), not
102
+ // inside functions/. Matches the convention used by UJM, BXM, electron-manager,
103
+ // etc. — every project's transient cache directory sits at the repo root.
104
+ const outRoot = path.join(process.cwd(), '..', '.temp', 'newsletter');
105
+ const runDir = path.join(outRoot, `run-${stamp}`);
106
+ const claimedFile = path.join(outRoot, '.claimed.json');
107
+
108
+ jetpack.dir(runDir);
109
+
110
+ // --- Release mode (early return) ---
111
+ if (env.NEWSLETTER_RELEASE) {
112
+ const released = await releaseAll(claimedFile);
113
+ console.log(`Released ${released} previously claimed source(s) back to ready`);
114
+ return;
115
+ }
116
+
117
+ // --- Fixture mode ---
118
+ // Default behavior of this test. Loads a hand-crafted structure JSON from
119
+ // test/marketing/fixtures/{name}.json and renders it directly. No AI, no
120
+ // source fetching, no images. Used as the deterministic "predictable
121
+ // preview" loop AND as the default test-suite render.
122
+ //
123
+ // Fixture-name resolution priority:
124
+ // 1. NEWSLETTER_FIXTURE=<name> (explicit override)
125
+ // 2. NEWSLETTER_TEMPLATE=<name> (use the fixture for the override template)
126
+ // 3. newsletterConfig.template (use the fixture for the active brand's template)
127
+ // 4. 'clean' (universal fallback)
128
+ //
129
+ // This means a fresh `npx mgr test` against any consumer project picks the
130
+ // brand's configured template, renders the matching fixture, and produces
131
+ // a deterministic preview HTML — no AI, no money spent.
132
+ //
133
+ // The full AI pipeline only runs when TEST_EXTENDED_MODE=1 is set (below).
134
+ // Explicit NEWSLETTER_FIXTURE wins even in EXTENDED mode (you can ask for
135
+ // a fixture render mid-AI-iteration without re-cycling envs).
136
+ // NEWSLETTER_THEME_ONLY takes precedence below — it's a no-AI mode that
137
+ // reuses prior AI output, distinct from fixture mode.
138
+ if (env.NEWSLETTER_THEME_ONLY) {
139
+ // Fall through to the theme-only block below.
140
+ } else if (!env.TEST_EXTENDED_MODE || env.NEWSLETTER_FIXTURE) {
141
+ const requestedFixture = (env.NEWSLETTER_FIXTURE || env.NEWSLETTER_TEMPLATE || newsletterConfig.template || 'clean')
142
+ .replace(/^fixture:/, '');
143
+ const fixturePath = path.join(__dirname, 'fixtures', `${requestedFixture}.json`);
144
+
145
+ assert.ok(jetpack.exists(fixturePath),
146
+ `Fixture not found: ${fixturePath}. Available fixtures: ${jetpack.list(path.join(__dirname, 'fixtures')).filter((f) => f.endsWith('.json')).join(', ')}. Every registered template should ship a matching fixture in test/marketing/fixtures/.`);
147
+
148
+ const structure = jetpack.read(fixturePath, 'json');
149
+
150
+ assert.ok(structure, `Fixture ${requestedFixture}.json failed to parse as JSON`);
151
+
152
+ // Pin the template to the fixture name — fixtures are authored for a
153
+ // specific template's content shape, so this prevents the obvious bug
154
+ // of "render the field-report fixture through the clean template".
155
+ // Explicit NEWSLETTER_TEMPLATE override is still honored above.
156
+ if (!env.NEWSLETTER_TEMPLATE) {
157
+ newsletterConfig.template = requestedFixture;
158
+ }
159
+
160
+ const { renderNewsletter } = require('../../src/manager/libraries/email/generators/lib/mjml-template.js');
161
+
162
+ const renderStart = Date.now();
163
+ const { html, mjml, template: templateName } = await renderNewsletter({
164
+ brand: config.brand,
165
+ newsletterConfig,
166
+ structure,
167
+ imagePaths: [],
168
+ campaign: `fixture-${requestedFixture}`,
169
+ });
170
+ const renderMs = Date.now() - renderStart;
171
+
172
+ const previewPath = path.join(runDir, 'newsletter.html');
173
+ jetpack.write(previewPath, html);
174
+ jetpack.write(path.join(runDir, 'newsletter.mjml'), mjml || '');
175
+ jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(structure, null, 2));
176
+ jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify({
177
+ mode: 'fixture',
178
+ fixture: requestedFixture,
179
+ template: templateName,
180
+ renderMs,
181
+ timestamp: new Date().toISOString(),
182
+ }, null, 2));
183
+
184
+ console.log(`\n[fixture=${requestedFixture}] Rendered in ${renderMs}ms using template: ${templateName}`);
185
+ console.log(`[fixture=${requestedFixture}] Preview: ${previewPath}`);
186
+ console.log(`[fixture=${requestedFixture}] (Set TEST_EXTENDED_MODE=1 to switch to the full AI pipeline.)`);
187
+
188
+ if (!env.NEWSLETTER_NO_OPEN && process.platform === 'darwin') {
189
+ try { execSync(`open "${previewPath}"`); } catch (e) { /* no-op */ }
190
+ }
191
+
192
+ assert.ok(html.includes('<html'), 'Rendered HTML');
193
+ return;
194
+ }
195
+
196
+ // --- Theme-only mode (early return) ---
197
+ // Reuse a previous run's structure.json + PNGs to re-render MJML/HTML only.
198
+ // Sub-second iteration on layout / theme tokens with no AI cost.
199
+ if (env.NEWSLETTER_THEME_ONLY) {
200
+ const sourceRun = resolveReuseRun({
201
+ outRoot,
202
+ explicit: env.NEWSLETTER_REUSE_RUN,
203
+ });
204
+
205
+ assert.ok(sourceRun, 'Found a previous run to reuse (set NEWSLETTER_REUSE_RUN=run-<stamp> if needed)');
206
+
207
+ console.log(`[theme-only] reusing structure + images from: ${path.basename(sourceRun)}`);
208
+
209
+ const structure = jetpack.read(path.join(sourceRun, 'structure.json'), 'json');
210
+
211
+ assert.ok(structure?.sections?.length, 'Previous run has a valid structure.json');
212
+
213
+ // Copy section PNGs from previous run into this run's dir so paths resolve
214
+ const imagePaths = [];
215
+
216
+ for (let i = 0; i < structure.sections.length; i++) {
217
+ const filename = `section-${i + 1}.png`;
218
+ const srcPath = path.join(sourceRun, filename);
219
+
220
+ if (jetpack.exists(srcPath)) {
221
+ jetpack.copy(srcPath, path.join(runDir, filename));
222
+ imagePaths.push(`./${filename}`);
223
+ } else {
224
+ imagePaths.push(null);
225
+ }
226
+ }
227
+
228
+ const { renderNewsletter } = require('../../src/manager/libraries/email/generators/lib/mjml-template.js');
229
+
230
+ const renderStart = Date.now();
231
+ const { html, mjml, template: templateName } = await renderNewsletter({
232
+ brand: config.brand,
233
+ newsletterConfig,
234
+ structure,
235
+ imagePaths,
236
+ campaign: `theme-only-${stamp}`,
237
+ });
238
+ const renderMs = Date.now() - renderStart;
239
+
240
+ const previewPath = path.join(runDir, 'newsletter.html');
241
+ jetpack.write(previewPath, html);
242
+ jetpack.write(path.join(runDir, 'newsletter.mjml'), mjml || '');
243
+ jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(structure, null, 2));
244
+ jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify({
245
+ mode: 'theme-only',
246
+ reusedFrom: path.basename(sourceRun),
247
+ template: templateName,
248
+ renderMs,
249
+ timestamp: new Date().toISOString(),
250
+ }, null, 2));
251
+
252
+ console.log(`\n[theme-only] Rendered in ${renderMs}ms using template: ${templateName}`);
253
+ console.log(`[theme-only] Preview: ${previewPath}`);
254
+
255
+ // Optional: upload to Beehiiv as a draft
256
+ if (env.NEWSLETTER_BEEHIIV_UPLOAD) {
257
+ await uploadDraftToBeehiiv({
258
+ html,
259
+ structure,
260
+ config,
261
+ assistant: console,
262
+ runDir,
263
+ });
264
+ }
265
+
266
+ if (!env.NEWSLETTER_NO_OPEN && process.platform === 'darwin') {
267
+ try { execSync(`open "${previewPath}"`); } catch (e) { /* no-op */ }
268
+ }
269
+
270
+ assert.ok(html.includes('<html'), 'Rendered HTML');
271
+ return;
272
+ }
273
+
274
+ // --- AI pipeline path (TEST_EXTENDED_MODE) ---
275
+ // Everything below this point talks to the real parent server and the AI
276
+ // providers. The parent URL is required for any of it.
277
+ const parentUrl = env.PARENT_API_URL || config.parent;
278
+ assert.ok(parentUrl, 'PARENT_API_URL (env) or parent (config) must be set for the AI pipeline. Set TEST_EXTENDED_MODE=1 to run it, or omit TEST_EXTENDED_MODE for the fast fixture preview.');
279
+
280
+ // --- Peek mode (early return) ---
281
+ if (env.NEWSLETTER_PEEK) {
282
+ const sources = await peekSources({
283
+ parentUrl,
284
+ categories: newsletterConfig.categories || [],
285
+ limit: parseInt(env.NEWSLETTER_LIMIT, 10) || 10,
286
+ key: env.BACKEND_MANAGER_KEY,
287
+ });
288
+
289
+ console.log(`\nPeek mode — ${sources.length} ready source(s):\n`);
290
+ sources.forEach((s, i) => {
291
+ const raw = s.source || {};
292
+ const cats = (s.categories || []).join(', ') || s.category || '(none)';
293
+ console.log(`[${i + 1}] ${s.id}`);
294
+ console.log(` Categories: ${cats}`);
295
+ console.log(` From: ${raw.from || s.from || '(unknown)'}`);
296
+ console.log(` Subject: ${raw.subject || s.subject || '(none)'}`);
297
+ console.log(` Headline: ${s.ai?.headline || '(none — not yet AI-processed)'}`);
298
+ console.log('');
299
+ });
300
+
301
+ assert.ok(true, `Peeked ${sources.length} sources`);
302
+ return;
303
+ }
304
+
305
+ // --- Fetch sources (real claim) ---
306
+ const sources = await fetchSourcesForRun({
307
+ parentUrl,
308
+ newsletterConfig,
309
+ brandId: config.brand?.id,
310
+ sourceId: env.NEWSLETTER_SOURCE_ID,
311
+ key: env.BACKEND_MANAGER_KEY,
312
+ });
313
+
314
+ assert.ok(sources.length > 0, 'Fetched at least one source from parent server');
315
+
316
+ // Track claimed IDs for later --release-all
317
+ appendClaimed(claimedFile, sources.map((s) => s.id));
318
+
319
+ // --- Build a stub Manager + assistant to call the BEM generator library ---
320
+ // Newsletter config nests under beehiiv. Force `beehiiv.enabled: true` because
321
+ // the iteration test IS the explicit trigger — we're not checking whether
322
+ // beehiiv is configured for prod use, we're driving the generator directly.
323
+ const Manager = buildManagerStub({
324
+ ...config,
325
+ marketing: {
326
+ ...config.marketing,
327
+ beehiiv: {
328
+ ...(config.marketing?.beehiiv || {}),
329
+ enabled: true,
330
+ content: newsletterConfig,
331
+ },
332
+ },
333
+ });
334
+ const assistant = buildAssistantStub(Manager);
335
+
336
+ // --- Run the production generator with the local-persist image hook ---
337
+ const generator = require('../../src/manager/libraries/email/generators/newsletter.js');
338
+
339
+ // EXTENDED mode is a MIRROR of the production cron — no escape hatches.
340
+ // GH upload always happens (PNGs + newsletter.html), Beehiiv draft upload
341
+ // always happens (governed inside newsletter.js by beehiiv.enabled, which
342
+ // we force true above). If you don't want the side effects, run fixture
343
+ // mode instead.
344
+ //
345
+ // persistImage is a side-effect callback that writes PNG+SVG to runDir for
346
+ // local preview / debug. Its return value is ignored when imageHost: 'github'
347
+ // because the generator uses the uploaded CDN URLs in the rendered HTML.
348
+ const persistImage = async (image, idx) => {
349
+ const filename = `section-${idx + 1}.png`;
350
+ const svgFilename = `section-${idx + 1}.svg`;
351
+
352
+ jetpack.write(path.join(runDir, filename), image.png);
353
+ jetpack.write(path.join(runDir, svgFilename), image.svg);
354
+
355
+ return `./${filename}`;
356
+ };
357
+
358
+ // The campaignId becomes the GitHub folder name. Either pinned via env
359
+ // (for repeatable iteration on the same folder) or auto-generated by
360
+ // newsletter.generate() in Firestore auto-ID shape.
361
+ const campaignId = env.NEWSLETTER_CAMPAIGN_ID || undefined;
362
+
363
+ console.log(`[extended] uploading to itw-creative-works/newsletter-assets/${config.brand?.id}/${campaignId || '<auto-id>'}/ + Beehiiv draft`);
364
+
365
+ const result = await generator.generate(
366
+ Manager,
367
+ assistant,
368
+ { name: `Somiibo Newsletter — Iteration ${stamp}` },
369
+ {
370
+ sources,
371
+ skipClaim: true, // We manage the claim/release lifecycle ourselves
372
+ skipImages: !!env.NEWSLETTER_NO_IMAGES,
373
+ // Local disk persistence runs unconditionally (for preview/debug)
374
+ persistImage,
375
+ // EXTENDED always uploads to GitHub — mirrors production cron exactly
376
+ imageHost: 'github',
377
+ campaignId,
378
+ }
379
+ );
380
+
381
+ assert.ok(result, 'Generator returned a result');
382
+ assert.ok(result.contentHtml, 'Generator returned contentHtml');
383
+ assert.ok(result.structure?.sections?.length >= 2, 'Has at least 2 sections');
384
+
385
+ // --- Write outputs ---
386
+ const previewPath = path.join(runDir, 'newsletter.html');
387
+ jetpack.write(previewPath, result.contentHtml);
388
+ jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(result.structure, null, 2));
389
+ jetpack.write(path.join(runDir, 'newsletter.mjml'), result.mjml || '');
390
+ jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify(result.meta || {}, null, 2));
391
+
392
+ console.log(`\nNewsletter preview written: ${previewPath}`);
393
+ console.log(`Subject: ${result.subject}`);
394
+ console.log(`Preheader: ${result.preheader}`);
395
+ console.log(`Sections: ${result.structure.sections.length}`);
396
+ if (result.meta?.totals) {
397
+ const t = result.meta.totals;
398
+ console.log(`\nRun summary:`);
399
+ console.log(` Total duration: ${(result.meta.totalDurationMs / 1000).toFixed(1)}s`);
400
+ console.log(` AI calls: ${t.aiCalls}`);
401
+ console.log(` Tokens: ${t.inputTokens} in / ${t.outputTokens} out (${t.totalTokens} total)`);
402
+ console.log(` Cost: $${t.totalCostUSD}`);
403
+ console.log(` Filter: provider=${result.meta.steps.filter?.provider} model=${result.meta.steps.filter?.model} (${result.meta.steps.filter?.durationMs}ms)`);
404
+ console.log(` Structure: provider=${result.meta.steps.structure?.provider} model=${result.meta.steps.structure?.model} (${result.meta.steps.structure?.durationMs}ms)`);
405
+ for (const img of result.meta.steps.images || []) {
406
+ console.log(` Image ${img.section}: provider=${img.provider} model=${img.model} (${img.durationMs}ms${img.fallback ? ', FALLBACK' : ''})`);
407
+ }
408
+ }
409
+
410
+ // Beehiiv draft upload already happened inside generator.generate() —
411
+ // newsletter.js calls beehiiv.createPost(draft) after the GH HTML upload.
412
+ // Look for assets.beehiivPostId in the result (null on free plan; non-null
413
+ // once the Beehiiv account is on Enterprise).
414
+ if (result.assets?.beehiivPostId) {
415
+ console.log(`Beehiiv: draft post created — id=${result.assets.beehiivPostId}`);
416
+ }
417
+
418
+ // --- Auto-open in browser (macOS) ---
419
+ if (!env.NEWSLETTER_NO_OPEN && process.platform === 'darwin') {
420
+ try {
421
+ execSync(`open "${previewPath}"`);
422
+ } catch (e) {
423
+ console.warn('Failed to auto-open preview:', e.message);
424
+ }
425
+ }
426
+ },
427
+ };
428
+
429
+ /**
430
+ * Upload the rendered HTML to Beehiiv as a draft post (never sends). Uses the
431
+ * v2 Posts API directly so it works against the test's stub Manager.
432
+ *
433
+ * Writes the Beehiiv response to {runDir}/beehiiv-upload.json for inspection.
434
+ *
435
+ * Required env: BEEHIIV_API_KEY
436
+ * Required config: marketing.beehiiv.publicationId (or we fuzzy-match by brand name)
437
+ */
438
+ async function uploadDraftToBeehiiv({ html, structure, config, runDir }) {
439
+ const apiKey = process.env.BEEHIIV_API_KEY;
440
+
441
+ if (!apiKey) {
442
+ console.warn('[beehiiv] Skipping upload — BEEHIIV_API_KEY not set');
443
+ return;
444
+ }
445
+
446
+ const BASE_URL = 'https://api.beehiiv.com/v2';
447
+ const headers = { 'Authorization': `Bearer ${apiKey}` };
448
+
449
+ // Resolve publication ID — config first, then fuzzy-match by brand name
450
+ let publicationId = config?.marketing?.beehiiv?.publicationId;
451
+ const brandName = config?.brand?.name;
452
+
453
+ if (!publicationId && brandName) {
454
+ try {
455
+ const pubs = await fetch(`${BASE_URL}/publications?limit=100`, {
456
+ response: 'json',
457
+ headers,
458
+ timeout: 10000,
459
+ });
460
+ const brandLower = brandName.toLowerCase();
461
+ const matched = (pubs.data || []).find((p) =>
462
+ p.name.toLowerCase() === brandLower
463
+ || p.name.toLowerCase().includes(brandLower)
464
+ || brandLower.includes(p.name.toLowerCase())
465
+ );
466
+ publicationId = matched?.id;
467
+ } catch (e) {
468
+ console.error('[beehiiv] Publication lookup failed:', e.message);
469
+ }
470
+ }
471
+
472
+ if (!publicationId) {
473
+ console.warn(`[beehiiv] Skipping upload — could not resolve publication ID (brand=${brandName})`);
474
+ return;
475
+ }
476
+
477
+ // Build the post — always draft from the test (never sends)
478
+ const body = {
479
+ title: structure.subject || `Newsletter ${new Date().toISOString()}`,
480
+ status: 'draft',
481
+ body_content: html,
482
+ email_settings: {
483
+ subject_line: structure.subject,
484
+ preview_text: structure.preheader || '',
485
+ },
486
+ };
487
+
488
+ console.log(`[beehiiv] Uploading draft to publication ${publicationId}...`);
489
+
490
+ try {
491
+ const data = await fetch(`${BASE_URL}/publications/${publicationId}/posts`, {
492
+ method: 'post',
493
+ response: 'json',
494
+ headers,
495
+ timeout: 30000,
496
+ body,
497
+ });
498
+
499
+ if (data.data?.id) {
500
+ const editUrl = `https://app.beehiiv.com/posts/${data.data.id}`;
501
+ console.log(`[beehiiv] ✓ Draft uploaded: ${data.data.id}`);
502
+ console.log(`[beehiiv] Edit at: ${editUrl}`);
503
+ jetpack.write(path.join(runDir, 'beehiiv-upload.json'), JSON.stringify({
504
+ publicationId,
505
+ postId: data.data.id,
506
+ editUrl,
507
+ status: 'draft',
508
+ uploadedAt: new Date().toISOString(),
509
+ response: data.data,
510
+ }, null, 2));
511
+ } else {
512
+ console.error('[beehiiv] Upload returned no post ID:', JSON.stringify(data));
513
+ }
514
+ } catch (e) {
515
+ console.error('[beehiiv] Upload failed:', e.message);
516
+ }
517
+ }
518
+
519
+ // --- Helpers ---
520
+
521
+ /**
522
+ * GET /newsletter/sources without claiming (claimFor omitted).
523
+ */
524
+ async function peekSources({ parentUrl, categories, limit, key }) {
525
+ if (!categories.length) {
526
+ const data = await fetch(`${parentUrl}/newsletter-sources`, {
527
+ method: 'get',
528
+ response: 'json',
529
+ timeout: 15000,
530
+ query: { limit, backendManagerKey: key },
531
+ });
532
+
533
+ return data.sources || [];
534
+ }
535
+
536
+ const all = [];
537
+ for (const category of categories) {
538
+ const data = await fetch(`${parentUrl}/newsletter-sources`, {
539
+ method: 'get',
540
+ response: 'json',
541
+ timeout: 15000,
542
+ query: { category, limit, backendManagerKey: key },
543
+ });
544
+
545
+ all.push(...(data.sources || []));
546
+ }
547
+
548
+ return all;
549
+ }
550
+
551
+ /**
552
+ * Fetch sources for an actual generation run. Either:
553
+ * - A specific source by id — preview only, NO claim (iterate repeatedly on the same source)
554
+ * - Or N per category (claims them atomically for the brand)
555
+ *
556
+ * When NEWSLETTER_SOURCE_ID is set, we look the source up in any status
557
+ * (ready, claimed, used) so you can keep iterating on it across runs without
558
+ * the parent server's claim mechanism marking it consumed.
559
+ */
560
+ async function fetchSourcesForRun({ parentUrl, newsletterConfig, brandId, sourceId, key }) {
561
+ if (sourceId) {
562
+ // Peek across ALL ready sources (no claim). Search broadly first, then
563
+ // fall back to any-status if needed. We never call claimFor with sourceId
564
+ // so the source stays available for future runs.
565
+ const all = await peekSources({
566
+ parentUrl,
567
+ categories: newsletterConfig.categories || [],
568
+ limit: 100,
569
+ key,
570
+ });
571
+
572
+ const match = all.find((s) => s.id === sourceId);
573
+
574
+ if (match) {
575
+ console.log(`[source-id-mode] Found ${sourceId} in ready pool, using WITHOUT claiming (iteration mode)`);
576
+ return [match];
577
+ }
578
+
579
+ // Not in ready pool — could be already claimed/used. Try the broad endpoint without category filter.
580
+ const broad = await peekSources({
581
+ parentUrl,
582
+ categories: [],
583
+ limit: 100,
584
+ key,
585
+ });
586
+ const matchBroad = broad.find((s) => s.id === sourceId);
587
+
588
+ if (matchBroad) {
589
+ console.log(`[source-id-mode] Found ${sourceId} (outside configured categories), using WITHOUT claiming`);
590
+ return [matchBroad];
591
+ }
592
+
593
+ throw new Error(`Source ${sourceId} not found. It may have been used (status: used) or never existed.`);
594
+ }
595
+
596
+ // Normal: claim N per category
597
+ const categories = newsletterConfig.categories || [];
598
+
599
+ if (!categories.length) {
600
+ throw new Error('marketing.beehiiv.content.categories must have at least one entry');
601
+ }
602
+
603
+ const all = [];
604
+ for (const category of categories) {
605
+ const data = await fetch(`${parentUrl}/newsletter-sources`, {
606
+ method: 'get',
607
+ response: 'json',
608
+ timeout: 15000,
609
+ query: { category, limit: 3, claimFor: brandId, backendManagerKey: key },
610
+ });
611
+ all.push(...(data.sources || []));
612
+ }
613
+
614
+ return all;
615
+ }
616
+
617
+ /**
618
+ * Find a previous run directory to reuse for theme-only iteration.
619
+ * Either honors NEWSLETTER_REUSE_RUN explicitly, or picks the most recent
620
+ * run-* directory under outRoot. Skips the current in-progress run dir.
621
+ */
622
+ function resolveReuseRun({ outRoot, explicit }) {
623
+ if (explicit) {
624
+ const candidate = path.join(outRoot, explicit);
625
+ return jetpack.exists(candidate) ? candidate : null;
626
+ }
627
+
628
+ if (!jetpack.exists(outRoot)) {
629
+ return null;
630
+ }
631
+
632
+ const entries = jetpack.list(outRoot) || [];
633
+ const runs = entries
634
+ .filter((name) => name.startsWith('run-'))
635
+ .map((name) => ({ name, full: path.join(outRoot, name) }))
636
+ .filter((r) => {
637
+ // Must contain a structure.json to be reusable
638
+ return jetpack.exists(path.join(r.full, 'structure.json')) === 'file';
639
+ })
640
+ .sort((a, b) => (a.name < b.name ? 1 : -1));
641
+
642
+ return runs[0]?.full || null;
643
+ }
644
+
645
+ function appendClaimed(claimedFile, ids) {
646
+ const existing = jetpack.exists(claimedFile) ? jetpack.read(claimedFile, 'json') || [] : [];
647
+ const stamped = ids.map((id) => ({ id, claimedAt: Math.round(Date.now() / 1000) }));
648
+ jetpack.write(claimedFile, [...existing, ...stamped]);
649
+ }
650
+
651
+ async function releaseAll(claimedFile) {
652
+ if (!jetpack.exists(claimedFile)) {
653
+ return 0;
654
+ }
655
+
656
+ const entries = jetpack.read(claimedFile, 'json') || [];
657
+
658
+ if (!entries.length) {
659
+ return 0;
660
+ }
661
+
662
+ const saPath = process.env.PARENT_SERVICE_ACCOUNT_PATH;
663
+
664
+ if (!saPath) {
665
+ throw new Error('PARENT_SERVICE_ACCOUNT_PATH env var is required for release. Point it at the parent project service-account.json.');
666
+ }
667
+
668
+ // Lazy-require firebase-admin
669
+ const admin = require('firebase-admin');
670
+
671
+ if (!admin.apps.length) {
672
+ admin.initializeApp({
673
+ credential: admin.credential.cert(require(saPath)),
674
+ });
675
+ }
676
+
677
+ const db = admin.firestore();
678
+ let released = 0;
679
+
680
+ for (const entry of entries) {
681
+ try {
682
+ await db.collection('newsletter-sources').doc(entry.id).update({
683
+ status: 'ready',
684
+ usedBy: null,
685
+ claimedBy: null,
686
+ usedAt: null,
687
+ claimedAt: null,
688
+ });
689
+ released++;
690
+ } catch (e) {
691
+ console.warn(`Failed to release ${entry.id}: ${e.message}`);
692
+ }
693
+ }
694
+
695
+ // Clear the tracker
696
+ jetpack.write(claimedFile, []);
697
+
698
+ return released;
699
+ }
700
+
701
+ /**
702
+ * Minimal Manager stub for tests. Provides what newsletter.js + lib/* read:
703
+ * Manager.config — full config (used for brand, marketing.beehiiv.content, parent)
704
+ * Manager.AI(assistant) — instantiates the unified AI library
705
+ */
706
+ function buildManagerStub(config) {
707
+ return {
708
+ config,
709
+ libraries: {},
710
+ AI(assistant) {
711
+ const AI = require('../../src/manager/libraries/ai/index.js');
712
+ return new AI(assistant);
713
+ },
714
+ };
715
+ }
716
+
717
+ function buildAssistantStub(Manager) {
718
+ return {
719
+ Manager,
720
+ user: null,
721
+ request: { geolocation: { ip: 'local' } },
722
+ log: (...args) => console.log('[ASSIST]', ...args),
723
+ error: (...args) => console.error('[ASSIST ERROR]', ...args),
724
+ getUser: () => null,
725
+ errorify: (msg, opts) => {
726
+ const err = new Error(typeof msg === 'string' ? msg : String(msg));
727
+ if (opts) Object.assign(err, opts);
728
+ return err;
729
+ },
730
+ };
731
+ }