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
|
|
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}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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/
|
|
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
|
@@ -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
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
//
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
const
|
|
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
|
-
* `
|
|
57
|
+
* `content/{campaignId}/newsletter.html`.
|
|
54
58
|
* @param {string} [args.markdown] - Programmatic markdown view of the newsletter.
|
|
55
|
-
* Uploaded as `
|
|
59
|
+
* Uploaded as `content/{campaignId}/newsletter.md`.
|
|
56
60
|
* @param {string} [args.summary] - Short editorial recap (2-3 sentences). Uploaded
|
|
57
|
-
* as `
|
|
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 = `${
|
|
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 = `${
|
|
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 = `${
|
|
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 = `${
|
|
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} → ${
|
|
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.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
ref: `heads/${
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
|
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
|
|
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: [
|
|
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/${
|
|
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) => `${
|
|
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/${
|
|
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 = `${
|
|
279
|
+
result.htmlUrl = `${rawBase}/${htmlFile.path}`;
|
|
249
280
|
result.htmlPath = htmlFile.path;
|
|
250
|
-
result.previewUrl = `${PAGES_BASE}
|
|
281
|
+
result.previewUrl = `${PAGES_BASE}/?path=${encodeURIComponent(htmlFile.path)}&branch=${encodeURIComponent(branch)}`;
|
|
251
282
|
}
|
|
252
283
|
|
|
253
284
|
if (markdownFile) {
|
|
254
|
-
result.markdownUrl = `${
|
|
285
|
+
result.markdownUrl = `${rawBase}/${markdownFile.path}`;
|
|
255
286
|
result.markdownPath = markdownFile.path;
|
|
256
287
|
}
|
|
257
288
|
|
|
258
289
|
if (summaryFile) {
|
|
259
|
-
result.summaryUrl = `${
|
|
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
|
|
100
|
-
const
|
|
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 };
|