backend-manager 5.10.0 → 5.10.1

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.1",
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,18 @@ 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}`;
31
34
  const PAGES_BASE = `https://${REPO_OWNER}.github.io/${REPO_NAME}`;
32
35
 
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$/;
36
+ // Fallback RAW_BASE for callers that don't have a brand context
37
+ const RAW_BASE = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/main`;
38
+
39
+ // `content/{campaignId}/section-N.png` — brand is the branch, not the path
40
+ const CONTENT_DIR = 'content';
41
+ const IMAGE_PATH_REGEX = /^content\/[A-Za-z0-9_-]+\/section-\d+\.png$/;
42
+ const HTML_PATH_REGEX = /^content\/[A-Za-z0-9_-]+\/newsletter\.html$/;
43
+ const MARKDOWN_PATH_REGEX = /^content\/[A-Za-z0-9_-]+\/newsletter\.md$/;
44
+ const SUMMARY_PATH_REGEX = /^content\/[A-Za-z0-9_-]+\/summary\.md$/;
41
45
 
42
46
  // PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
43
47
  const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
@@ -50,11 +54,11 @@ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
50
54
  * Optional — pass an empty array or omit if
51
55
  * you only want to upload the HTML (rare).
52
56
  * @param {string} [args.html] - The final rendered newsletter HTML. Uploaded as
53
- * `{brandId}/{campaignId}/newsletter.html`.
57
+ * `content/{campaignId}/newsletter.html`.
54
58
  * @param {string} [args.markdown] - Programmatic markdown view of the newsletter.
55
- * Uploaded as `{brandId}/{campaignId}/newsletter.md`.
59
+ * Uploaded as `content/{campaignId}/newsletter.md`.
56
60
  * @param {string} [args.summary] - Short editorial recap (2-3 sentences). Uploaded
57
- * as `{brandId}/{campaignId}/summary.md`.
61
+ * as `content/{campaignId}/summary.md`.
58
62
  * @param {string} args.brandId - lowercase brand slug (e.g. 'somiibo')
59
63
  * @param {string} args.campaignId - Consumer-side `marketing-campaigns/{id}` Firestore doc ID.
60
64
  * Folder names use this verbatim — stable forever.
@@ -86,13 +90,17 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
86
90
 
87
91
  const log = (msg) => assistant?.log ? assistant.log(`[image-host] ${msg}`) : null;
88
92
 
93
+ // Brand branch — each brand gets its own branch, zero cross-brand contention
94
+ const branch = brandId;
95
+ const rawBase = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${branch}`;
96
+
89
97
  // 1. Build the list of files to commit. Validate each before we touch GitHub.
90
98
  const files = [];
91
99
 
92
100
  if (hasImages) {
93
101
  for (let i = 0; i < images.length; i++) {
94
102
  const img = images[i];
95
- const path = `${brandId}/${campaignId}/section-${i + 1}.png`;
103
+ const path = `${CONTENT_DIR}/${campaignId}/section-${i + 1}.png`;
96
104
 
97
105
  if (!IMAGE_PATH_REGEX.test(path)) {
98
106
  throw new Error(`image-host: refusing to upload — invalid image path "${path}"`);
@@ -115,7 +123,7 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
115
123
  }
116
124
 
117
125
  if (hasHtml) {
118
- const path = `${brandId}/${campaignId}/newsletter.html`;
126
+ const path = `${CONTENT_DIR}/${campaignId}/newsletter.html`;
119
127
 
120
128
  if (!HTML_PATH_REGEX.test(path)) {
121
129
  throw new Error(`image-host: refusing to upload — invalid html path "${path}"`);
@@ -129,7 +137,7 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
129
137
  }
130
138
 
131
139
  if (hasMarkdown) {
132
- const path = `${brandId}/${campaignId}/newsletter.md`;
140
+ const path = `${CONTENT_DIR}/${campaignId}/newsletter.md`;
133
141
 
134
142
  if (!MARKDOWN_PATH_REGEX.test(path)) {
135
143
  throw new Error(`image-host: refusing to upload — invalid markdown path "${path}"`);
@@ -143,7 +151,7 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
143
151
  }
144
152
 
145
153
  if (hasSummary) {
146
- const path = `${brandId}/${campaignId}/summary.md`;
154
+ const path = `${CONTENT_DIR}/${campaignId}/summary.md`;
147
155
 
148
156
  if (!SUMMARY_PATH_REGEX.test(path)) {
149
157
  throw new Error(`image-host: refusing to upload — invalid summary path "${path}"`);
@@ -164,26 +172,41 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
164
172
  hasSummary ? 'summary.md' : null,
165
173
  ].filter(Boolean).join(' + ');
166
174
 
167
- log(`uploading ${fileSummary} to ${REPO_OWNER}/${REPO_NAME} → ${brandId}/${campaignId}/`);
175
+ log(`uploading ${fileSummary} to ${REPO_OWNER}/${REPO_NAME}:${branch} → ${CONTENT_DIR}/${campaignId}/`);
168
176
 
169
177
  const octokit = new Octokit({ auth: githubToken });
170
178
 
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
- });
179
+ // 2. Ensure the brand branch exists. New branches start empty (orphan
180
+ // commit with an empty tree) no files from main, no shared history.
181
+ let branchSha;
182
+ try {
183
+ const { data } = await octokit.rest.git.getRef({ owner: REPO_OWNER, repo: REPO_NAME, ref: `heads/${branch}` });
184
+ branchSha = data.object.sha;
185
+ } catch (e) {
186
+ if (e.status === 404) {
187
+ // Create an empty tree → orphan commit → brand branch
188
+ const { data: emptyTree } = await octokit.rest.git.createTree({
189
+ owner: REPO_OWNER, repo: REPO_NAME, tree: [],
190
+ });
191
+ const { data: orphanCommit } = await octokit.rest.git.createCommit({
192
+ owner: REPO_OWNER, repo: REPO_NAME,
193
+ message: `Initialize ${branch} branch`,
194
+ tree: emptyTree.sha,
195
+ parents: [],
196
+ });
197
+ await octokit.rest.git.createRef({
198
+ owner: REPO_OWNER, repo: REPO_NAME,
199
+ ref: `refs/heads/${branch}`,
200
+ sha: orphanCommit.sha,
201
+ });
202
+ branchSha = orphanCommit.sha;
203
+ log(`created empty branch "${branch}"`);
204
+ } else {
205
+ throw e;
206
+ }
207
+ }
185
208
 
186
- // 3. Create a blob per file
209
+ // 3. Create blobs (content-addressed safe to reuse if we ever add retries)
187
210
  const treeItems = [];
188
211
 
189
212
  for (const file of files) {
@@ -202,7 +225,13 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
202
225
  });
203
226
  }
204
227
 
205
- // 4. Build new tree on top of base
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
+
206
235
  const { data: newTree } = await octokit.rest.git.createTree({
207
236
  owner: REPO_OWNER,
208
237
  repo: REPO_NAME,
@@ -210,8 +239,7 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
210
239
  tree: treeItems,
211
240
  });
212
241
 
213
- // 5. Commit. Default message format: "[brand] campaignId — Subject" so
214
- // `git log` doubles as a human-readable index of the (opaque) folder names.
242
+ // 5. Commit
215
243
  const defaultSubject = subject ? subject.trim() : `${files.length} newsletter asset${files.length === 1 ? '' : 's'}`;
216
244
  const message = commitMessage || `[${brandId}] ${campaignId} — ${defaultSubject}`;
217
245
 
@@ -220,14 +248,17 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
220
248
  repo: REPO_NAME,
221
249
  message,
222
250
  tree: newTree.sha,
223
- parents: [baseCommitSha],
251
+ parents: [branchSha],
224
252
  });
225
253
 
226
- // 6. Update branch ref
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.
227
258
  await octokit.rest.git.updateRef({
228
259
  owner: REPO_OWNER,
229
260
  repo: REPO_NAME,
230
- ref: `heads/${REPO_BRANCH}`,
261
+ ref: `heads/${branch}`,
231
262
  sha: newCommit.sha,
232
263
  });
233
264
 
@@ -238,29 +269,29 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
238
269
  const summaryFile = files.find((f) => f.kind === 'summary');
239
270
 
240
271
  const result = {
241
- urls: imageFiles.map((f) => `${RAW_BASE}/${f.path}`),
272
+ urls: imageFiles.map((f) => `${rawBase}/${f.path}`),
242
273
  paths: imageFiles.map((f) => f.path),
243
- folderUrl: `https://github.com/${REPO_OWNER}/${REPO_NAME}/tree/${REPO_BRANCH}/${brandId}/${campaignId}`,
274
+ folderUrl: `https://github.com/${REPO_OWNER}/${REPO_NAME}/tree/${branch}/${CONTENT_DIR}/${campaignId}`,
244
275
  commitSha: newCommit.sha,
245
276
  };
246
277
 
247
278
  if (htmlFile) {
248
- result.htmlUrl = `${RAW_BASE}/${htmlFile.path}`;
279
+ result.htmlUrl = `${rawBase}/${htmlFile.path}`;
249
280
  result.htmlPath = htmlFile.path;
250
- result.previewUrl = `${PAGES_BASE}/${htmlFile.path}`;
281
+ result.previewUrl = `${PAGES_BASE}/?path=${encodeURIComponent(htmlFile.path)}&branch=${encodeURIComponent(branch)}`;
251
282
  }
252
283
 
253
284
  if (markdownFile) {
254
- result.markdownUrl = `${RAW_BASE}/${markdownFile.path}`;
285
+ result.markdownUrl = `${rawBase}/${markdownFile.path}`;
255
286
  result.markdownPath = markdownFile.path;
256
287
  }
257
288
 
258
289
  if (summaryFile) {
259
- result.summaryUrl = `${RAW_BASE}/${summaryFile.path}`;
290
+ result.summaryUrl = `${rawBase}/${summaryFile.path}`;
260
291
  result.summaryPath = summaryFile.path;
261
292
  }
262
293
 
263
- log(`committed ${newCommit.sha.slice(0, 7)} — folder: ${result.folderUrl}`);
294
+ log(`committed ${newCommit.sha.slice(0, 7)} to ${branch} — folder: ${result.folderUrl}`);
264
295
 
265
296
  return result;
266
297
  }
@@ -281,7 +312,6 @@ module.exports = {
281
312
  uploadAssets,
282
313
  REPO_OWNER,
283
314
  REPO_NAME,
284
- REPO_BRANCH,
285
315
  RAW_BASE,
286
316
  PAGES_BASE,
287
317
  };
@@ -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 };