backend-manager 5.10.0 → 5.10.2

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.
@@ -231,16 +231,19 @@ AI provider defaults live in code (openai for structure, anthropic for SVG — e
231
231
 
232
232
  ## Asset hosting (production cron flow)
233
233
 
234
- The frequent cron uploads per-section PNGs + the rendered `newsletter.html` + `newsletter.md` + `summary.md` to the public `itw-creative-works/newsletter-assets` repo as two atomic Git Trees commits per issue (PNGs first so URLs exist for embedding, then HTML/MD/summary in a second commit). Folder layout:
234
+ The frequent cron uploads per-section PNGs + the rendered `newsletter.html` + `newsletter.md` + `summary.md` to the public `itw-creative-works/newsletter-assets` repo. Each brand pushes to its own branch (branch name = brandId) zero contention when multiple brands upload concurrently. New brand branches are created automatically as empty orphans on first upload. Folder layout:
235
235
 
236
236
  ```
237
- {brandId}/{campaignId}/
238
- section-N.png — per-section illustration (embedded in HTML)
239
- newsletter.html final rendered email-safe HTML
240
- newsletter.md programmatic markdown view (per-section ## blocks, ready for Beehiiv paste)
241
- summary.md short editorial recap (2-3 sentences)
237
+ Branch: {brandId}
238
+ content/{campaignId}/
239
+ section-N.png per-section illustration (embedded in HTML)
240
+ newsletter.html final rendered email-safe HTML
241
+ newsletter.md programmatic markdown view (per-section ## blocks, ready for Beehiiv paste)
242
+ summary.md — short editorial recap (2-3 sentences)
242
243
  ```
243
244
 
245
+ The `main` branch is a viewer hub (GitHub Pages) — no newsletter content.
246
+
244
247
  `newsletter.md` is built programmatically from the same `structure` JSON the HTML is rendered from (no AI cost) by `lib/markdown-renderer.js`. Each section/dispatch becomes a standalone `## heading` block — drop it into the Beehiiv editor one block at a time and insert ad blocks between dispatches.
245
248
 
246
249
  The `campaignId` is the same Firestore doc ID the cron uses for the generated `marketing-campaigns/{newId}` doc, reserved up front so the GitHub URLs and the Firestore doc always match.
@@ -252,7 +255,7 @@ marketing-campaigns/{newId}: {
252
255
  settings: { subject, preheader, contentHtml, ... },
253
256
  assets: {
254
257
  campaignId, // same as the doc id
255
- folderUrl, // https://github.com/itw-creative-works/newsletter-assets/tree/main/{brandId}/{campaignId}
258
+ folderUrl, // https://github.com/itw-creative-works/newsletter-assets/tree/{brandId}/content/{campaignId}
256
259
  htmlUrl, // https://raw.githubusercontent.com/.../newsletter.html — paste this into Beehiiv as one block
257
260
  markdownUrl, // https://raw.githubusercontent.com/.../newsletter.md — per-section blocks (ads between)
258
261
  summaryUrl, // https://raw.githubusercontent.com/.../summary.md — share-snippet recap
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.10.0",
3
+ "version": "5.10.2",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "files": [
@@ -3,18 +3,23 @@
3
3
  * rendered `newsletter.html`) to the public `itw-creative-works/newsletter-assets`
4
4
  * GitHub repo as a single atomic commit.
5
5
  *
6
+ * Each brand pushes to its own branch (branch name = brandId). This
7
+ * eliminates race conditions — 30+ brands can upload concurrently with
8
+ * zero contention. The `main` branch hosts only the viewer page
9
+ * (index.html on GitHub Pages) for browser-renderable previews.
10
+ *
6
11
  * Returns publicly resolvable URLs:
7
- * - Image URLs (`raw.githubusercontent.com`): embedded in the HTML via
8
- * <img src=...> so Beehiiv / SendGrid / any inbox can render them
12
+ * - Image URLs (`raw.githubusercontent.com/{owner}/{repo}/{brandId}/...`):
13
+ * embedded in the HTML via <img src=...>
9
14
  * - HTML URL (`raw.githubusercontent.com`): download link for manual paste
10
- * into Beehiiv (and a stable archive of every issue's final rendered form)
11
- * - Preview URL (`*.github.io`): browser-renderable HTML via GitHub Pages
15
+ * - Preview URL (`*.github.io/?path=...`): viewer page that fetches and
16
+ * renders the newsletter HTML in-browser
12
17
  *
13
18
  * Public-safety guarantees baked in:
14
19
  * - Only accepts PNG buffers for images — verified by magic-byte check
15
20
  * - HTML must be a non-empty string (no buffers, no other types)
16
21
  * - Path validated against a strict allowlist regex per file kind
17
- * - Repo / branch hardcoded (no env override) so a misconfigured caller
22
+ * - Repo owner hardcoded (no env override) so a misconfigured caller
18
23
  * can't redirect uploads elsewhere
19
24
  * - One atomic commit per newsletter (Git Trees API)
20
25
  *
@@ -25,19 +30,24 @@ const { Octokit } = require('@octokit/rest');
25
30
 
26
31
  const REPO_OWNER = 'itw-creative-works';
27
32
  const REPO_NAME = 'newsletter-assets';
28
- const REPO_BRANCH = 'main';
29
33
 
30
- const RAW_BASE = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${REPO_BRANCH}`;
34
+ // When true, asset URLs use the parent CDN (cdn.{parentDomain}/newsletters/...)
35
+ // instead of raw.githubusercontent.com. Requires a Cloudflare redirect rule
36
+ // on the CDN domain that maps /newsletters/* to raw.githubusercontent.com.
37
+ // Set to false to revert to direct GitHub URLs.
38
+ const USE_CDN_URLS = true;
39
+
31
40
  const PAGES_BASE = `https://${REPO_OWNER}.github.io/${REPO_NAME}`;
32
41
 
33
- // `{brandId}/{campaignId}/section-N.png` both ids are kebab/alphanumeric
34
- const IMAGE_PATH_REGEX = /^[a-z0-9-]+\/[A-Za-z0-9_-]+\/section-\d+\.png$/;
35
- // `{brandId}/{campaignId}/newsletter.html` — fixed file name, same folder
36
- const HTML_PATH_REGEX = /^[a-z0-9-]+\/[A-Za-z0-9_-]+\/newsletter\.html$/;
37
- // `{brandId}/{campaignId}/newsletter.md` markdown view, same folder
38
- const MARKDOWN_PATH_REGEX = /^[a-z0-9-]+\/[A-Za-z0-9_-]+\/newsletter\.md$/;
39
- // `{brandId}/{campaignId}/summary.md` — short editorial recap, same folder
40
- const SUMMARY_PATH_REGEX = /^[a-z0-9-]+\/[A-Za-z0-9_-]+\/summary\.md$/;
42
+ // Fallback RAW_BASE for callers that don't have a brand context
43
+ const RAW_BASE = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/main`;
44
+
45
+ // `content/{campaignId}/section-N.png` — brand is the branch, not the path
46
+ const CONTENT_DIR = 'content';
47
+ const IMAGE_PATH_REGEX = /^content\/[A-Za-z0-9_-]+\/section-\d+\.png$/;
48
+ const HTML_PATH_REGEX = /^content\/[A-Za-z0-9_-]+\/newsletter\.html$/;
49
+ const MARKDOWN_PATH_REGEX = /^content\/[A-Za-z0-9_-]+\/newsletter\.md$/;
50
+ const SUMMARY_PATH_REGEX = /^content\/[A-Za-z0-9_-]+\/summary\.md$/;
41
51
 
42
52
  // PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
43
53
  const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
@@ -50,22 +60,25 @@ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
50
60
  * Optional — pass an empty array or omit if
51
61
  * you only want to upload the HTML (rare).
52
62
  * @param {string} [args.html] - The final rendered newsletter HTML. Uploaded as
53
- * `{brandId}/{campaignId}/newsletter.html`.
63
+ * `content/{campaignId}/newsletter.html`.
54
64
  * @param {string} [args.markdown] - Programmatic markdown view of the newsletter.
55
- * Uploaded as `{brandId}/{campaignId}/newsletter.md`.
65
+ * Uploaded as `content/{campaignId}/newsletter.md`.
56
66
  * @param {string} [args.summary] - Short editorial recap (2-3 sentences). Uploaded
57
- * as `{brandId}/{campaignId}/summary.md`.
67
+ * as `content/{campaignId}/summary.md`.
58
68
  * @param {string} args.brandId - lowercase brand slug (e.g. 'somiibo')
59
69
  * @param {string} args.campaignId - Consumer-side `marketing-campaigns/{id}` Firestore doc ID.
60
70
  * Folder names use this verbatim — stable forever.
61
71
  * @param {string} [args.subject] - Newsletter subject. Embedded in the commit message so
62
72
  * `git log` reads as a human-browseable history.
63
73
  * @param {string} [args.commitMessage] - Full override of the default commit message
74
+ * @param {string} [args.cdnBase] - CDN base URL (e.g. 'https://cdn.itwcreativeworks.com/newsletters').
75
+ * When provided and USE_CDN_URLS is true, asset URLs use this
76
+ * instead of raw.githubusercontent.com.
64
77
  * @param {string} [args.token] - GitHub token (defaults to process.env.GH_TOKEN)
65
78
  * @param {object} [args.assistant] - logger
66
79
  * @returns {Promise<{ urls: string[], paths: string[], htmlUrl?: string, htmlPath?: string, previewUrl?: string, folderUrl: string, commitSha: string }>}
67
80
  */
68
- async function uploadAssets({ images, html, markdown, summary, brandId, campaignId, subject, commitMessage, token, assistant }) {
81
+ async function uploadAssets({ images, html, markdown, summary, brandId, campaignId, subject, commitMessage, cdnBase, token, assistant }) {
69
82
  const hasImages = Array.isArray(images) && images.length > 0;
70
83
  const hasHtml = typeof html === 'string' && html.length > 0;
71
84
  const hasMarkdown = typeof markdown === 'string' && markdown.length > 0;
@@ -86,13 +99,21 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
86
99
 
87
100
  const log = (msg) => assistant?.log ? assistant.log(`[image-host] ${msg}`) : null;
88
101
 
102
+ // Brand branch — each brand gets its own branch, zero cross-brand contention
103
+ const branch = brandId;
104
+ const gitRawBase = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${branch}`;
105
+
106
+ // CDN URLs: cdn.{parentDomain}/newsletters/{brandId}/content/{campaignId}/...
107
+ // The CDN redirect maps /newsletters/{brandId}/* → raw.githubusercontent.com/{brandId}/*
108
+ const assetBase = (USE_CDN_URLS && cdnBase) ? `${cdnBase}/${brandId}` : gitRawBase;
109
+
89
110
  // 1. Build the list of files to commit. Validate each before we touch GitHub.
90
111
  const files = [];
91
112
 
92
113
  if (hasImages) {
93
114
  for (let i = 0; i < images.length; i++) {
94
115
  const img = images[i];
95
- const path = `${brandId}/${campaignId}/section-${i + 1}.png`;
116
+ const path = `${CONTENT_DIR}/${campaignId}/section-${i + 1}.png`;
96
117
 
97
118
  if (!IMAGE_PATH_REGEX.test(path)) {
98
119
  throw new Error(`image-host: refusing to upload — invalid image path "${path}"`);
@@ -115,7 +136,7 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
115
136
  }
116
137
 
117
138
  if (hasHtml) {
118
- const path = `${brandId}/${campaignId}/newsletter.html`;
139
+ const path = `${CONTENT_DIR}/${campaignId}/newsletter.html`;
119
140
 
120
141
  if (!HTML_PATH_REGEX.test(path)) {
121
142
  throw new Error(`image-host: refusing to upload — invalid html path "${path}"`);
@@ -129,7 +150,7 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
129
150
  }
130
151
 
131
152
  if (hasMarkdown) {
132
- const path = `${brandId}/${campaignId}/newsletter.md`;
153
+ const path = `${CONTENT_DIR}/${campaignId}/newsletter.md`;
133
154
 
134
155
  if (!MARKDOWN_PATH_REGEX.test(path)) {
135
156
  throw new Error(`image-host: refusing to upload — invalid markdown path "${path}"`);
@@ -143,7 +164,7 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
143
164
  }
144
165
 
145
166
  if (hasSummary) {
146
- const path = `${brandId}/${campaignId}/summary.md`;
167
+ const path = `${CONTENT_DIR}/${campaignId}/summary.md`;
147
168
 
148
169
  if (!SUMMARY_PATH_REGEX.test(path)) {
149
170
  throw new Error(`image-host: refusing to upload — invalid summary path "${path}"`);
@@ -164,26 +185,41 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
164
185
  hasSummary ? 'summary.md' : null,
165
186
  ].filter(Boolean).join(' + ');
166
187
 
167
- log(`uploading ${fileSummary} to ${REPO_OWNER}/${REPO_NAME} → ${brandId}/${campaignId}/`);
188
+ log(`uploading ${fileSummary} to ${REPO_OWNER}/${REPO_NAME}:${branch} → ${CONTENT_DIR}/${campaignId}/`);
168
189
 
169
190
  const octokit = new Octokit({ auth: githubToken });
170
191
 
171
- // 2. Get current main branch ref + tree
172
- const { data: refData } = await octokit.rest.git.getRef({
173
- owner: REPO_OWNER,
174
- repo: REPO_NAME,
175
- ref: `heads/${REPO_BRANCH}`,
176
- });
177
-
178
- const baseCommitSha = refData.object.sha;
179
-
180
- const { data: baseCommit } = await octokit.rest.git.getCommit({
181
- owner: REPO_OWNER,
182
- repo: REPO_NAME,
183
- commit_sha: baseCommitSha,
184
- });
192
+ // 2. Ensure the brand branch exists. New branches start empty (orphan
193
+ // commit with an empty tree) no files from main, no shared history.
194
+ let branchSha;
195
+ try {
196
+ const { data } = await octokit.rest.git.getRef({ owner: REPO_OWNER, repo: REPO_NAME, ref: `heads/${branch}` });
197
+ branchSha = data.object.sha;
198
+ } catch (e) {
199
+ if (e.status === 404) {
200
+ // Create an empty tree → orphan commit → brand branch
201
+ const { data: emptyTree } = await octokit.rest.git.createTree({
202
+ owner: REPO_OWNER, repo: REPO_NAME, tree: [],
203
+ });
204
+ const { data: orphanCommit } = await octokit.rest.git.createCommit({
205
+ owner: REPO_OWNER, repo: REPO_NAME,
206
+ message: `Initialize ${branch} branch`,
207
+ tree: emptyTree.sha,
208
+ parents: [],
209
+ });
210
+ await octokit.rest.git.createRef({
211
+ owner: REPO_OWNER, repo: REPO_NAME,
212
+ ref: `refs/heads/${branch}`,
213
+ sha: orphanCommit.sha,
214
+ });
215
+ branchSha = orphanCommit.sha;
216
+ log(`created empty branch "${branch}"`);
217
+ } else {
218
+ throw e;
219
+ }
220
+ }
185
221
 
186
- // 3. Create a blob per file
222
+ // 3. Create blobs (content-addressed safe to reuse if we ever add retries)
187
223
  const treeItems = [];
188
224
 
189
225
  for (const file of files) {
@@ -202,34 +238,55 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
202
238
  });
203
239
  }
204
240
 
205
- // 4. Build new tree on top of base
206
- const { data: newTree } = await octokit.rest.git.createTree({
207
- owner: REPO_OWNER,
208
- repo: REPO_NAME,
209
- base_tree: baseCommit.tree.sha,
210
- tree: treeItems,
211
- });
212
-
213
- // 5. Commit. Default message format: "[brand] campaignId — Subject" so
214
- // `git log` doubles as a human-readable index of the (opaque) folder names.
241
+ // 4. Commit + push with retry. The newsletter pipeline calls uploadAssets
242
+ // twice sequentially (images then HTML). GitHub's API can return a stale
243
+ // ref on the second getRef if the first updateRef hasn't fully propagated.
244
+ // Retry with a fresh ref read + jitter handles this.
215
245
  const defaultSubject = subject ? subject.trim() : `${files.length} newsletter asset${files.length === 1 ? '' : 's'}`;
216
246
  const message = commitMessage || `[${brandId}] ${campaignId} — ${defaultSubject}`;
247
+ const MAX_RETRIES = 3;
248
+ let newCommit;
249
+
250
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
251
+ const { data: refData } = await octokit.rest.git.getRef({
252
+ owner: REPO_OWNER, repo: REPO_NAME, ref: `heads/${branch}`,
253
+ });
254
+ const tipSha = refData.object.sha;
217
255
 
218
- const { data: newCommit } = await octokit.rest.git.createCommit({
219
- owner: REPO_OWNER,
220
- repo: REPO_NAME,
221
- message,
222
- tree: newTree.sha,
223
- parents: [baseCommitSha],
224
- });
225
-
226
- // 6. Update branch ref
227
- await octokit.rest.git.updateRef({
228
- owner: REPO_OWNER,
229
- repo: REPO_NAME,
230
- ref: `heads/${REPO_BRANCH}`,
231
- sha: newCommit.sha,
232
- });
256
+ const { data: baseCommit } = await octokit.rest.git.getCommit({
257
+ owner: REPO_OWNER, repo: REPO_NAME, commit_sha: tipSha,
258
+ });
259
+
260
+ const { data: newTree } = await octokit.rest.git.createTree({
261
+ owner: REPO_OWNER, repo: REPO_NAME,
262
+ base_tree: baseCommit.tree.sha,
263
+ tree: treeItems,
264
+ });
265
+
266
+ newCommit = (await octokit.rest.git.createCommit({
267
+ owner: REPO_OWNER, repo: REPO_NAME,
268
+ message,
269
+ tree: newTree.sha,
270
+ parents: [tipSha],
271
+ })).data;
272
+
273
+ try {
274
+ await octokit.rest.git.updateRef({
275
+ owner: REPO_OWNER, repo: REPO_NAME,
276
+ ref: `heads/${branch}`,
277
+ sha: newCommit.sha,
278
+ });
279
+ break;
280
+ } catch (e) {
281
+ if (attempt < MAX_RETRIES && /fast.forward/i.test(e.message)) {
282
+ const jitter = 500 + Math.floor(Math.random() * 1500);
283
+ log(`updateRef race (attempt ${attempt}/${MAX_RETRIES}), retrying in ${jitter}ms...`);
284
+ await new Promise(r => setTimeout(r, jitter));
285
+ continue;
286
+ }
287
+ throw e;
288
+ }
289
+ }
233
290
 
234
291
  // 7. Split the URL list by kind so callers can grab each independently.
235
292
  const imageFiles = files.filter((f) => f.kind === 'image');
@@ -238,29 +295,29 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
238
295
  const summaryFile = files.find((f) => f.kind === 'summary');
239
296
 
240
297
  const result = {
241
- urls: imageFiles.map((f) => `${RAW_BASE}/${f.path}`),
298
+ urls: imageFiles.map((f) => `${assetBase}/${f.path}`),
242
299
  paths: imageFiles.map((f) => f.path),
243
- folderUrl: `https://github.com/${REPO_OWNER}/${REPO_NAME}/tree/${REPO_BRANCH}/${brandId}/${campaignId}`,
300
+ folderUrl: `https://github.com/${REPO_OWNER}/${REPO_NAME}/tree/${branch}/${CONTENT_DIR}/${campaignId}`,
244
301
  commitSha: newCommit.sha,
245
302
  };
246
303
 
247
304
  if (htmlFile) {
248
- result.htmlUrl = `${RAW_BASE}/${htmlFile.path}`;
305
+ result.htmlUrl = `${assetBase}/${htmlFile.path}`;
249
306
  result.htmlPath = htmlFile.path;
250
- result.previewUrl = `${PAGES_BASE}/${htmlFile.path}`;
307
+ result.previewUrl = `${PAGES_BASE}/?path=${encodeURIComponent(htmlFile.path)}&branch=${encodeURIComponent(branch)}`;
251
308
  }
252
309
 
253
310
  if (markdownFile) {
254
- result.markdownUrl = `${RAW_BASE}/${markdownFile.path}`;
311
+ result.markdownUrl = `${assetBase}/${markdownFile.path}`;
255
312
  result.markdownPath = markdownFile.path;
256
313
  }
257
314
 
258
315
  if (summaryFile) {
259
- result.summaryUrl = `${RAW_BASE}/${summaryFile.path}`;
316
+ result.summaryUrl = `${assetBase}/${summaryFile.path}`;
260
317
  result.summaryPath = summaryFile.path;
261
318
  }
262
319
 
263
- log(`committed ${newCommit.sha.slice(0, 7)} — folder: ${result.folderUrl}`);
320
+ log(`committed ${newCommit.sha.slice(0, 7)} to ${branch} — folder: ${result.folderUrl}`);
264
321
 
265
322
  return result;
266
323
  }
@@ -279,9 +336,9 @@ function validateCampaignId(campaignId) {
279
336
 
280
337
  module.exports = {
281
338
  uploadAssets,
339
+ USE_CDN_URLS,
282
340
  REPO_OWNER,
283
341
  REPO_NAME,
284
- REPO_BRANCH,
285
342
  RAW_BASE,
286
343
  PAGES_BASE,
287
344
  };
@@ -39,7 +39,7 @@ function resolveSectionImageFn(newsletterConfig) {
39
39
  return method === 'svg' ? generateSvgSection : generateImageSection;
40
40
  }
41
41
  const { renderMarkdown } = require('./lib/markdown-renderer.js');
42
- const { uploadAssets, RAW_BASE } = require('./lib/image-host.js');
42
+ const { uploadAssets, USE_CDN_URLS, REPO_OWNER, REPO_NAME, RAW_BASE } = require('./lib/image-host.js');
43
43
  const { buildPublicConfig } = require('../../../routes/brand/get.js');
44
44
  const { writeArticle, publishArticle } = require('../../../libraries/content/ghostii.js');
45
45
  const { trackContentSource, contentSourceHash, resolveNewsletterSources } = require('../../../libraries/content/source-resolver.js');
@@ -162,6 +162,23 @@ async function generate(Manager, assistant, settings, opts = {}) {
162
162
  // Defaults to 'github' so production cron path "just works" without flag fiddling.
163
163
  const host = opts.imageHost || 'github';
164
164
 
165
+ // CDN base URL — resolves from the parent config.
166
+ // parent: 'https://itwcreativeworks.com' → cdn.itwcreativeworks.com
167
+ // parent: 'self' / '' / null → falls back to brand.url
168
+ let cdnBase = null;
169
+ if (USE_CDN_URLS && host === 'github') {
170
+ const parentUrl = Manager.config?.parent;
171
+ let cdnDomain;
172
+ if (parentUrl && parentUrl !== 'self' && parentUrl !== '$self' && parentUrl.startsWith('http')) {
173
+ cdnDomain = new URL(parentUrl).hostname;
174
+ } else {
175
+ cdnDomain = brand?.url ? new URL(brand.url).hostname : null;
176
+ }
177
+ if (cdnDomain) {
178
+ cdnBase = `https://cdn.${cdnDomain}/newsletters`;
179
+ }
180
+ }
181
+
165
182
  // campaignId — used as the GitHub folder name (and as the doc ID in production).
166
183
  // Resolution priority (most-specific first):
167
184
  // 1. opts.campaignId — explicit override (test runs, cron paths)
@@ -181,6 +198,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
181
198
  // the article build expands the lead section into a full blog post and
182
199
  // returns its public URL, which we inject as a "Read more" CTA before render.
183
200
  let imagePaths = [];
201
+ let generatedImages = null;
184
202
 
185
203
  const buildImages = async () => {
186
204
  if (opts.skipImages) {
@@ -188,7 +206,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
188
206
  }
189
207
 
190
208
  const generateSectionImage = resolveSectionImageFn(config);
191
- const images = await Promise.all(
209
+ generatedImages = await Promise.all(
192
210
  structure.sections.map((s) => generateSectionImage({
193
211
  imagePrompt: s.image_prompt,
194
212
  brand,
@@ -198,35 +216,23 @@ async function generate(Manager, assistant, settings, opts = {}) {
198
216
  }))
199
217
  );
200
218
 
201
- // Run persistImage side-effects first (writes to disk, etc.). The local
202
- // paths it returns are used as a fallback if no host produces URLs.
219
+ // Run persistImage side-effects (writes to disk for iteration test).
203
220
  const persistedPaths = typeof opts.persistImage === 'function'
204
- ? await Promise.all(images.map((img, i) => opts.persistImage(img, i)))
221
+ ? await Promise.all(generatedImages.map((img, i) => opts.persistImage(img, i)))
205
222
  : null;
206
223
 
207
224
  if (host === 'github') {
208
- try {
209
- const { urls } = await uploadAssets({
210
- images,
211
- brandId: brand?.id,
212
- campaignId,
213
- subject: structure.subject,
214
- assistant,
215
- });
216
- imagePaths = urls;
217
- } catch (e) {
218
- assistant.error(`Newsletter generator: image upload failed — ${e.message}`);
219
- imagePaths = persistedPaths || images.map((_, i) => `about:blank#section-${i + 1}`);
220
- }
225
+ // Compute image URLs from the deterministic path — no upload needed yet.
226
+ // The URLs are based on branch + path, not commit SHA, so they're valid
227
+ // before the commit exists. Everything uploads in one atomic commit later.
228
+ const imgBase = cdnBase ? `${cdnBase}/${brand?.id}` : `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${brand?.id}`;
229
+ imagePaths = generatedImages.map((_, i) => `${imgBase}/content/${campaignId}/section-${i + 1}.png`);
221
230
  } else {
222
- // 'local' use persisted paths if available, else placeholder
223
- imagePaths = persistedPaths || images.map((_, i) => `about:blank#section-${i + 1}`);
231
+ imagePaths = persistedPaths || generatedImages.map((_, i) => `about:blank#section-${i + 1}`);
224
232
  }
225
233
 
226
- assistant.log(`Newsletter generator: ${images.length} images rendered`);
227
-
228
- // Stash images on the return for callers that want to access raw buffers
229
- opts._lastImages = images;
234
+ assistant.log(`Newsletter generator: ${generatedImages.length} images rendered`);
235
+ opts._lastImages = generatedImages;
230
236
  };
231
237
 
232
238
  // The linked-article build is gated by config.article.enabled and needs a lead
@@ -307,9 +313,9 @@ async function generate(Manager, assistant, settings, opts = {}) {
307
313
 
308
314
  const summaryText = (structure.summary || '').trim();
309
315
 
310
- // 3b. Upload the rendered HTML + markdown + summary to GitHub alongside the
311
- // images. All four kinds live in the same {brandId}/{campaignId}/ folder.
312
- // The folder URL becomes the canonical archive of the issue.
316
+ // 3b. Upload EVERYTHING to GitHub in one atomic commit images + HTML +
317
+ // markdown + summary. Image URLs were pre-computed from the deterministic
318
+ // path, so the HTML already embeds the correct URLs before the commit exists.
313
319
  let assetsFolderUrl = null;
314
320
  let htmlUrl = null;
315
321
  let previewUrl = null;
@@ -319,12 +325,14 @@ async function generate(Manager, assistant, settings, opts = {}) {
319
325
  if (host === 'github') {
320
326
  try {
321
327
  const upload = await uploadAssets({
328
+ images: generatedImages,
322
329
  html,
323
330
  markdown,
324
331
  summary: summaryText || undefined,
325
332
  brandId: brand?.id,
326
333
  campaignId,
327
334
  subject: structure.subject,
335
+ cdnBase,
328
336
  assistant,
329
337
  });
330
338
  assetsFolderUrl = upload.folderUrl;
@@ -333,7 +341,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
333
341
  markdownUrl = upload.markdownUrl || null;
334
342
  summaryUrl = upload.summaryUrl || null;
335
343
  } catch (e) {
336
- assistant.error(`Newsletter generator: HTML upload failed — ${e.message}`);
344
+ assistant.error(`Newsletter generator: asset upload failed — ${e.message}`);
337
345
  }
338
346
  }
339
347
 
@@ -377,7 +385,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
377
385
  // paste), markdown URL (per-section blocks for ad insertion), summary
378
386
  // URL, and the tags to set. Failure of THIS email is logged but never
379
387
  // blocks the cron — the campaign doc is still written either way.
380
- if (beehiivFailureReason && htmlUrl) {
388
+ if (beehiivFailureReason) {
381
389
  await sendBeehiivFallbackEmail(Manager, assistant, {
382
390
  brand,
383
391
  subject: structure.subject,
@@ -389,6 +397,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
389
397
  summaryUrl,
390
398
  folderUrl: assetsFolderUrl,
391
399
  reason: beehiivFailureReason,
400
+ articles: (articleResult?.published) ? [{ title: articleResult.article?.title || structure.sections?.[0]?.title, url: articleResult.url }] : [],
392
401
  });
393
402
  }
394
403
 
@@ -545,15 +554,25 @@ async function buildLinkedArticle({ Manager, assistant, brand, config, structure
545
554
  return { url, slug, path: null, published: false, article };
546
555
  }
547
556
 
548
- const result = await publishArticle(assistant, {
549
- brand: publicConfig,
550
- article,
551
- id: Math.round(Date.now() / 1000),
552
- author: config?.article?.author,
553
- postPath: 'newsletter',
554
- });
557
+ try {
558
+ const result = await publishArticle(assistant, {
559
+ brand: publicConfig,
560
+ article,
561
+ id: Math.round(Date.now() / 1000),
562
+ author: config?.article?.author,
563
+ postPath: 'newsletter',
564
+ });
565
+
566
+ if (result?.path) {
567
+ return { url: result.url || url, slug: result.slug || slug, path: result.path, published: true, article };
568
+ }
555
569
 
556
- return { url: result.url || url, slug: result.slug || slug, path: result.path, published: true, article };
570
+ assistant.log(`Newsletter generator: publishArticle returned no path treating as unpublished`);
571
+ return { url, slug, path: null, published: false, article };
572
+ } catch (e) {
573
+ assistant.error(`Newsletter generator: publishArticle failed — ${e.message}`);
574
+ return { url, slug, path: null, published: false, article };
575
+ }
557
576
  }
558
577
 
559
578
  /**
@@ -599,39 +618,73 @@ async function sendBeehiivFallbackEmail(Manager, assistant, args) {
599
618
  const email = Manager.Email(assistant);
600
619
  const messageLines = [];
601
620
 
602
- messageLines.push(`<strong>Beehiiv draft creation failed</strong> — the newsletter is generated and archived, but needs to be manually uploaded to Beehiiv.`);
621
+ messageLines.push(`The newsletter is generated and archived, but Beehiiv draft creation failed. It needs to be manually uploaded.`);
603
622
  messageLines.push('');
604
- messageLines.push(`<strong>Failure reason:</strong> ${args.reason}`);
623
+
624
+ // --- Failure ---
625
+ messageLines.push('<strong>Failure reason</strong>');
626
+ messageLines.push('<ul>');
627
+ messageLines.push(`<li>${args.reason}</li>`);
628
+ messageLines.push('</ul>');
605
629
  messageLines.push('');
606
- messageLines.push('<strong>Newsletter details:</strong>');
630
+
631
+ // --- Details ---
632
+ messageLines.push('<strong>Newsletter details</strong>');
607
633
  messageLines.push('<ul>');
608
- messageLines.push(`<li><strong>Subject:</strong> ${args.subject}</li>`);
609
- messageLines.push(`<li><strong>Preheader:</strong> ${args.preheader || '(none)'}</li>`);
634
+ messageLines.push(`<li>Subject: ${args.subject}</li>`);
635
+ messageLines.push(`<li>Preheader: ${args.preheader || '(none)'}</li>`);
610
636
  if (args.tags?.length) {
611
- messageLines.push(`<li><strong>Tags:</strong> ${args.tags.join(', ')}</li>`);
637
+ messageLines.push(`<li>Tags: ${args.tags.join(', ')}</li>`);
612
638
  }
613
639
  messageLines.push('</ul>');
614
640
  messageLines.push('');
615
- messageLines.push('<strong>Assets:</strong>');
616
- messageLines.push('<ul>');
617
- messageLines.push('<li><strong>Full HTML</strong> (one-shot paste into Beehiiv)');
641
+
642
+ // --- Full HTML ---
643
+ messageLines.push('<strong>Full HTML</strong> one-shot paste into Beehiiv');
618
644
  messageLines.push('<ul>');
619
645
  if (args.previewUrl) {
620
646
  messageLines.push(`<li><a href="${args.previewUrl}">Preview in browser</a></li>`);
621
647
  }
622
648
  messageLines.push(`<li><a href="${args.htmlUrl}">View raw HTML</a></li>`);
623
649
  messageLines.push('</ul>');
624
- messageLines.push('</li>');
650
+ messageLines.push('');
651
+
652
+ // --- Markdown ---
625
653
  if (args.markdownUrl) {
626
- messageLines.push(`<li><a href="${args.markdownUrl}"><strong>Per-section markdown</strong></a> — paste as separate blocks, ads between</li>`);
654
+ messageLines.push('<strong>Per-section markdown</strong> — paste as separate blocks, ads between');
655
+ messageLines.push('<ul>');
656
+ messageLines.push(`<li><a href="${args.markdownUrl}">View markdown</a></li>`);
657
+ messageLines.push('</ul>');
658
+ messageLines.push('');
627
659
  }
660
+
661
+ // --- Summary ---
628
662
  if (args.summaryUrl) {
629
- messageLines.push(`<li><a href="${args.summaryUrl}"><strong>Summary</strong></a> — 2-3 sentence recap</li>`);
663
+ messageLines.push('<strong>Summary</strong> — 2-3 sentence recap');
664
+ messageLines.push('<ul>');
665
+ messageLines.push(`<li><a href="${args.summaryUrl}">View summary</a></li>`);
666
+ messageLines.push('</ul>');
667
+ messageLines.push('');
630
668
  }
669
+
670
+ // --- Linked articles (only published ones) ---
671
+ if (args.articles?.length) {
672
+ messageLines.push('');
673
+ messageLines.push('<strong>Linked articles</strong>');
674
+ messageLines.push('<ul>');
675
+ for (const article of args.articles) {
676
+ messageLines.push(`<li><a href="${article.url}">${article.title || 'Article'}</a></li>`);
677
+ }
678
+ messageLines.push('</ul>');
679
+ }
680
+
681
+ // --- All assets ---
631
682
  if (args.folderUrl) {
632
- messageLines.push(`<li><a href="${args.folderUrl}"><strong>All assets</strong></a> — GitHub folder</li>`);
683
+ messageLines.push('<strong>All assets</strong> — full GitHub archive');
684
+ messageLines.push('<ul>');
685
+ messageLines.push(`<li><a href="${args.folderUrl}">Browse folder</a></li>`);
686
+ messageLines.push('</ul>');
633
687
  }
634
- messageLines.push('</ul>');
635
688
 
636
689
  await email.send({
637
690
  sender: 'internal', // resolves to alerts@{brandDomain}
@@ -96,8 +96,10 @@ module.exports = {
96
96
  const env = process.env;
97
97
 
98
98
  // --- Apply env overrides into newsletterConfig ---
99
- // Newsletter config now lives under marketing.newsletter.content.
100
- const newsletterConfig = JSON.parse(JSON.stringify(config.marketing?.newsletter?.content || {}));
99
+ // Newsletter config lives under marketing.newsletter.content (array or object).
100
+ const rawContent = config.marketing?.newsletter?.content;
101
+ const contentEntry = Array.isArray(rawContent) ? rawContent[0] : rawContent;
102
+ const newsletterConfig = JSON.parse(JSON.stringify(contentEntry || {}));
101
103
 
102
104
  if (env.NEWSLETTER_PROVIDER_STRUCTURE) {
103
105
  newsletterConfig.provider = { ...(newsletterConfig.provider || {}), structure: env.NEWSLETTER_PROVIDER_STRUCTURE };