backend-manager 5.9.27 → 5.10.0

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,7 +231,7 @@ 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 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:
233
235
 
234
236
  ```
235
237
  {brandId}/{campaignId}/
@@ -366,7 +368,6 @@ marketing: {
366
368
  | SendGrid provider | `src/manager/libraries/email/providers/sendgrid.js` |
367
369
  | Beehiiv provider | `src/manager/libraries/email/providers/beehiiv.js` |
368
370
  | 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` |
371
+ | Campaign + newsletter cron | `src/manager/events/cron/frequent/marketing-campaigns.js` |
372
+ | Pruning cron | `src/manager/events/cron/daily/marketing-prune.js` |
372
373
  | 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.0",
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
 
@@ -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
+ };
@@ -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
- };