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.
@@ -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()`. Both cron jobs import from there — no duplicated logic.
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 don't send directly. A daily cron pre-generates content 24 hours before `sendAt`:
51
- 1. Daily cron finds generator campaigns due within 24 hours
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. Creates a NEW standalone `pending` campaign with generated content
54
- 4. Advances the recurring template's `sendAt`
55
- 5. Generated campaign appears on calendar for review, sent by frequent cron when due
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 daily 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:
233
235
 
234
236
  ```
235
- {brandId}/{campaignId}/
236
- section-N.png — per-section illustration (embedded in HTML)
237
- newsletter.html final rendered email-safe HTML
238
- newsletter.md programmatic markdown view (per-section ## blocks, ready for Beehiiv paste)
239
- 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)
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/main/{brandId}/{campaignId}
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
- | Newsletter pre-gen cron | `src/manager/cron/daily/marketing-newsletter-generate.js` |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.9.27",
3
+ "version": "5.10.1",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "files": [
@@ -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 are handled by the daily pre-generation cron, not here ---
55
+ // --- Generator campaigns: generate content + send in one shot ---
50
56
  if (generator) {
51
- assistant.log(`Skipping generator campaign ${campaignId} — handled by daily pre-generation cron`);
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`): 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
  };
@@ -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 daily pre-generation cron (and the iteration test) when a campaign has
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
- // sectionCard (MJML) renders section.cta = { label, url } automatically; the
272
- // markdown renderer emits it too. The URL is the post's public blog URL
273
- // present whether the article was actually published or only generated
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 ${articleResult.published ? 'published' : 'generated (not published)'} — ${articleResult.url}`);
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 as a draft. Uses the same complete HTML with
341
- // CDN image URLs already embedded. Today this will fail (Beehiiv's
342
- // post-creation API requires Enterprise plan), but we ship it anyway
343
- // so the day we upgrade to Enterprise it Just Works. Failure is logged,
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, // explicit — avoids singleton-Manager dependency
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 draft upload skipped/failed — ${beehiivFailureReason}`);
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, fetchSources, generatePushId };
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/marketing-newsletter-generate"), consumer hooks/ directory, and consumer project root. Supports both function exports and class-based hooks.',
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/marketing-newsletter-generate", "cron/daily/reset-usage", "cron/frequent/marketing-campaigns", or a consumer hook path)' },
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 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 };
@@ -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 (marketing-newsletter-generate, expire-paypal-cancellations,
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
- };