backend-manager 5.9.27 → 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.
- package/docs/marketing-campaigns.md +20 -16
- package/package.json +1 -1
- package/src/manager/events/cron/frequent/marketing-campaigns.js +80 -2
- package/src/manager/libraries/email/generators/lib/image-host.js +82 -52
- package/src/manager/libraries/email/generators/newsletter.js +17 -21
- package/src/mcp/tools.js +2 -2
- package/test/email/campaign-cron-pipeline.js +272 -0
- package/test/email/newsletter-generate.js +4 -2
- package/test/routes/test/usage.js +1 -1
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +0 -160
|
@@ -43,16 +43,18 @@ Recurrence patterns: `daily`, `weekly`, `monthly`, `monthly-weekday`, `quarterly
|
|
|
43
43
|
|
|
44
44
|
The `monthly-weekday` pattern targets the Nth weekday of each month (e.g., 2nd Wednesday). Requires `nth` (1-4) and `day` (0=Sun–6=Sat) in the recurrence object. All other patterns use simple interval addition from the current `sendAt`.
|
|
45
45
|
|
|
46
|
-
All scheduling helpers live in `constants.js` (SSOT): `nextWeekday()`, `nextNthWeekday()`, `nextMonthDay()`, `getNextOccurrence()`.
|
|
46
|
+
All scheduling helpers live in `constants.js` (SSOT): `nextWeekday()`, `nextNthWeekday()`, `nextMonthDay()`, `getNextOccurrence()`. The cron job imports from there — no duplicated logic.
|
|
47
47
|
|
|
48
48
|
## Generator Campaigns
|
|
49
49
|
|
|
50
|
-
Campaigns with a `generator` field
|
|
51
|
-
1.
|
|
50
|
+
Campaigns with a `generator` field (e.g. `generator: 'newsletter'`) are handled by the frequent cron inline — generate content and send in one shot when `sendAt` is due:
|
|
51
|
+
1. Frequent cron finds the generator campaign past its `sendAt`
|
|
52
52
|
2. Runs the generator module (e.g., `generators/newsletter.js`)
|
|
53
|
-
3.
|
|
54
|
-
4.
|
|
55
|
-
5.
|
|
53
|
+
3. Sends the generated content immediately
|
|
54
|
+
4. Stores a history record with generated content + send results
|
|
55
|
+
5. Advances the recurring template's `sendAt` to the next occurrence
|
|
56
|
+
|
|
57
|
+
In production, Beehiiv posts are published (`status: 'confirmed'`). In testing, they're forced to draft (`status: 'draft'`). If Beehiiv upload fails, a fallback alert email is sent to `alerts@{brandDomain}` with all asset links for manual upload.
|
|
56
58
|
|
|
57
59
|
## Email Rendering
|
|
58
60
|
|
|
@@ -229,16 +231,19 @@ AI provider defaults live in code (openai for structure, anthropic for SVG — e
|
|
|
229
231
|
|
|
230
232
|
## Asset hosting (production cron flow)
|
|
231
233
|
|
|
232
|
-
The
|
|
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:
|
|
233
235
|
|
|
234
236
|
```
|
|
235
|
-
{brandId}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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)
|
|
240
243
|
```
|
|
241
244
|
|
|
245
|
+
The `main` branch is a viewer hub (GitHub Pages) — no newsletter content.
|
|
246
|
+
|
|
242
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.
|
|
243
248
|
|
|
244
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.
|
|
@@ -250,7 +255,7 @@ marketing-campaigns/{newId}: {
|
|
|
250
255
|
settings: { subject, preheader, contentHtml, ... },
|
|
251
256
|
assets: {
|
|
252
257
|
campaignId, // same as the doc id
|
|
253
|
-
folderUrl, // https://github.com/itw-creative-works/newsletter-assets/tree/
|
|
258
|
+
folderUrl, // https://github.com/itw-creative-works/newsletter-assets/tree/{brandId}/content/{campaignId}
|
|
254
259
|
htmlUrl, // https://raw.githubusercontent.com/.../newsletter.html — paste this into Beehiiv as one block
|
|
255
260
|
markdownUrl, // https://raw.githubusercontent.com/.../newsletter.md — per-section blocks (ads between)
|
|
256
261
|
summaryUrl, // https://raw.githubusercontent.com/.../summary.md — share-snippet recap
|
|
@@ -366,7 +371,6 @@ marketing: {
|
|
|
366
371
|
| SendGrid provider | `src/manager/libraries/email/providers/sendgrid.js` |
|
|
367
372
|
| Beehiiv provider | `src/manager/libraries/email/providers/beehiiv.js` |
|
|
368
373
|
| Campaign routes | `src/manager/routes/marketing/campaign/{get,post,put,delete}.js` |
|
|
369
|
-
| Campaign cron | `src/manager/cron/frequent/marketing-campaigns.js` |
|
|
370
|
-
|
|
|
371
|
-
| Pruning cron | `src/manager/cron/daily/marketing-prune.js` |
|
|
374
|
+
| Campaign + newsletter cron | `src/manager/events/cron/frequent/marketing-campaigns.js` |
|
|
375
|
+
| Pruning cron | `src/manager/events/cron/daily/marketing-prune.js` |
|
|
372
376
|
| Seed campaigns | `src/cli/commands/setup-tests/helpers/seed-campaigns.js` |
|
package/package.json
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* - email: fires through mailer.sendCampaign()
|
|
7
7
|
* - push: fires through notification.send()
|
|
8
8
|
*
|
|
9
|
+
* Generator campaigns (has `generator` field, e.g. 'newsletter'):
|
|
10
|
+
* - Runs the content generation pipeline (AI content, images, uploads)
|
|
11
|
+
* - Sends the generated content immediately
|
|
12
|
+
* - Stores a history record with the generated content + send results
|
|
13
|
+
* - Advances the recurring template's sendAt to the next occurrence
|
|
14
|
+
*
|
|
9
15
|
* Recurring campaigns (has `recurrence` field):
|
|
10
16
|
* - Creates a history doc in the same collection with results
|
|
11
17
|
* - Advances the recurring doc's sendAt to the next occurrence
|
|
@@ -46,9 +52,81 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
46
52
|
|
|
47
53
|
assistant.log(`Processing campaign ${campaignId} (${type}): ${settings.name}`);
|
|
48
54
|
|
|
49
|
-
// --- Generator campaigns
|
|
55
|
+
// --- Generator campaigns: generate content + send in one shot ---
|
|
50
56
|
if (generator) {
|
|
51
|
-
|
|
57
|
+
const generators = {
|
|
58
|
+
newsletter: require('../../../libraries/email/generators/newsletter.js'),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (!generators[generator]) {
|
|
62
|
+
assistant.log(`Unknown generator "${generator}" on ${campaignId}, skipping`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
assistant.log(`Running generator "${generator}" for ${campaignId}...`);
|
|
67
|
+
|
|
68
|
+
const generatedId = pushid();
|
|
69
|
+
const generated = await generators[generator].generate(Manager, assistant, settings, {
|
|
70
|
+
campaignId: generatedId,
|
|
71
|
+
imageHost: 'github',
|
|
72
|
+
publishArticle: Manager.isProduction(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!generated) {
|
|
76
|
+
assistant.log(`Generator "${generator}" returned no content for ${campaignId}, will retry next run`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const {
|
|
81
|
+
images: _images,
|
|
82
|
+
mjml: _mjml,
|
|
83
|
+
structure: _structure,
|
|
84
|
+
contentMarkdown: _contentMarkdown,
|
|
85
|
+
assets,
|
|
86
|
+
meta,
|
|
87
|
+
...generatedSettings
|
|
88
|
+
} = generated;
|
|
89
|
+
|
|
90
|
+
assistant.log(`Generated content for ${campaignId}: "${generated.subject}"`);
|
|
91
|
+
|
|
92
|
+
// Send immediately
|
|
93
|
+
const campaignResults = await email.sendCampaign({ ...generatedSettings, sendAt: 'now' });
|
|
94
|
+
const success = Object.values(campaignResults).some(r => r.success || r.sent > 0);
|
|
95
|
+
|
|
96
|
+
const nowISO = new Date().toISOString();
|
|
97
|
+
const nowUNIX = Math.round(Date.now() / 1000);
|
|
98
|
+
|
|
99
|
+
// Store history record
|
|
100
|
+
const historyId = pushid();
|
|
101
|
+
await admin.firestore().doc(`marketing-campaigns/${historyId}`).set({
|
|
102
|
+
settings: generatedSettings,
|
|
103
|
+
assets: assets || null,
|
|
104
|
+
meta: meta || null,
|
|
105
|
+
type,
|
|
106
|
+
sendAt: data.sendAt,
|
|
107
|
+
status: success ? 'sent' : 'failed',
|
|
108
|
+
results: campaignResults,
|
|
109
|
+
generatedFrom: campaignId,
|
|
110
|
+
metadata: {
|
|
111
|
+
created: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
112
|
+
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Advance sendAt to next occurrence
|
|
117
|
+
if (recurrence) {
|
|
118
|
+
const nextSendAt = getNextOccurrence(data.sendAt, recurrence);
|
|
119
|
+
await doc.ref.set({
|
|
120
|
+
sendAt: nextSendAt,
|
|
121
|
+
metadata: {
|
|
122
|
+
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
123
|
+
},
|
|
124
|
+
}, { merge: true });
|
|
125
|
+
assistant.log(`${success ? 'Sent' : 'Failed'} generator campaign ${campaignId}, next: ${moment.unix(nextSendAt).toISOString()}`);
|
|
126
|
+
} else {
|
|
127
|
+
assistant.log(`${success ? 'Sent' : 'Failed'} generator campaign ${campaignId} (one-off)`);
|
|
128
|
+
}
|
|
129
|
+
|
|
52
130
|
return;
|
|
53
131
|
}
|
|
54
132
|
|
|
@@ -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
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Newsletter generator — pulls content from parent server and assembles a branded newsletter.
|
|
3
3
|
*
|
|
4
|
-
* Called by the
|
|
4
|
+
* Called by the frequent cron (and the iteration test) when a campaign has
|
|
5
5
|
* `generator: 'newsletter'`. Produces a fully rendered email-safe HTML newsletter ready
|
|
6
6
|
* to ship to Beehiiv / SendGrid.
|
|
7
7
|
*
|
|
@@ -267,14 +267,14 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
267
267
|
|
|
268
268
|
const [, articleResult] = await Promise.all([buildImages(), buildArticle()]);
|
|
269
269
|
|
|
270
|
-
// Inject the "Read the full article" CTA onto the lead section BEFORE render
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
// (derived from the title slug), so the newsletter links correctly either way.
|
|
275
|
-
if (articleResult?.url) {
|
|
270
|
+
// Inject the "Read the full article" CTA onto the lead section BEFORE render,
|
|
271
|
+
// but ONLY if the article was actually published. A CTA pointing to a
|
|
272
|
+
// non-existent post is worse than no CTA at all.
|
|
273
|
+
if (articleResult?.url && articleResult.published) {
|
|
276
274
|
structure.sections[0].cta = { label: 'Read the full article', url: articleResult.url };
|
|
277
|
-
assistant.log(`Newsletter generator: linked article
|
|
275
|
+
assistant.log(`Newsletter generator: linked article published — ${articleResult.url}`);
|
|
276
|
+
} else if (articleResult?.url) {
|
|
277
|
+
assistant.log(`Newsletter generator: linked article generated (not published, CTA omitted) — ${articleResult.url}`);
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
// 3. MJML → HTML
|
|
@@ -337,13 +337,10 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
-
// 3c. Upload to Beehiiv
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
// never thrown — the rest of the pipeline (GH archive, Firestore doc)
|
|
345
|
-
// succeeds regardless. newsletterRoleConfig was already resolved at the top
|
|
346
|
-
// of the function for the initial enabled-check.
|
|
340
|
+
// 3c. Upload to Beehiiv. Published in production, forced to draft in
|
|
341
|
+
// testing so real subscribers never receive test newsletters. Failure
|
|
342
|
+
// is logged, never thrown — the rest of the pipeline (GH archive,
|
|
343
|
+
// Firestore doc) succeeds regardless.
|
|
347
344
|
let beehiivPostId = null;
|
|
348
345
|
let beehiivFailureReason = null;
|
|
349
346
|
|
|
@@ -351,22 +348,21 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
351
348
|
try {
|
|
352
349
|
const beehiivProvider = require('../providers/beehiiv.js');
|
|
353
350
|
const result = await beehiivProvider.createPost({
|
|
354
|
-
publicationId: newsletterRoleConfig.publicationId,
|
|
351
|
+
publicationId: newsletterRoleConfig.publicationId,
|
|
355
352
|
title: structure.subject,
|
|
356
353
|
subject: structure.subject,
|
|
357
354
|
preheader: structure.preheader,
|
|
358
355
|
content: html,
|
|
359
356
|
contentTags: Array.isArray(structure.tags) ? structure.tags : [],
|
|
360
|
-
status: 'draft',
|
|
357
|
+
status: assistant.isTesting() ? 'draft' : 'confirmed',
|
|
361
358
|
});
|
|
362
359
|
|
|
363
360
|
if (result?.success && result.id) {
|
|
364
361
|
beehiivPostId = result.id;
|
|
365
|
-
assistant.log(`Newsletter generator: Beehiiv draft created — ${beehiivPostId}`);
|
|
362
|
+
assistant.log(`Newsletter generator: Beehiiv ${assistant.isTesting() ? 'draft' : 'post'} created — ${beehiivPostId}`);
|
|
366
363
|
} else {
|
|
367
|
-
// Expected today until Enterprise plan — log, do not throw.
|
|
368
364
|
beehiivFailureReason = result?.error || 'unknown error';
|
|
369
|
-
assistant.log(`Newsletter generator: Beehiiv
|
|
365
|
+
assistant.log(`Newsletter generator: Beehiiv upload failed — ${beehiivFailureReason}`);
|
|
370
366
|
}
|
|
371
367
|
} catch (e) {
|
|
372
368
|
beehiivFailureReason = e.message;
|
|
@@ -750,4 +746,4 @@ function generatePushId() {
|
|
|
750
746
|
return id;
|
|
751
747
|
}
|
|
752
748
|
|
|
753
|
-
module.exports = { generate,
|
|
749
|
+
module.exports = { generate, generatePushId };
|
package/src/mcp/tools.js
CHANGED
|
@@ -459,7 +459,7 @@ module.exports = [
|
|
|
459
459
|
// --- Hooks ---
|
|
460
460
|
{
|
|
461
461
|
name: 'run_hook',
|
|
462
|
-
description: 'Execute a hook or BEM cron job by path. Searches: BEM internal crons (e.g. "cron/daily/blog-auto-publisher", "cron/daily/
|
|
462
|
+
description: 'Execute a hook or BEM cron job by path. Searches: BEM internal crons (e.g. "cron/daily/blog-auto-publisher", "cron/daily/reset-usage"), consumer hooks/ directory, and consumer project root. Supports both function exports and class-based hooks.',
|
|
463
463
|
role: 'admin',
|
|
464
464
|
method: 'POST',
|
|
465
465
|
path: 'admin/hook',
|
|
@@ -467,7 +467,7 @@ module.exports = [
|
|
|
467
467
|
inputSchema: {
|
|
468
468
|
type: 'object',
|
|
469
469
|
properties: {
|
|
470
|
-
path: { type: 'string', description: 'Hook path (e.g. "cron/daily/blog-auto-publisher", "cron/daily/
|
|
470
|
+
path: { type: 'string', description: 'Hook path (e.g. "cron/daily/blog-auto-publisher", "cron/daily/reset-usage", "cron/frequent/marketing-campaigns", or a consumer hook path)' },
|
|
471
471
|
},
|
|
472
472
|
required: ['path'],
|
|
473
473
|
},
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Campaign cron pipeline
|
|
3
|
+
*
|
|
4
|
+
* Verifies that bm_cronFrequent correctly picks up and processes campaigns
|
|
5
|
+
* from the marketing-campaigns collection when their sendAt is past due.
|
|
6
|
+
*
|
|
7
|
+
* Covers:
|
|
8
|
+
* 1. One-off campaigns: status changes from 'pending' to 'sent'/'failed'
|
|
9
|
+
* 2. Recurring campaigns: sendAt advances, history doc created
|
|
10
|
+
* 3. Generator campaigns (e.g. newsletter): NOT skipped — the generator
|
|
11
|
+
* pipeline runs inline and produces a new campaign doc
|
|
12
|
+
*
|
|
13
|
+
* Default mode: seeds campaigns and triggers the cron, verifies doc state
|
|
14
|
+
* changes. The generator test verifies the campaign is attempted (not skipped);
|
|
15
|
+
* if the consumer config has newsletter disabled, the generator returns null
|
|
16
|
+
* and the test confirms the graceful "will retry" path.
|
|
17
|
+
*
|
|
18
|
+
* Extended mode: same, but the newsletter generator runs the full AI pipeline.
|
|
19
|
+
*/
|
|
20
|
+
const { getNextOccurrence } = require('../../src/manager/libraries/email/constants.js');
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
description: 'Campaign cron pipeline (frequent cron processes all campaign types)',
|
|
24
|
+
type: 'suite',
|
|
25
|
+
timeout: 60000,
|
|
26
|
+
|
|
27
|
+
tests: [
|
|
28
|
+
{
|
|
29
|
+
name: 'seed-campaigns',
|
|
30
|
+
auth: 'none',
|
|
31
|
+
|
|
32
|
+
async run({ firestore, state }) {
|
|
33
|
+
const now = Math.round(Date.now() / 1000);
|
|
34
|
+
const pastSendAt = now - 600;
|
|
35
|
+
|
|
36
|
+
// One-off email campaign (sendAt 10 min ago)
|
|
37
|
+
await firestore.set('marketing-campaigns/_test-oneoff', {
|
|
38
|
+
status: 'pending',
|
|
39
|
+
type: 'email',
|
|
40
|
+
sendAt: pastSendAt,
|
|
41
|
+
settings: {
|
|
42
|
+
name: '[TEST] One-off blast',
|
|
43
|
+
subject: 'Test subject',
|
|
44
|
+
preheader: 'Test preheader',
|
|
45
|
+
template: 'card',
|
|
46
|
+
data: {
|
|
47
|
+
content: {
|
|
48
|
+
title: 'Test',
|
|
49
|
+
message: 'Test campaign body',
|
|
50
|
+
button: { text: 'Click', url: 'https://example.com' },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
test: true,
|
|
54
|
+
},
|
|
55
|
+
metadata: {
|
|
56
|
+
created: { timestamp: new Date().toISOString(), timestampUNIX: now },
|
|
57
|
+
updated: { timestamp: new Date().toISOString(), timestampUNIX: now },
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Recurring email campaign (sendAt 10 min ago, weekly recurrence)
|
|
62
|
+
await firestore.set('marketing-campaigns/_test-recurring', {
|
|
63
|
+
status: 'pending',
|
|
64
|
+
type: 'email',
|
|
65
|
+
sendAt: pastSendAt,
|
|
66
|
+
recurrence: {
|
|
67
|
+
pattern: 'weekly',
|
|
68
|
+
hour: 10,
|
|
69
|
+
minute: 0,
|
|
70
|
+
day: 1,
|
|
71
|
+
},
|
|
72
|
+
settings: {
|
|
73
|
+
name: '[TEST] Weekly digest',
|
|
74
|
+
subject: 'Weekly digest',
|
|
75
|
+
preheader: 'Your weekly summary',
|
|
76
|
+
template: 'card',
|
|
77
|
+
data: {
|
|
78
|
+
content: {
|
|
79
|
+
title: 'Weekly Digest',
|
|
80
|
+
message: 'Here is your weekly digest.',
|
|
81
|
+
button: { text: 'Read more', url: 'https://example.com' },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
test: true,
|
|
85
|
+
},
|
|
86
|
+
metadata: {
|
|
87
|
+
created: { timestamp: new Date().toISOString(), timestampUNIX: now },
|
|
88
|
+
updated: { timestamp: new Date().toISOString(), timestampUNIX: now },
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Generator campaign (newsletter — sendAt 10 min ago)
|
|
93
|
+
await firestore.set('marketing-campaigns/_test-generator', {
|
|
94
|
+
status: 'pending',
|
|
95
|
+
type: 'email',
|
|
96
|
+
generator: 'newsletter',
|
|
97
|
+
sendAt: pastSendAt,
|
|
98
|
+
recurrence: {
|
|
99
|
+
pattern: 'weekly',
|
|
100
|
+
hour: 17,
|
|
101
|
+
minute: 30,
|
|
102
|
+
day: 2,
|
|
103
|
+
},
|
|
104
|
+
settings: {
|
|
105
|
+
name: '{brand.name} Newsletter — {date.month} {date.year}',
|
|
106
|
+
subject: '',
|
|
107
|
+
preheader: '',
|
|
108
|
+
sender: 'newsletter',
|
|
109
|
+
providers: ['newsletter'],
|
|
110
|
+
},
|
|
111
|
+
metadata: {
|
|
112
|
+
created: { timestamp: new Date().toISOString(), timestampUNIX: now },
|
|
113
|
+
updated: { timestamp: new Date().toISOString(), timestampUNIX: now },
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
state.pastSendAt = pastSendAt;
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
name: 'trigger-frequent-cron',
|
|
123
|
+
auth: 'none',
|
|
124
|
+
timeout: 120000,
|
|
125
|
+
|
|
126
|
+
async run({ pubsub, waitFor, firestore, state }) {
|
|
127
|
+
await pubsub.trigger('bm_cronFrequent');
|
|
128
|
+
|
|
129
|
+
// Wait for BOTH the one-off and recurring campaigns to be processed.
|
|
130
|
+
// The cron processes all campaigns in parallel via Promise.allSettled,
|
|
131
|
+
// but individual Firestore writes may land at different times.
|
|
132
|
+
await waitFor(
|
|
133
|
+
async () => {
|
|
134
|
+
const oneoff = await firestore.get('marketing-campaigns/_test-oneoff');
|
|
135
|
+
const recurring = await firestore.get('marketing-campaigns/_test-recurring');
|
|
136
|
+
return oneoff?.status !== 'pending' && recurring?.sendAt !== state.pastSendAt;
|
|
137
|
+
},
|
|
138
|
+
90000,
|
|
139
|
+
1000,
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
name: 'oneoff-campaign-processed',
|
|
146
|
+
auth: 'none',
|
|
147
|
+
|
|
148
|
+
async run({ firestore, assert }) {
|
|
149
|
+
const doc = await firestore.get('marketing-campaigns/_test-oneoff');
|
|
150
|
+
|
|
151
|
+
assert.ok(doc, 'One-off campaign doc should exist');
|
|
152
|
+
assert.ok(
|
|
153
|
+
doc.status === 'sent' || doc.status === 'failed',
|
|
154
|
+
`One-off campaign status should be sent or failed, got: ${doc.status}`,
|
|
155
|
+
);
|
|
156
|
+
assert.ok(doc.metadata?.updated, 'Should have updated metadata');
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
{
|
|
161
|
+
name: 'recurring-campaign-advanced',
|
|
162
|
+
auth: 'none',
|
|
163
|
+
|
|
164
|
+
async run({ firestore, assert, state }) {
|
|
165
|
+
const doc = await firestore.get('marketing-campaigns/_test-recurring');
|
|
166
|
+
|
|
167
|
+
assert.ok(doc, 'Recurring campaign doc should exist');
|
|
168
|
+
assert.equal(doc.status, 'pending', 'Recurring campaign status stays pending');
|
|
169
|
+
|
|
170
|
+
const expectedNext = getNextOccurrence(state.pastSendAt, {
|
|
171
|
+
pattern: 'weekly',
|
|
172
|
+
hour: 10,
|
|
173
|
+
minute: 0,
|
|
174
|
+
day: 1,
|
|
175
|
+
});
|
|
176
|
+
assert.equal(doc.sendAt, expectedNext, 'sendAt should advance to next occurrence');
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
{
|
|
181
|
+
name: 'recurring-campaign-has-history',
|
|
182
|
+
auth: 'none',
|
|
183
|
+
|
|
184
|
+
async run({ firestore, assert }) {
|
|
185
|
+
const snapshot = await firestore.collection('marketing-campaigns')
|
|
186
|
+
.where('recurringId', '==', '_test-recurring')
|
|
187
|
+
.limit(1)
|
|
188
|
+
.get();
|
|
189
|
+
|
|
190
|
+
assert.ok(!snapshot.empty, 'Should have a history doc from the recurring campaign');
|
|
191
|
+
|
|
192
|
+
const history = snapshot.docs[0].data();
|
|
193
|
+
assert.ok(
|
|
194
|
+
history.status === 'sent' || history.status === 'failed',
|
|
195
|
+
'History doc status should be sent or failed',
|
|
196
|
+
);
|
|
197
|
+
assert.equal(history.recurringId, '_test-recurring', 'Should reference the recurring campaign');
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
{
|
|
202
|
+
name: 'generator-campaign-not-skipped',
|
|
203
|
+
auth: 'none',
|
|
204
|
+
|
|
205
|
+
async run({ firestore, assert, state, config }) {
|
|
206
|
+
const doc = await firestore.get('marketing-campaigns/_test-generator');
|
|
207
|
+
assert.ok(doc, 'Generator campaign doc should still exist');
|
|
208
|
+
|
|
209
|
+
const newsletterEnabled = config.marketing?.newsletter?.enabled;
|
|
210
|
+
|
|
211
|
+
if (newsletterEnabled && process.env.TEST_EXTENDED_MODE) {
|
|
212
|
+
// Extended mode with newsletter enabled: generator should have
|
|
213
|
+
// generated + sent in one shot, created a history doc, and
|
|
214
|
+
// advanced sendAt
|
|
215
|
+
const expectedNext = getNextOccurrence(state.pastSendAt, {
|
|
216
|
+
pattern: 'weekly',
|
|
217
|
+
hour: 17,
|
|
218
|
+
minute: 30,
|
|
219
|
+
day: 2,
|
|
220
|
+
});
|
|
221
|
+
assert.equal(doc.sendAt, expectedNext, 'Generator campaign sendAt should advance');
|
|
222
|
+
|
|
223
|
+
const histSnapshot = await firestore.collection('marketing-campaigns')
|
|
224
|
+
.where('generatedFrom', '==', '_test-generator')
|
|
225
|
+
.limit(1)
|
|
226
|
+
.get();
|
|
227
|
+
assert.ok(!histSnapshot.empty, 'Should have a history doc from the generator');
|
|
228
|
+
const histDoc = histSnapshot.docs[0].data();
|
|
229
|
+
assert.ok(
|
|
230
|
+
histDoc.status === 'sent' || histDoc.status === 'failed',
|
|
231
|
+
'History doc status should be sent or failed',
|
|
232
|
+
);
|
|
233
|
+
assert.ok(!histDoc.generator, 'History doc should NOT have a generator field');
|
|
234
|
+
} else {
|
|
235
|
+
// Non-extended or newsletter disabled: generator returns null,
|
|
236
|
+
// sendAt stays unchanged (will retry next run). The critical
|
|
237
|
+
// assertion is that the campaign was ATTEMPTED, not skipped —
|
|
238
|
+
// verified by the cron logs showing "Running generator" and
|
|
239
|
+
// "returned no content" instead of "Skipping generator campaign".
|
|
240
|
+
assert.equal(doc.status, 'pending', 'Status stays pending when generator returns null');
|
|
241
|
+
assert.equal(doc.sendAt, state.pastSendAt, 'sendAt stays unchanged for retry when generator returns null');
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
{
|
|
247
|
+
name: 'cleanup',
|
|
248
|
+
auth: 'none',
|
|
249
|
+
|
|
250
|
+
async run({ firestore }) {
|
|
251
|
+
await firestore.delete('marketing-campaigns/_test-oneoff');
|
|
252
|
+
await firestore.delete('marketing-campaigns/_test-recurring');
|
|
253
|
+
await firestore.delete('marketing-campaigns/_test-generator');
|
|
254
|
+
|
|
255
|
+
// Clean up history docs (from both recurring and generator campaigns)
|
|
256
|
+
const recurringHist = await firestore.collection('marketing-campaigns')
|
|
257
|
+
.where('recurringId', '==', '_test-recurring')
|
|
258
|
+
.get();
|
|
259
|
+
for (const doc of recurringHist.docs) {
|
|
260
|
+
await doc.ref.delete();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const generatorHist = await firestore.collection('marketing-campaigns')
|
|
264
|
+
.where('generatedFrom', '==', '_test-generator')
|
|
265
|
+
.get();
|
|
266
|
+
for (const doc of generatorHist.docs) {
|
|
267
|
+
await doc.ref.delete();
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
};
|
|
@@ -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 };
|
|
@@ -257,7 +257,7 @@ module.exports = {
|
|
|
257
257
|
|
|
258
258
|
// Wait for cron to reset daily counter.
|
|
259
259
|
// bm_cronDaily executes every registered daily job sequentially. In EXTENDED
|
|
260
|
-
// mode the real-API jobs (
|
|
260
|
+
// mode the real-API jobs (expire-paypal-cancellations,
|
|
261
261
|
// blog-auto-publisher, etc.) can take 40-50s combined before reset-usage
|
|
262
262
|
// (alphabetical tail) gets its turn. 70s gives that the headroom it needs;
|
|
263
263
|
// the per-test `timeout` below matches.
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Newsletter pre-generation cron job
|
|
3
|
-
*
|
|
4
|
-
* Runs daily. Looks for generator campaigns (e.g., _recurring-newsletter)
|
|
5
|
-
* with sendAt within the next 24 hours. For each due campaign it runs the
|
|
6
|
-
* FULL pipeline:
|
|
7
|
-
*
|
|
8
|
-
* 1. Fetch + filter sources from parent server (per brand category set)
|
|
9
|
-
* 2. AI authors structured content (subject, sections/dispatches, signoff, ...)
|
|
10
|
-
* 3. AI authors per-section SVG, rasterized to PNG
|
|
11
|
-
* 4. Upload PNGs to itw-creative-works/newsletter-assets/{brandId}/{newId}/
|
|
12
|
-
* and render MJML → email-safe HTML embedding those URLs
|
|
13
|
-
* 5. Upload the rendered newsletter.html into the same folder so the issue
|
|
14
|
-
* has a browseable, downloadable archive (and a paste-into-Beehiiv URL)
|
|
15
|
-
* 6. Create a NEW pending campaign doc with the generated content + asset URLs
|
|
16
|
-
* 7. Advance the recurring template's sendAt to the next occurrence
|
|
17
|
-
*
|
|
18
|
-
* The generated campaign appears on the calendar for review.
|
|
19
|
-
* The frequent cron picks it up and sends it when sendAt is due.
|
|
20
|
-
*
|
|
21
|
-
* Generated doc shape (marketing-campaigns/{newId}):
|
|
22
|
-
* {
|
|
23
|
-
* settings: { ...generated content, subject, contentHtml, ... },
|
|
24
|
-
* assets: { folderUrl, htmlUrl, imageUrls, campaignId },
|
|
25
|
-
* meta: { telemetry — tokens, cost, durations, source scores },
|
|
26
|
-
* type, sendAt, status: 'pending', generatedFrom, metadata
|
|
27
|
-
* }
|
|
28
|
-
*
|
|
29
|
-
* Runs on bm_cronDaily.
|
|
30
|
-
*/
|
|
31
|
-
const moment = require('moment');
|
|
32
|
-
const pushid = require('pushid');
|
|
33
|
-
const { getNextOccurrence } = require('../../../libraries/email/constants.js');
|
|
34
|
-
|
|
35
|
-
// Generator modules — keyed by generator field value
|
|
36
|
-
const generators = {
|
|
37
|
-
newsletter: require('../../../libraries/email/generators/newsletter.js'),
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
module.exports = async ({ Manager, assistant, libraries }) => {
|
|
41
|
-
const { admin } = libraries;
|
|
42
|
-
|
|
43
|
-
const now = Math.round(Date.now() / 1000);
|
|
44
|
-
const oneDayFromNow = now + (24 * 60 * 60);
|
|
45
|
-
|
|
46
|
-
// Find generator campaigns with sendAt within the next 24 hours
|
|
47
|
-
const snapshot = await admin.firestore()
|
|
48
|
-
.collection('marketing-campaigns')
|
|
49
|
-
.where('status', '==', 'pending')
|
|
50
|
-
.where('sendAt', '<=', oneDayFromNow)
|
|
51
|
-
.get();
|
|
52
|
-
|
|
53
|
-
// Filter to only generator campaigns (can't query on field existence in Firestore)
|
|
54
|
-
const generatorDocs = snapshot.docs.filter(doc => doc.data().generator);
|
|
55
|
-
|
|
56
|
-
if (!generatorDocs.length) {
|
|
57
|
-
assistant.log('No generator campaigns due within 24 hours');
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
assistant.log(`Pre-generating ${generatorDocs.length} campaign(s)...`);
|
|
62
|
-
|
|
63
|
-
for (const doc of generatorDocs) {
|
|
64
|
-
const data = doc.data();
|
|
65
|
-
const { settings, type, generator, recurrence } = data;
|
|
66
|
-
const campaignId = doc.id;
|
|
67
|
-
|
|
68
|
-
if (!generators[generator]) {
|
|
69
|
-
assistant.log(`Unknown generator "${generator}" on ${campaignId}, skipping`);
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
assistant.log(`Generating content for ${campaignId} (${generator}): ${settings.name}`);
|
|
74
|
-
|
|
75
|
-
// Reserve the new doc ID UP FRONT so the generator can use it as the
|
|
76
|
-
// GitHub folder name. The asset URLs (raw.githubusercontent.com/.../{newId}/...)
|
|
77
|
-
// get baked into the rendered HTML, and we want those URLs to match the
|
|
78
|
-
// Firestore doc that hosts the campaign. Stable, predictable, browseable.
|
|
79
|
-
const newId = pushid();
|
|
80
|
-
|
|
81
|
-
// Run the generator with imageHost forced to 'github' (production cron
|
|
82
|
-
// path always uploads — that's what "production" means here) and the
|
|
83
|
-
// campaignId pinned so all assets land in marketing-campaigns/{newId}/'s
|
|
84
|
-
// matching folder. publishArticle: true so the linked blog post (when
|
|
85
|
-
// config.article.enabled is on) is actually committed to the website repo.
|
|
86
|
-
const generated = await generators[generator].generate(Manager, assistant, settings, {
|
|
87
|
-
campaignId: newId,
|
|
88
|
-
imageHost: 'github',
|
|
89
|
-
publishArticle: true,
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
if (!generated) {
|
|
93
|
-
assistant.log(`Generator "${generator}" returned no content for ${campaignId}, skipping`);
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const nowISO = new Date().toISOString();
|
|
98
|
-
const nowUNIX = Math.round(Date.now() / 1000);
|
|
99
|
-
|
|
100
|
-
// Strip non-serializable / oversized fields out of the generator's return
|
|
101
|
-
// before writing to Firestore.
|
|
102
|
-
// images: Buffer[] — not safe to persist
|
|
103
|
-
// mjml: raw template string — pollutes the doc, available in the GH archive
|
|
104
|
-
// structure: full JSON dump (5-10kb) — available via assets.markdownUrl + assets.htmlUrl
|
|
105
|
-
// contentMarkdown: large markdown blob — available via assets.markdownUrl
|
|
106
|
-
const {
|
|
107
|
-
images: _images,
|
|
108
|
-
mjml: _mjml,
|
|
109
|
-
structure: _structure,
|
|
110
|
-
contentMarkdown: _contentMarkdown,
|
|
111
|
-
assets,
|
|
112
|
-
meta,
|
|
113
|
-
...campaignSettings
|
|
114
|
-
} = generated;
|
|
115
|
-
|
|
116
|
-
await admin.firestore().doc(`marketing-campaigns/${newId}`).set({
|
|
117
|
-
settings: campaignSettings,
|
|
118
|
-
assets: assets || null, // { folderUrl, htmlUrl, markdownUrl, summaryUrl, imageUrls, beehiivPostId, tags, campaignId }
|
|
119
|
-
meta: meta || null, // tokens, cost, durations, source scores
|
|
120
|
-
type,
|
|
121
|
-
sendAt: data.sendAt,
|
|
122
|
-
status: 'pending',
|
|
123
|
-
generatedFrom: campaignId,
|
|
124
|
-
metadata: {
|
|
125
|
-
created: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
126
|
-
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
127
|
-
},
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
assistant.log(`Created campaign ${newId} from generator ${campaignId}: "${generated.subject}"`);
|
|
131
|
-
if (assets?.htmlUrl) {
|
|
132
|
-
assistant.log(` HTML: ${assets.htmlUrl}`);
|
|
133
|
-
assistant.log(` Markdown: ${assets.markdownUrl || '(none)'}`);
|
|
134
|
-
assistant.log(` Summary: ${assets.summaryUrl || '(none)'}`);
|
|
135
|
-
assistant.log(` Folder: ${assets.folderUrl}`);
|
|
136
|
-
}
|
|
137
|
-
if (assets?.beehiivPostId) {
|
|
138
|
-
assistant.log(` Beehiiv: draft post ${assets.beehiivPostId}`);
|
|
139
|
-
}
|
|
140
|
-
if (assets?.tags?.length) {
|
|
141
|
-
assistant.log(` Tags: ${assets.tags.join(', ')}`);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Advance the recurring doc's sendAt to the next occurrence
|
|
145
|
-
if (recurrence) {
|
|
146
|
-
const nextSendAt = getNextOccurrence(data.sendAt, recurrence);
|
|
147
|
-
|
|
148
|
-
await doc.ref.set({
|
|
149
|
-
sendAt: nextSendAt,
|
|
150
|
-
metadata: {
|
|
151
|
-
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
152
|
-
},
|
|
153
|
-
}, { merge: true });
|
|
154
|
-
|
|
155
|
-
assistant.log(`Advanced ${campaignId} sendAt to ${moment.unix(nextSendAt).toISOString()}`);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
assistant.log('Pre-generation complete');
|
|
160
|
-
};
|