backend-manager 5.10.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.10.1",
3
+ "version": "5.10.2",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "files": [
@@ -31,6 +31,12 @@ const { Octokit } = require('@octokit/rest');
31
31
  const REPO_OWNER = 'itw-creative-works';
32
32
  const REPO_NAME = 'newsletter-assets';
33
33
 
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
+
34
40
  const PAGES_BASE = `https://${REPO_OWNER}.github.io/${REPO_NAME}`;
35
41
 
36
42
  // Fallback RAW_BASE for callers that don't have a brand context
@@ -65,11 +71,14 @@ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
65
71
  * @param {string} [args.subject] - Newsletter subject. Embedded in the commit message so
66
72
  * `git log` reads as a human-browseable history.
67
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.
68
77
  * @param {string} [args.token] - GitHub token (defaults to process.env.GH_TOKEN)
69
78
  * @param {object} [args.assistant] - logger
70
79
  * @returns {Promise<{ urls: string[], paths: string[], htmlUrl?: string, htmlPath?: string, previewUrl?: string, folderUrl: string, commitSha: string }>}
71
80
  */
72
- 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 }) {
73
82
  const hasImages = Array.isArray(images) && images.length > 0;
74
83
  const hasHtml = typeof html === 'string' && html.length > 0;
75
84
  const hasMarkdown = typeof markdown === 'string' && markdown.length > 0;
@@ -92,7 +101,11 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
92
101
 
93
102
  // Brand branch — each brand gets its own branch, zero cross-brand contention
94
103
  const branch = brandId;
95
- const rawBase = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${branch}`;
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;
96
109
 
97
110
  // 1. Build the list of files to commit. Validate each before we touch GitHub.
98
111
  const files = [];
@@ -225,42 +238,55 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
225
238
  });
226
239
  }
227
240
 
228
- // 4. Build tree on top of the branch tip
229
- const { data: baseCommit } = await octokit.rest.git.getCommit({
230
- owner: REPO_OWNER,
231
- repo: REPO_NAME,
232
- commit_sha: branchSha,
233
- });
234
-
235
- const { data: newTree } = await octokit.rest.git.createTree({
236
- owner: REPO_OWNER,
237
- repo: REPO_NAME,
238
- base_tree: baseCommit.tree.sha,
239
- tree: treeItems,
240
- });
241
-
242
- // 5. Commit
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.
243
245
  const defaultSubject = subject ? subject.trim() : `${files.length} newsletter asset${files.length === 1 ? '' : 's'}`;
244
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;
255
+
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
+ });
245
265
 
246
- const { data: newCommit } = await octokit.rest.git.createCommit({
247
- owner: REPO_OWNER,
248
- repo: REPO_NAME,
249
- message,
250
- tree: newTree.sha,
251
- parents: [branchSha],
252
- });
253
-
254
- // 6. Update brand branch ref. Since each brand has its own branch,
255
- // the only possible race is the SAME brand uploading images and HTML
256
- // at the same time — which is sequential in the newsletter pipeline.
257
- // No retry/jitter needed.
258
- await octokit.rest.git.updateRef({
259
- owner: REPO_OWNER,
260
- repo: REPO_NAME,
261
- ref: `heads/${branch}`,
262
- sha: newCommit.sha,
263
- });
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
+ }
264
290
 
265
291
  // 7. Split the URL list by kind so callers can grab each independently.
266
292
  const imageFiles = files.filter((f) => f.kind === 'image');
@@ -269,25 +295,25 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
269
295
  const summaryFile = files.find((f) => f.kind === 'summary');
270
296
 
271
297
  const result = {
272
- urls: imageFiles.map((f) => `${rawBase}/${f.path}`),
298
+ urls: imageFiles.map((f) => `${assetBase}/${f.path}`),
273
299
  paths: imageFiles.map((f) => f.path),
274
300
  folderUrl: `https://github.com/${REPO_OWNER}/${REPO_NAME}/tree/${branch}/${CONTENT_DIR}/${campaignId}`,
275
301
  commitSha: newCommit.sha,
276
302
  };
277
303
 
278
304
  if (htmlFile) {
279
- result.htmlUrl = `${rawBase}/${htmlFile.path}`;
305
+ result.htmlUrl = `${assetBase}/${htmlFile.path}`;
280
306
  result.htmlPath = htmlFile.path;
281
307
  result.previewUrl = `${PAGES_BASE}/?path=${encodeURIComponent(htmlFile.path)}&branch=${encodeURIComponent(branch)}`;
282
308
  }
283
309
 
284
310
  if (markdownFile) {
285
- result.markdownUrl = `${rawBase}/${markdownFile.path}`;
311
+ result.markdownUrl = `${assetBase}/${markdownFile.path}`;
286
312
  result.markdownPath = markdownFile.path;
287
313
  }
288
314
 
289
315
  if (summaryFile) {
290
- result.summaryUrl = `${rawBase}/${summaryFile.path}`;
316
+ result.summaryUrl = `${assetBase}/${summaryFile.path}`;
291
317
  result.summaryPath = summaryFile.path;
292
318
  }
293
319
 
@@ -310,6 +336,7 @@ function validateCampaignId(campaignId) {
310
336
 
311
337
  module.exports = {
312
338
  uploadAssets,
339
+ USE_CDN_URLS,
313
340
  REPO_OWNER,
314
341
  REPO_NAME,
315
342
  RAW_BASE,
@@ -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}