backend-manager 5.9.6 → 5.9.8

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 (215) hide show
  1. package/package.json +5 -3
  2. package/src/manager/events/cron/runner.js +0 -1
  3. package/src/manager/libraries/email/data/disposable-domains.json +21 -0
  4. package/src/test/fixtures/firebase-project/.temp/test-mode.json +1 -1
  5. package/src/test/fixtures/firebase-project/database-debug.log +8 -8
  6. package/src/test/fixtures/firebase-project/firestore-debug.log +55 -59
  7. package/src/test/fixtures/firebase-project/pubsub-debug.log +3 -3
  8. package/templates/_.env +42 -0
  9. package/templates/_.gitignore +60 -0
  10. package/templates/backend-manager-config.json +249 -0
  11. package/templates/database.rules.json +83 -0
  12. package/templates/firebase.json +66 -0
  13. package/templates/firestore.indexes.json +4 -0
  14. package/templates/firestore.rules +112 -0
  15. package/templates/public/404.html +26 -0
  16. package/templates/public/index.html +24 -0
  17. package/templates/remoteconfig.template.json +1 -0
  18. package/templates/storage-lifecycle-config-1-day.json +9 -0
  19. package/templates/storage-lifecycle-config-30-days.json +9 -0
  20. package/templates/storage.rules +8 -0
  21. package/test/_init/accounts-validation.js +58 -0
  22. package/test/_legacy/ai/index.js +231 -0
  23. package/test/_legacy/ai/test.jpg +0 -0
  24. package/test/_legacy/ai/test.pdf +0 -0
  25. package/test/_legacy/index.js +31 -0
  26. package/test/_legacy/payment-resolver/chargebee/orders/refunded.json +0 -0
  27. package/test/_legacy/payment-resolver/chargebee/orders/unpaid.json +98 -0
  28. package/test/_legacy/payment-resolver/chargebee/subscriptions/active.json +578 -0
  29. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-in-trial.json +38 -0
  30. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-skipped-to-active.json +135 -0
  31. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-active-to-cancelled.json +42 -0
  32. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-active.json +44 -0
  33. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-cancelled.json +39 -0
  34. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-refund.json +143 -0
  35. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-suspended.json +48 -0
  36. package/test/_legacy/payment-resolver/coinbase/orders/regular.json +204 -0
  37. package/test/_legacy/payment-resolver/coinbase/subscriptions/cancelled.json +204 -0
  38. package/test/_legacy/payment-resolver/coinbase/subscriptions/paid-2.json +125 -0
  39. package/test/_legacy/payment-resolver/index.js +1558 -0
  40. package/test/_legacy/payment-resolver/paypal/orders/refunded.json +0 -0
  41. package/test/_legacy/payment-resolver/paypal/orders/regular.json +120 -0
  42. package/test/_legacy/payment-resolver/paypal/subscriptions/active-refund-previous-stmnt.json +323 -0
  43. package/test/_legacy/payment-resolver/paypal/subscriptions/active.json +192 -0
  44. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-in-trial.json +125 -0
  45. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-payment-not-complete.json +76 -0
  46. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-payment-overdue-2.json +114 -0
  47. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-payment-overdue.json +114 -0
  48. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-active-to-cancelled.json +111 -0
  49. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-active.json +132 -0
  50. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-cancelled.json +107 -0
  51. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-expired.json +91 -0
  52. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-refund.json +147 -0
  53. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-suspended.json +120 -0
  54. package/test/_legacy/payment-resolver/stripe/orders/refunded.json +0 -0
  55. package/test/_legacy/payment-resolver/stripe/orders/regular.json +91 -0
  56. package/test/_legacy/payment-resolver/stripe/subscriptions/active.json +421 -0
  57. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-in-trial.json +349 -0
  58. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-active-failed-authorization.json +430 -0
  59. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-active.json +319 -0
  60. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-cancelled.json +319 -0
  61. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-refund.json +414 -0
  62. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-suspended.json +319 -0
  63. package/test/_legacy/payment-resolver/stripe/subscriptions/unsure.json +339 -0
  64. package/test/_legacy/test-new +1178 -0
  65. package/test/_legacy/test.md +89 -0
  66. package/test/_legacy/usage.js +175 -0
  67. package/test/_legacy/user.js +31 -0
  68. package/test/ai/tools-live.js +170 -0
  69. package/test/boot/emulator-boots.js +37 -0
  70. package/test/content/blog-generate.js +160 -0
  71. package/test/email/campaign-send.js +45 -0
  72. package/test/email/consent-lifecycle.js +257 -0
  73. package/test/email/feedback-and-plain-send.js +52 -0
  74. package/test/email/fixtures/clean.json +30 -0
  75. package/test/email/fixtures/editorial.json +30 -0
  76. package/test/email/fixtures/field-report.json +53 -0
  77. package/test/email/marketing-lifecycle.js +132 -0
  78. package/test/email/newsletter-generate.js +819 -0
  79. package/test/email/newsletter-templates.js +491 -0
  80. package/test/email/templates.js +207 -0
  81. package/test/email/transactional-send.js +34 -0
  82. package/test/email/transactional.js +560 -0
  83. package/test/email/validation.js +710 -0
  84. package/test/events/auth-delete-race.js +201 -0
  85. package/test/events/payments/journey-payments-cancel-endpoint.js +100 -0
  86. package/test/events/payments/journey-payments-cancel.js +182 -0
  87. package/test/events/payments/journey-payments-discount.js +89 -0
  88. package/test/events/payments/journey-payments-failure.js +146 -0
  89. package/test/events/payments/journey-payments-legacy-product.js +149 -0
  90. package/test/events/payments/journey-payments-one-time-failure.js +111 -0
  91. package/test/events/payments/journey-payments-one-time.js +136 -0
  92. package/test/events/payments/journey-payments-plan-change.js +144 -0
  93. package/test/events/payments/journey-payments-refund-webhook.js +180 -0
  94. package/test/events/payments/journey-payments-suspend.js +181 -0
  95. package/test/events/payments/journey-payments-trial-cancel.js +130 -0
  96. package/test/events/payments/journey-payments-trial.js +171 -0
  97. package/test/events/payments/journey-payments-uid-resolution.js +132 -0
  98. package/test/events/payments/journey-payments-upgrade.js +135 -0
  99. package/test/fixtures/chargebee/invoice-one-time.json +27 -0
  100. package/test/fixtures/chargebee/subscription-active.json +44 -0
  101. package/test/fixtures/chargebee/subscription-cancelled.json +42 -0
  102. package/test/fixtures/chargebee/subscription-in-trial.json +41 -0
  103. package/test/fixtures/chargebee/subscription-legacy-plan.json +41 -0
  104. package/test/fixtures/chargebee/subscription-non-renewing.json +41 -0
  105. package/test/fixtures/chargebee/subscription-paused.json +42 -0
  106. package/test/fixtures/chargebee/webhook-payment-failed.json +51 -0
  107. package/test/fixtures/chargebee/webhook-subscription-created.json +47 -0
  108. package/test/fixtures/paypal/order-approved.json +62 -0
  109. package/test/fixtures/paypal/order-completed.json +110 -0
  110. package/test/fixtures/paypal/subscription-active.json +76 -0
  111. package/test/fixtures/paypal/subscription-cancelled.json +50 -0
  112. package/test/fixtures/paypal/subscription-suspended.json +65 -0
  113. package/test/fixtures/stripe/checkout-session-completed.json +130 -0
  114. package/test/fixtures/stripe/invoice-payment-failed.json +148 -0
  115. package/test/fixtures/stripe/invoice-subscription-payment-failed.json +28 -0
  116. package/test/fixtures/stripe/subscription-active.json +161 -0
  117. package/test/fixtures/stripe/subscription-canceled.json +161 -0
  118. package/test/fixtures/stripe/subscription-trialing.json +161 -0
  119. package/test/functions/admin/database-read.js +134 -0
  120. package/test/functions/admin/database-write.js +159 -0
  121. package/test/functions/admin/edit-post.js +366 -0
  122. package/test/functions/admin/firestore-query.js +207 -0
  123. package/test/functions/admin/firestore-read.js +129 -0
  124. package/test/functions/admin/firestore-write.js +105 -0
  125. package/test/functions/admin/get-stats.js +115 -0
  126. package/test/functions/admin/send-email.js +119 -0
  127. package/test/functions/admin/send-notification.js +208 -0
  128. package/test/functions/admin/write-repo-content.js +223 -0
  129. package/test/functions/general/add-marketing-contact.js +335 -0
  130. package/test/functions/general/fetch-post.js +54 -0
  131. package/test/functions/general/generate-uuid.js +114 -0
  132. package/test/functions/test/authenticate.js +64 -0
  133. package/test/functions/user/create-custom-token.js +73 -0
  134. package/test/functions/user/delete.js +135 -0
  135. package/test/functions/user/get-active-sessions.js +111 -0
  136. package/test/functions/user/get-subscription-info.js +99 -0
  137. package/test/functions/user/regenerate-api-keys.js +156 -0
  138. package/test/functions/user/resolve.js +52 -0
  139. package/test/functions/user/sign-out-all-sessions.js +94 -0
  140. package/test/functions/user/sign-up.js +252 -0
  141. package/test/functions/user/submit-feedback.js +104 -0
  142. package/test/functions/user/validate-settings.js +82 -0
  143. package/test/helpers/ai-request-payload.js +300 -0
  144. package/test/helpers/ai-test-provider.js +202 -0
  145. package/test/helpers/ai-tools-format.js +383 -0
  146. package/test/helpers/content/blog-auto-publisher.js +484 -0
  147. package/test/helpers/content/feed-parser.js +528 -0
  148. package/test/helpers/content/ghostii-blocks.js +134 -0
  149. package/test/helpers/content/ghostii-feed-integration.js +404 -0
  150. package/test/helpers/content/ghostii-write-article.js +243 -0
  151. package/test/helpers/environment.js +230 -0
  152. package/test/helpers/infer-contact.js +113 -0
  153. package/test/helpers/merge-line-files.js +271 -0
  154. package/test/helpers/payment/chargebee/parse-webhook.js +413 -0
  155. package/test/helpers/payment/chargebee/to-unified-one-time.js +147 -0
  156. package/test/helpers/payment/chargebee/to-unified-subscription.js +648 -0
  157. package/test/helpers/payment/paypal/parse-webhook.js +539 -0
  158. package/test/helpers/payment/paypal/to-unified-one-time.js +382 -0
  159. package/test/helpers/payment/paypal/to-unified-subscription.js +820 -0
  160. package/test/helpers/payment/stripe/parse-webhook.js +447 -0
  161. package/test/helpers/payment/stripe/to-unified-one-time.js +306 -0
  162. package/test/helpers/payment/stripe/to-unified-subscription.js +708 -0
  163. package/test/helpers/sanitize.js +222 -0
  164. package/test/helpers/slugify.js +394 -0
  165. package/test/helpers/storage.js +194 -0
  166. package/test/helpers/user.js +755 -0
  167. package/test/helpers/webhook-forward.js +418 -0
  168. package/test/mcp/discovery.js +53 -0
  169. package/test/mcp/oauth.js +161 -0
  170. package/test/mcp/protocol.js +268 -0
  171. package/test/mcp/roles.js +188 -0
  172. package/test/mcp/utils.js +245 -0
  173. package/test/routes/admin/create-post.js +364 -0
  174. package/test/routes/admin/database.js +131 -0
  175. package/test/routes/admin/deduplicate-image-alts.js +190 -0
  176. package/test/routes/admin/email.js +114 -0
  177. package/test/routes/admin/firestore-query.js +204 -0
  178. package/test/routes/admin/firestore.js +127 -0
  179. package/test/routes/admin/infer-contact.js +218 -0
  180. package/test/routes/admin/notification.js +198 -0
  181. package/test/routes/admin/post-resize-image.js +184 -0
  182. package/test/routes/admin/post.js +368 -0
  183. package/test/routes/admin/repo-content.js +223 -0
  184. package/test/routes/admin/stats.js +112 -0
  185. package/test/routes/content/post.js +54 -0
  186. package/test/routes/general/uuid.js +115 -0
  187. package/test/routes/marketing/campaign.js +125 -0
  188. package/test/routes/marketing/contact.js +370 -0
  189. package/test/routes/marketing/email-preferences.js +292 -0
  190. package/test/routes/marketing/push-send.js +32 -0
  191. package/test/routes/marketing/webhook-forward.js +61 -0
  192. package/test/routes/marketing/webhook.js +639 -0
  193. package/test/routes/payments/cancel.js +163 -0
  194. package/test/routes/payments/discount.js +80 -0
  195. package/test/routes/payments/dispute-alert.js +320 -0
  196. package/test/routes/payments/intent.js +336 -0
  197. package/test/routes/payments/portal.js +92 -0
  198. package/test/routes/payments/refund.js +179 -0
  199. package/test/routes/payments/trial-eligibility.js +71 -0
  200. package/test/routes/payments/webhook.js +107 -0
  201. package/test/routes/test/authenticate.js +59 -0
  202. package/test/routes/test/schema.js +554 -0
  203. package/test/routes/test/usage.js +342 -0
  204. package/test/routes/user/api-keys.js +156 -0
  205. package/test/routes/user/delete.js +136 -0
  206. package/test/routes/user/feedback.js +104 -0
  207. package/test/routes/user/sessions.js +199 -0
  208. package/test/routes/user/settings-validate.js +82 -0
  209. package/test/routes/user/signup.js +542 -0
  210. package/test/routes/user/subscription.js +99 -0
  211. package/test/routes/user/token.js +73 -0
  212. package/test/routes/user/user.js +157 -0
  213. package/test/rules/notifications.js +410 -0
  214. package/test/rules/payments-carts.js +371 -0
  215. package/test/rules/user.js +396 -0
@@ -0,0 +1,819 @@
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/email/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.newsletter.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/email/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_OPEN=1 Auto-open the rendered preview in the default browser (macOS only).
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_CLAIM=1 CLAIM the fetched sources (claimFor=brandId), consuming them so the
48
+ * real newsletter won't reuse them. OFF by default — the test fetches
49
+ * without claiming so runs are repeatable and non-destructive.
50
+ * NEWSLETTER_RELEASE=1 Reset locally-tracked claimed sources back to 'ready'.
51
+ * NEWSLETTER_NO_IMAGES=1 Skip SVG/PNG generation (fast iteration on copy only).
52
+ * NEWSLETTER_CREATE_ARTICLE=1 PUBLISH the linked blog article to the website repo (Ghostii → admin/post → GitHub).
53
+ * The article is always GENERATED when the brand's
54
+ * marketing.newsletter.content.article.enabled is on (exercises the Ghostii write +
55
+ * URL/CTA path); this flag only controls whether it's actually committed.
56
+ * OFF by default — a newsletter test run never commits a real post.
57
+ * NEWSLETTER_PROVIDER_STRUCTURE=X Override structure provider (openai|anthropic).
58
+ * NEWSLETTER_PROVIDER_SVG=X Override SVG provider (openai|anthropic).
59
+ * NEWSLETTER_CAMPAIGN_ID=<id> Override the auto-generated campaign ID (folder name in newsletter-assets).
60
+ *
61
+ * In EXTENDED mode, the test ALWAYS uploads PNGs + newsletter.html to GitHub
62
+ * and creates a Beehiiv draft — same side effects as the production cron.
63
+ * No opt-out, no per-side-effect flag. EXTENDED = production-equivalent run.
64
+ *
65
+ * --- THEME_ONLY-mode-only env vars ---
66
+ * NEWSLETTER_BEEHIIV_UPLOAD=1 After re-rendering, upload as a new Beehiiv draft (rare).
67
+ *
68
+ * AI mode requires:
69
+ * BACKEND_MANAGER_KEY — authenticates with parent as admin
70
+ * OPENAI_API_KEY — structure provider (or BACKEND_MANAGER_OPENAI_API_KEY)
71
+ * ANTHROPIC_API_KEY — SVG provider (or BACKEND_MANAGER_ANTHROPIC_API_KEY)
72
+ * PARENT_API_URL — or set `parent` in backend-manager-config.json
73
+ *
74
+ * Fixture mode requires: nothing.
75
+ */
76
+ const path = require('path');
77
+ const { execSync } = require('child_process');
78
+ const fetch = require('wonderful-fetch');
79
+ const jetpack = require('fs-jetpack');
80
+
81
+ module.exports = {
82
+ description: 'Generate a newsletter preview (fixture by default, full AI pipeline with TEST_EXTENDED_MODE)',
83
+ auth: 'none',
84
+ timeout: 300000, // 5 min — AI structure + image generation + GitHub upload + Beehiiv draft
85
+ // The test ALWAYS runs. By default it renders a hand-crafted fixture for the
86
+ // active template (fast, free, deterministic — catches layout regressions in
87
+ // CI). Set TEST_EXTENDED_MODE=1 to switch to the full AI pipeline that fetches
88
+ // real sources, calls the structure + SVG providers, and writes a preview.
89
+ // Other modes (FIXTURE, THEME_ONLY, RELEASE, PEEK) are also opt-in via env.
90
+ async run({ assert, config, Manager, assistant, skip }) {
91
+ const env = process.env;
92
+
93
+ // --- Apply env overrides into newsletterConfig ---
94
+ // Newsletter config now lives under marketing.newsletter.content.
95
+ const newsletterConfig = JSON.parse(JSON.stringify(config.marketing?.newsletter?.content || {}));
96
+
97
+ if (env.NEWSLETTER_PROVIDER_STRUCTURE) {
98
+ newsletterConfig.provider = { ...(newsletterConfig.provider || {}), structure: env.NEWSLETTER_PROVIDER_STRUCTURE };
99
+ }
100
+ if (env.NEWSLETTER_PROVIDER_SVG) {
101
+ newsletterConfig.provider = { ...(newsletterConfig.provider || {}), svg: env.NEWSLETTER_PROVIDER_SVG };
102
+ }
103
+ if (env.NEWSLETTER_TEMPLATE) {
104
+ newsletterConfig.template = env.NEWSLETTER_TEMPLATE;
105
+ }
106
+
107
+ // --- Build the output dir ---
108
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/-(\d{3})Z$/, '');
109
+ // .temp lives at the CONSUMER PROJECT ROOT (e.g. somiibo-backend/.temp), not
110
+ // inside functions/. Matches the convention used by UJM, BXM, electron-manager,
111
+ // etc. — every project's transient cache directory sits at the repo root.
112
+ const outRoot = path.join(process.cwd(), '..', '.temp', 'newsletter');
113
+ const runDir = path.join(outRoot, `run-${stamp}`);
114
+ const claimedFile = path.join(outRoot, '.claimed.json');
115
+
116
+ jetpack.dir(runDir);
117
+
118
+ // --- Release mode (early return) ---
119
+ if (env.NEWSLETTER_RELEASE) {
120
+ const released = await releaseAll(claimedFile);
121
+ console.log(`Released ${released} previously claimed source(s) back to ready`);
122
+ return;
123
+ }
124
+
125
+ // --- Fixture mode ---
126
+ // Default behavior of this test. Loads a hand-crafted structure JSON from
127
+ // test/email/fixtures/{name}.json and renders it directly. No AI, no
128
+ // source fetching, no images. Used as the deterministic "predictable
129
+ // preview" loop AND as the default test-suite render.
130
+ //
131
+ // Fixture-name resolution priority:
132
+ // 1. NEWSLETTER_FIXTURE=<name> (explicit override)
133
+ // 2. NEWSLETTER_TEMPLATE=<name> (use the fixture for the override template)
134
+ // 3. newsletterConfig.template (use the fixture for the active brand's template)
135
+ // 4. 'clean' (universal fallback)
136
+ //
137
+ // This means a fresh `npx mgr test` against any consumer project picks the
138
+ // brand's configured template, renders the matching fixture, and produces
139
+ // a deterministic preview HTML — no AI, no money spent.
140
+ //
141
+ // The full AI pipeline only runs when TEST_EXTENDED_MODE=1 is set (below).
142
+ // Explicit NEWSLETTER_FIXTURE wins even in EXTENDED mode (you can ask for
143
+ // a fixture render mid-AI-iteration without re-cycling envs).
144
+ // NEWSLETTER_THEME_ONLY takes precedence below — it's a no-AI mode that
145
+ // reuses prior AI output, distinct from fixture mode.
146
+ if (env.NEWSLETTER_THEME_ONLY) {
147
+ // Fall through to the theme-only block below.
148
+ } else if (!env.TEST_EXTENDED_MODE || env.NEWSLETTER_FIXTURE) {
149
+ const requestedFixture = (env.NEWSLETTER_FIXTURE || env.NEWSLETTER_TEMPLATE || newsletterConfig.template || 'clean')
150
+ .replace(/^fixture:/, '');
151
+ const fixturePath = path.join(__dirname, 'fixtures', `${requestedFixture}.json`);
152
+
153
+ assert.ok(jetpack.exists(fixturePath),
154
+ `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/email/fixtures/.`);
155
+
156
+ const structure = jetpack.read(fixturePath, 'json');
157
+
158
+ assert.ok(structure, `Fixture ${requestedFixture}.json failed to parse as JSON`);
159
+
160
+ // Pin the template to the fixture name — fixtures are authored for a
161
+ // specific template's content shape, so this prevents the obvious bug
162
+ // of "render the field-report fixture through the clean template".
163
+ // Explicit NEWSLETTER_TEMPLATE override is still honored above.
164
+ if (!env.NEWSLETTER_TEMPLATE) {
165
+ newsletterConfig.template = requestedFixture;
166
+ }
167
+
168
+ const { renderNewsletter } = require('../../src/manager/libraries/email/generators/lib/mjml-template.js');
169
+ const { renderMarkdown } = require('../../src/manager/libraries/email/generators/lib/markdown-renderer.js');
170
+
171
+ const renderStart = Date.now();
172
+ const { html, mjml, template: templateName } = await renderNewsletter({
173
+ brand: config.brand,
174
+ newsletterConfig,
175
+ structure,
176
+ imagePaths: [],
177
+ campaign: `fixture-${requestedFixture}`,
178
+ });
179
+ // Force the template metadata onto structure so renderMarkdown picks
180
+ // the right body strategy (fixtures don't carry _meta on their own).
181
+ Object.defineProperty(structure, '_meta', {
182
+ enumerable: false,
183
+ configurable: true,
184
+ value: { template: templateName },
185
+ });
186
+ const markdown = renderMarkdown({
187
+ structure,
188
+ brand: config.brand,
189
+ imagePaths: [],
190
+ });
191
+ const renderMs = Date.now() - renderStart;
192
+
193
+ const previewPath = path.join(runDir, 'newsletter.html');
194
+ jetpack.write(previewPath, html);
195
+ jetpack.write(path.join(runDir, 'newsletter.md'), markdown);
196
+ if (structure.summary) {
197
+ jetpack.write(path.join(runDir, 'summary.md'), structure.summary + '\n');
198
+ }
199
+ jetpack.write(path.join(runDir, 'newsletter.mjml'), mjml || '');
200
+ jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(structure, null, 2));
201
+ jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify({
202
+ mode: 'fixture',
203
+ fixture: requestedFixture,
204
+ template: templateName,
205
+ renderMs,
206
+ tags: structure.tags || [],
207
+ timestamp: new Date().toISOString(),
208
+ }, null, 2));
209
+
210
+ console.log(`\n[fixture=${requestedFixture}] Rendered in ${renderMs}ms using template: ${templateName}`);
211
+ console.log(`[fixture=${requestedFixture}] Preview: ${previewPath}`);
212
+ console.log(`[fixture=${requestedFixture}] Markdown: ${path.join(runDir, 'newsletter.md')}`);
213
+ console.log(`[fixture=${requestedFixture}] (Set TEST_EXTENDED_MODE=1 to switch to the full AI pipeline.)`);
214
+
215
+ if (env.NEWSLETTER_OPEN === '1' && process.platform === 'darwin') {
216
+ try { execSync(`open "${previewPath}"`); } catch (e) { /* no-op */ }
217
+ }
218
+
219
+ assert.ok(html.includes('<html'), 'Rendered HTML');
220
+ assert.ok(markdown.includes('# '), 'Rendered markdown has a heading');
221
+ return;
222
+ }
223
+
224
+ // --- Theme-only mode (early return) ---
225
+ // Reuse a previous run's structure.json + PNGs to re-render MJML/HTML only.
226
+ // Sub-second iteration on layout / theme tokens with no AI cost.
227
+ if (env.NEWSLETTER_THEME_ONLY) {
228
+ const sourceRun = resolveReuseRun({
229
+ outRoot,
230
+ explicit: env.NEWSLETTER_REUSE_RUN,
231
+ });
232
+
233
+ assert.ok(sourceRun, 'Found a previous run to reuse (set NEWSLETTER_REUSE_RUN=run-<stamp> if needed)');
234
+
235
+ console.log(`[theme-only] reusing structure + images from: ${path.basename(sourceRun)}`);
236
+
237
+ const structure = jetpack.read(path.join(sourceRun, 'structure.json'), 'json');
238
+
239
+ assert.ok(structure?.sections?.length, 'Previous run has a valid structure.json');
240
+
241
+ // Copy section PNGs from previous run into this run's dir so paths resolve
242
+ const imagePaths = [];
243
+
244
+ for (let i = 0; i < structure.sections.length; i++) {
245
+ const filename = `section-${i + 1}.png`;
246
+ const srcPath = path.join(sourceRun, filename);
247
+
248
+ if (jetpack.exists(srcPath)) {
249
+ jetpack.copy(srcPath, path.join(runDir, filename));
250
+ imagePaths.push(`./${filename}`);
251
+ } else {
252
+ imagePaths.push(null);
253
+ }
254
+ }
255
+
256
+ const { renderNewsletter } = require('../../src/manager/libraries/email/generators/lib/mjml-template.js');
257
+ const { renderMarkdown } = require('../../src/manager/libraries/email/generators/lib/markdown-renderer.js');
258
+
259
+ const renderStart = Date.now();
260
+ const { html, mjml, template: templateName } = await renderNewsletter({
261
+ brand: config.brand,
262
+ newsletterConfig,
263
+ structure,
264
+ imagePaths,
265
+ campaign: `theme-only-${stamp}`,
266
+ });
267
+ // Force the template metadata onto structure so renderMarkdown picks
268
+ // the right body strategy (reused structures may have lost _meta).
269
+ Object.defineProperty(structure, '_meta', {
270
+ enumerable: false,
271
+ configurable: true,
272
+ value: { template: templateName },
273
+ });
274
+ const markdown = renderMarkdown({
275
+ structure,
276
+ brand: config.brand,
277
+ imagePaths,
278
+ });
279
+ const renderMs = Date.now() - renderStart;
280
+
281
+ const previewPath = path.join(runDir, 'newsletter.html');
282
+ jetpack.write(previewPath, html);
283
+ jetpack.write(path.join(runDir, 'newsletter.md'), markdown);
284
+ if (structure.summary) {
285
+ jetpack.write(path.join(runDir, 'summary.md'), structure.summary + '\n');
286
+ }
287
+ jetpack.write(path.join(runDir, 'newsletter.mjml'), mjml || '');
288
+ jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(structure, null, 2));
289
+ jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify({
290
+ mode: 'theme-only',
291
+ reusedFrom: path.basename(sourceRun),
292
+ template: templateName,
293
+ renderMs,
294
+ tags: structure.tags || [],
295
+ timestamp: new Date().toISOString(),
296
+ }, null, 2));
297
+
298
+ console.log(`\n[theme-only] Rendered in ${renderMs}ms using template: ${templateName}`);
299
+ console.log(`[theme-only] Preview: ${previewPath}`);
300
+
301
+ // Optional: upload to Beehiiv as a draft
302
+ if (env.NEWSLETTER_BEEHIIV_UPLOAD) {
303
+ await uploadDraftToBeehiiv({
304
+ html,
305
+ structure,
306
+ config,
307
+ assistant: console,
308
+ runDir,
309
+ });
310
+ }
311
+
312
+ if (env.NEWSLETTER_OPEN === '1' && process.platform === 'darwin') {
313
+ try { execSync(`open "${previewPath}"`); } catch (e) { /* no-op */ }
314
+ }
315
+
316
+ assert.ok(html.includes('<html'), 'Rendered HTML');
317
+ return;
318
+ }
319
+
320
+ // --- AI pipeline path (TEST_EXTENDED_MODE) ---
321
+ // Everything below this point talks to the real parent server and the AI
322
+ // providers. The parent URL is required for any of it.
323
+ // Use Manager.getParentApiUrl() — same helper the production newsletter
324
+ // generator uses. config.parent stores the parent's brand URL WITHOUT the
325
+ // `api.` subdomain (e.g. 'https://itwcreativeworks.com'); the helper
326
+ // inserts `api.` at call time. PARENT_API_URL env override is honored
327
+ // verbatim for one-off testing against a different parent.
328
+ const parentUrl = env.PARENT_API_URL || Manager.getParentApiUrl();
329
+ 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.');
330
+
331
+ // --- Peek mode (early return) ---
332
+ if (env.NEWSLETTER_PEEK) {
333
+ const sources = await peekSources({
334
+ parentUrl,
335
+ categories: newsletterConfig.categories || [],
336
+ limit: parseInt(env.NEWSLETTER_LIMIT, 10) || 10,
337
+ key: env.BACKEND_MANAGER_KEY,
338
+ });
339
+
340
+ console.log(`\nPeek mode — ${sources.length} ready source(s):\n`);
341
+ sources.forEach((s, i) => {
342
+ const raw = s.source || {};
343
+ const cats = (s.categories || []).join(', ') || s.category || '(none)';
344
+ console.log(`[${i + 1}] ${s.id}`);
345
+ console.log(` Categories: ${cats}`);
346
+ console.log(` From: ${raw.from || s.from || '(unknown)'}`);
347
+ console.log(` Subject: ${raw.subject || s.subject || '(none)'}`);
348
+ console.log(` Headline: ${s.ai?.headline || '(none — not yet AI-processed)'}`);
349
+ console.log('');
350
+ });
351
+
352
+ assert.ok(true, `Peeked ${sources.length} sources`);
353
+ return;
354
+ }
355
+
356
+ // --- Fetch sources ---
357
+ // By DEFAULT the test does NOT claim sources — it fetches them without
358
+ // claimFor, so they stay 'ready' and available for the real newsletter.
359
+ // This keeps test runs repeatable and non-destructive (a test should never
360
+ // silently consume production resources). Set NEWSLETTER_CLAIM=1 to exercise
361
+ // the real claim/consume path (then use NEWSLETTER_RELEASE=1 to put them back).
362
+ const claim = !!env.NEWSLETTER_CLAIM;
363
+ const sources = await fetchSourcesForRun({
364
+ parentUrl,
365
+ newsletterConfig,
366
+ brandId: config.brand?.id,
367
+ sourceId: env.NEWSLETTER_SOURCE_ID,
368
+ key: env.BACKEND_MANAGER_KEY,
369
+ claim,
370
+ });
371
+
372
+ // Environmental precondition: the parent server must have ready sources in
373
+ // at least one configured category. Skip cleanly when the pool is empty
374
+ // (transient state — no point hard-failing CI on an external queue).
375
+ if (sources.length === 0) {
376
+ return skip('No ready newsletter sources available on parent server (environmental)');
377
+ }
378
+
379
+ // Track claimed IDs for later --release-all (only when we actually claimed)
380
+ if (claim && !env.NEWSLETTER_SOURCE_ID) {
381
+ appendClaimed(claimedFile, sources.map((s) => s.id));
382
+ }
383
+
384
+ // Force `newsletter.enabled: true` and inject the per-run newsletter config
385
+ // overrides onto Manager.config. The iteration test IS the explicit trigger
386
+ // — we're not checking whether newsletter is configured for prod use, we're
387
+ // driving the generator directly. Mutating Manager.config is fine here
388
+ // because this is a `type: 'standalone'` test (one test per process — no
389
+ // cross-test config leakage).
390
+ Manager.config.marketing = {
391
+ ...(Manager.config.marketing || {}),
392
+ newsletter: {
393
+ ...(Manager.config.marketing?.newsletter || {}),
394
+ enabled: true,
395
+ content: newsletterConfig,
396
+ },
397
+ };
398
+
399
+ // --- Run the production generator with the local-persist image hook ---
400
+ const generator = require('../../src/manager/libraries/email/generators/newsletter.js');
401
+
402
+ // EXTENDED mode mirrors the production cron's newsletter side effects:
403
+ // GH upload always happens (PNGs + newsletter.html), Beehiiv draft upload
404
+ // always happens (governed inside newsletter.js by newsletter.enabled, which
405
+ // we force true above). If you don't want the side effects, run fixture
406
+ // mode instead.
407
+ //
408
+ // The ONE deliberate exception is PUBLISHING the linked blog article: the
409
+ // article is still generated (so the test exercises the Ghostii write + CTA
410
+ // path), but it's NOT committed to the website repo unless you opt in with
411
+ // NEWSLETTER_CREATE_ARTICLE=1 (publishArticle below). Committing a real post
412
+ // is out of scope for a routine newsletter test.
413
+ //
414
+ // persistImage is a side-effect callback that writes PNG+SVG to runDir for
415
+ // local preview / debug. Its return value is ignored when imageHost: 'github'
416
+ // because the generator uses the uploaded CDN URLs in the rendered HTML.
417
+ const persistImage = async (image, idx) => {
418
+ const filename = `section-${idx + 1}.png`;
419
+
420
+ jetpack.write(path.join(runDir, filename), image.png);
421
+ if (image.svg) {
422
+ jetpack.write(path.join(runDir, `section-${idx + 1}.svg`), image.svg);
423
+ }
424
+
425
+ return `./${filename}`;
426
+ };
427
+
428
+ // The campaignId becomes the GitHub folder name. Either pinned via env
429
+ // (for repeatable iteration on the same folder) or auto-generated by
430
+ // newsletter.generate() in Firestore auto-ID shape.
431
+ const campaignId = env.NEWSLETTER_CAMPAIGN_ID || undefined;
432
+
433
+ console.log(`[extended] uploading to itw-creative-works/newsletter-assets/${config.brand?.id}/${campaignId || '<auto-id>'}/ + Beehiiv draft`);
434
+
435
+ const result = await generator.generate(
436
+ Manager,
437
+ assistant,
438
+ { name: `Somiibo Newsletter — Iteration ${stamp}` },
439
+ {
440
+ sources,
441
+ skipClaim: true, // We manage the claim/release lifecycle ourselves
442
+ skipImages: !!env.NEWSLETTER_NO_IMAGES,
443
+ // The article is GENERATED whenever the brand's config.article.enabled is on
444
+ // (exercises the Ghostii write + URL/CTA path), but only PUBLISHED to the
445
+ // website repo when you opt in with NEWSLETTER_CREATE_ARTICLE=1. Default
446
+ // test run generates but does not commit a real post.
447
+ publishArticle: !!env.NEWSLETTER_CREATE_ARTICLE,
448
+ // Local disk persistence runs unconditionally (for preview/debug)
449
+ persistImage,
450
+ // EXTENDED always uploads to GitHub — mirrors production cron exactly
451
+ imageHost: 'github',
452
+ campaignId,
453
+ }
454
+ );
455
+
456
+ assert.ok(result, 'Generator returned a result');
457
+ assert.ok(result.contentHtml, 'Generator returned contentHtml');
458
+ assert.ok(result.contentMarkdown, 'Generator returned contentMarkdown');
459
+ assert.ok(result.structure?.sections?.length >= 2, 'Has at least 2 sections');
460
+
461
+ // --- Write outputs ---
462
+ const previewPath = path.join(runDir, 'newsletter.html');
463
+ jetpack.write(previewPath, result.contentHtml);
464
+ jetpack.write(path.join(runDir, 'newsletter.md'), result.contentMarkdown);
465
+ if (result.summary) {
466
+ jetpack.write(path.join(runDir, 'summary.md'), result.summary + '\n');
467
+ }
468
+ jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(result.structure, null, 2));
469
+ jetpack.write(path.join(runDir, 'newsletter.mjml'), result.mjml || '');
470
+ jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify({
471
+ ...(result.meta || {}),
472
+ tags: result.tags || [],
473
+ assets: result.assets || null,
474
+ }, null, 2));
475
+
476
+ // Linked blog article (when content.article.enabled). Save the full Ghostii
477
+ // output + the computed URL so you can review what would be published — both
478
+ // the raw JSON and a readable markdown view of the article body.
479
+ if (result.article?.article) {
480
+ const a = result.article.article;
481
+ jetpack.write(path.join(runDir, 'article.json'), JSON.stringify(result.article, null, 2));
482
+ jetpack.write(path.join(runDir, 'article.md'), [
483
+ `# ${a.title || ''}`,
484
+ '',
485
+ `> ${a.description || ''}`,
486
+ '',
487
+ `**URL:** ${result.article.url || '(none)'}`,
488
+ `**Published:** ${result.article.published ? 'yes' : 'no (generate-only)'}`,
489
+ a.headerImageUrl ? `**Header image:** ${a.headerImageUrl}` : '',
490
+ a.categories?.length ? `**Categories:** ${a.categories.join(', ')}` : '',
491
+ a.keywords?.length ? `**Keywords:** ${a.keywords.join(', ')}` : '',
492
+ '',
493
+ '---',
494
+ '',
495
+ a.body || '',
496
+ ].filter(line => line !== undefined).join('\n'));
497
+ }
498
+
499
+ console.log(`\nNewsletter preview written: ${previewPath}`);
500
+ console.log(`Subject: ${result.subject}`);
501
+ console.log(`Preheader: ${result.preheader}`);
502
+ console.log(`Sections: ${result.structure.sections.length}`);
503
+ if (result.article?.article) {
504
+ console.log(`Article: "${result.article.article.title}" → ${result.article.url} (${result.article.published ? 'published' : 'generate-only'})`);
505
+ console.log(` ${path.join(runDir, 'article.md')}`);
506
+ }
507
+ if (result.meta?.totals) {
508
+ const t = result.meta.totals;
509
+ console.log(`\nRun summary:`);
510
+ console.log(` Total duration: ${(result.meta.totalDurationMs / 1000).toFixed(1)}s`);
511
+ console.log(` AI calls: ${t.aiCalls}`);
512
+ console.log(` Tokens: ${t.inputTokens} in / ${t.outputTokens} out (${t.totalTokens} total)`);
513
+ console.log(` Cost: $${t.totalCostUSD}`);
514
+ console.log(` Filter: provider=${result.meta.steps.filter?.provider} model=${result.meta.steps.filter?.model} (${result.meta.steps.filter?.durationMs}ms)`);
515
+ console.log(` Structure: provider=${result.meta.steps.structure?.provider} model=${result.meta.steps.structure?.model} (${result.meta.steps.structure?.durationMs}ms)`);
516
+ for (const img of result.meta.steps.images || []) {
517
+ console.log(` Image ${img.section}: provider=${img.provider} model=${img.model} (${img.durationMs}ms${img.fallback ? ', FALLBACK' : ''})`);
518
+ }
519
+ }
520
+
521
+ // Beehiiv draft upload already happened inside generator.generate() —
522
+ // newsletter.js calls beehiiv.createPost(draft) after the GH HTML upload.
523
+ // Look for assets.beehiivPostId in the result (null on free plan; non-null
524
+ // once the Beehiiv account is on Enterprise).
525
+ if (result.assets?.beehiivPostId) {
526
+ console.log(`Beehiiv: draft post created — id=${result.assets.beehiivPostId}`);
527
+ }
528
+
529
+ // --- Auto-open in browser (macOS) ---
530
+ if (env.NEWSLETTER_OPEN === '1' && process.platform === 'darwin') {
531
+ try {
532
+ execSync(`open "${previewPath}"`);
533
+ } catch (e) {
534
+ console.warn('Failed to auto-open preview:', e.message);
535
+ }
536
+ }
537
+ },
538
+ };
539
+
540
+ /**
541
+ * Upload the rendered HTML to Beehiiv as a draft post (never sends). Uses the
542
+ * v2 Posts API directly so it works against the test's stub Manager.
543
+ *
544
+ * Writes the Beehiiv response to {runDir}/beehiiv-upload.json for inspection.
545
+ *
546
+ * Required env: BEEHIIV_API_KEY
547
+ * Required config: marketing.newsletter.publicationId (or we fuzzy-match by brand name)
548
+ */
549
+ async function uploadDraftToBeehiiv({ html, structure, config, runDir }) {
550
+ const apiKey = process.env.BEEHIIV_API_KEY;
551
+
552
+ if (!apiKey) {
553
+ console.warn('[beehiiv] Skipping upload — BEEHIIV_API_KEY not set');
554
+ return;
555
+ }
556
+
557
+ const BASE_URL = 'https://api.beehiiv.com/v2';
558
+ const headers = { 'Authorization': `Bearer ${apiKey}` };
559
+
560
+ // Resolve publication ID — config first, then fuzzy-match by brand name
561
+ let publicationId = config?.marketing?.newsletter?.publicationId;
562
+ const brandName = config?.brand?.name;
563
+
564
+ if (!publicationId && brandName) {
565
+ try {
566
+ const pubs = await fetch(`${BASE_URL}/publications?limit=100`, {
567
+ response: 'json',
568
+ headers,
569
+ timeout: 10000,
570
+ });
571
+ const brandLower = brandName.toLowerCase();
572
+ const matched = (pubs.data || []).find((p) =>
573
+ p.name.toLowerCase() === brandLower
574
+ || p.name.toLowerCase().includes(brandLower)
575
+ || brandLower.includes(p.name.toLowerCase())
576
+ );
577
+ publicationId = matched?.id;
578
+ } catch (e) {
579
+ console.error('[beehiiv] Publication lookup failed:', e.message);
580
+ }
581
+ }
582
+
583
+ if (!publicationId) {
584
+ console.warn(`[beehiiv] Skipping upload — could not resolve publication ID (brand=${brandName})`);
585
+ return;
586
+ }
587
+
588
+ // Build the post — always draft from the test (never sends)
589
+ const body = {
590
+ title: structure.subject || `Newsletter ${new Date().toISOString()}`,
591
+ status: 'draft',
592
+ body_content: html,
593
+ email_settings: {
594
+ subject_line: structure.subject,
595
+ preview_text: structure.preheader || '',
596
+ },
597
+ };
598
+
599
+ console.log(`[beehiiv] Uploading draft to publication ${publicationId}...`);
600
+
601
+ try {
602
+ const data = await fetch(`${BASE_URL}/publications/${publicationId}/posts`, {
603
+ method: 'post',
604
+ response: 'json',
605
+ headers,
606
+ timeout: 30000,
607
+ body,
608
+ });
609
+
610
+ if (data.data?.id) {
611
+ const editUrl = `https://app.beehiiv.com/posts/${data.data.id}`;
612
+ console.log(`[beehiiv] ✓ Draft uploaded: ${data.data.id}`);
613
+ console.log(`[beehiiv] Edit at: ${editUrl}`);
614
+ jetpack.write(path.join(runDir, 'beehiiv-upload.json'), JSON.stringify({
615
+ publicationId,
616
+ postId: data.data.id,
617
+ editUrl,
618
+ status: 'draft',
619
+ uploadedAt: new Date().toISOString(),
620
+ response: data.data,
621
+ }, null, 2));
622
+ } else {
623
+ console.error('[beehiiv] Upload returned no post ID:', JSON.stringify(data));
624
+ }
625
+ } catch (e) {
626
+ console.error('[beehiiv] Upload failed:', e.message);
627
+ }
628
+ }
629
+
630
+ // --- Helpers ---
631
+
632
+ /**
633
+ * GET /newsletter/sources without claiming (claimFor omitted).
634
+ */
635
+ async function peekSources({ parentUrl, categories, limit, key }) {
636
+ if (!categories.length) {
637
+ const data = await fetch(`${parentUrl}/newsletter-sources`, {
638
+ method: 'get',
639
+ response: 'json',
640
+ timeout: 15000,
641
+ query: { limit, backendManagerKey: key },
642
+ });
643
+
644
+ return data.sources || [];
645
+ }
646
+
647
+ const all = [];
648
+ for (const category of categories) {
649
+ const data = await fetch(`${parentUrl}/newsletter-sources`, {
650
+ method: 'get',
651
+ response: 'json',
652
+ timeout: 15000,
653
+ query: { category, limit, backendManagerKey: key },
654
+ });
655
+
656
+ all.push(...(data.sources || []));
657
+ }
658
+
659
+ return all;
660
+ }
661
+
662
+ /**
663
+ * Fetch sources for an actual generation run. Either:
664
+ * - A specific source by id — preview only, NO claim (iterate repeatedly on the same source)
665
+ * - Or N per category. Fetches WITHOUT claiming by default (claim=false), so runs are
666
+ * repeatable; pass claim=true (NEWSLETTER_CLAIM=1) to atomically claim/consume them.
667
+ *
668
+ * When NEWSLETTER_SOURCE_ID is set, we look the source up in any status
669
+ * (ready, claimed, used) so you can keep iterating on it across runs without
670
+ * the parent server's claim mechanism marking it consumed.
671
+ */
672
+ async function fetchSourcesForRun({ parentUrl, newsletterConfig, brandId, sourceId, key, claim = false }) {
673
+ if (sourceId) {
674
+ // Peek across ALL ready sources (no claim). Search broadly first, then
675
+ // fall back to any-status if needed. We never call claimFor with sourceId
676
+ // so the source stays available for future runs.
677
+ const all = await peekSources({
678
+ parentUrl,
679
+ categories: newsletterConfig.categories || [],
680
+ limit: 100,
681
+ key,
682
+ });
683
+
684
+ const match = all.find((s) => s.id === sourceId);
685
+
686
+ if (match) {
687
+ console.log(`[source-id-mode] Found ${sourceId} in ready pool, using WITHOUT claiming (iteration mode)`);
688
+ return [match];
689
+ }
690
+
691
+ // Not in ready pool — could be already claimed/used. Try the broad endpoint without category filter.
692
+ const broad = await peekSources({
693
+ parentUrl,
694
+ categories: [],
695
+ limit: 100,
696
+ key,
697
+ });
698
+ const matchBroad = broad.find((s) => s.id === sourceId);
699
+
700
+ if (matchBroad) {
701
+ console.log(`[source-id-mode] Found ${sourceId} (outside configured categories), using WITHOUT claiming`);
702
+ return [matchBroad];
703
+ }
704
+
705
+ throw new Error(`Source ${sourceId} not found. It may have been used (status: used) or never existed.`);
706
+ }
707
+
708
+ // Normal: claim N per category
709
+ const categories = newsletterConfig.categories || [];
710
+
711
+ if (!categories.length) {
712
+ throw new Error('marketing.newsletter.content.categories must have at least one entry');
713
+ }
714
+
715
+ const all = [];
716
+ for (const category of categories) {
717
+ // claimFor is what tells the parent server to mark sources consumed. Omit it
718
+ // (claim=false) to fetch the same sources without claiming them.
719
+ const query = { category, limit: 3, backendManagerKey: key };
720
+ if (claim) {
721
+ query.claimFor = brandId;
722
+ }
723
+
724
+ const data = await fetch(`${parentUrl}/newsletter-sources`, {
725
+ method: 'get',
726
+ response: 'json',
727
+ timeout: 15000,
728
+ query,
729
+ });
730
+ all.push(...(data.sources || []));
731
+ }
732
+
733
+ return all;
734
+ }
735
+
736
+ /**
737
+ * Find a previous run directory to reuse for theme-only iteration.
738
+ * Either honors NEWSLETTER_REUSE_RUN explicitly, or picks the most recent
739
+ * run-* directory under outRoot. Skips the current in-progress run dir.
740
+ */
741
+ function resolveReuseRun({ outRoot, explicit }) {
742
+ if (explicit) {
743
+ const candidate = path.join(outRoot, explicit);
744
+ return jetpack.exists(candidate) ? candidate : null;
745
+ }
746
+
747
+ if (!jetpack.exists(outRoot)) {
748
+ return null;
749
+ }
750
+
751
+ const entries = jetpack.list(outRoot) || [];
752
+ const runs = entries
753
+ .filter((name) => name.startsWith('run-'))
754
+ .map((name) => ({ name, full: path.join(outRoot, name) }))
755
+ .filter((r) => {
756
+ // Must contain a structure.json to be reusable
757
+ return jetpack.exists(path.join(r.full, 'structure.json')) === 'file';
758
+ })
759
+ .sort((a, b) => (a.name < b.name ? 1 : -1));
760
+
761
+ return runs[0]?.full || null;
762
+ }
763
+
764
+ function appendClaimed(claimedFile, ids) {
765
+ const existing = jetpack.exists(claimedFile) ? jetpack.read(claimedFile, 'json') || [] : [];
766
+ const stamped = ids.map((id) => ({ id, claimedAt: Math.round(Date.now() / 1000) }));
767
+ jetpack.write(claimedFile, [...existing, ...stamped]);
768
+ }
769
+
770
+ async function releaseAll(claimedFile) {
771
+ if (!jetpack.exists(claimedFile)) {
772
+ return 0;
773
+ }
774
+
775
+ const entries = jetpack.read(claimedFile, 'json') || [];
776
+
777
+ if (!entries.length) {
778
+ return 0;
779
+ }
780
+
781
+ const saPath = process.env.PARENT_SERVICE_ACCOUNT_PATH;
782
+
783
+ if (!saPath) {
784
+ throw new Error('PARENT_SERVICE_ACCOUNT_PATH env var is required for release. Point it at the parent project service-account.json.');
785
+ }
786
+
787
+ // Lazy-require firebase-admin
788
+ const admin = require('firebase-admin');
789
+
790
+ if (!admin.apps.length) {
791
+ admin.initializeApp({
792
+ credential: admin.credential.cert(require(saPath)),
793
+ });
794
+ }
795
+
796
+ const db = admin.firestore();
797
+ let released = 0;
798
+
799
+ for (const entry of entries) {
800
+ try {
801
+ await db.collection('newsletter-sources').doc(entry.id).update({
802
+ status: 'ready',
803
+ usedBy: null,
804
+ claimedBy: null,
805
+ usedAt: null,
806
+ claimedAt: null,
807
+ });
808
+ released++;
809
+ } catch (e) {
810
+ console.warn(`Failed to release ${entry.id}: ${e.message}`);
811
+ }
812
+ }
813
+
814
+ // Clear the tracker
815
+ jetpack.write(claimedFile, []);
816
+
817
+ return released;
818
+ }
819
+