backend-manager 5.11.2 → 5.11.5

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.
@@ -15,13 +15,16 @@
15
15
  {
16
16
  settings: { name, subject, preheader, content, template, sender, segments, excludeSegments, ... },
17
17
  sendAt: 1743465600, // Unix timestamp (any format accepted, normalized on create)
18
- status: 'pending', // pending | sent | failed
18
+ status: 'pending', // pending | processing | sent | failed
19
19
  type: 'email', // email | push
20
20
  recurrence: { pattern, day, hour, minute?, nth? }, // Optional — makes it recurring
21
21
  generator: 'newsletter', // Optional — runs content generator before sending
22
22
  recurringId: '_recurring-sale', // Present on history docs (links to parent template)
23
23
  generatedFrom: '_recurring-newsletter', // Present on generated docs
24
24
  results: { campaigns: {...}, newsletter: {...} },
25
+ processingStartedAt: 1743465610, // Set while claimed by a cron run (lease)
26
+ generatorAttempts: 2, // Consecutive empty generator runs (retry-cap bookkeeping)
27
+ error: 'Unknown generator "..."', // Set when marked failed without sending
25
28
  metadata: { created: {...}, updated: {...} },
26
29
  }
27
30
  ```
@@ -32,29 +35,44 @@
32
35
  - **Push**: dispatches to FCM via `notification.send()` (shared library)
33
36
  - Content is **markdown** — converted to HTML at send time. Template variables resolved before conversion.
34
37
 
38
+ ## Claim/Lease Lifecycle
39
+
40
+ Every due campaign is **claimed** before any work happens: the cron transactionally flips `status: 'pending'` → `'processing'` (stamping `processingStartedAt`). A campaign another (overlapping) run already claimed is skipped — double-sends are impossible by construction.
41
+
42
+ - Every terminal path writes a final status: `sent`/`failed` for one-offs, back to `pending` (with an advanced `sendAt`) for recurring
43
+ - If a run dies mid-flight (crash, function timeout), the doc stays `processing` until the **stale-lease reclaim** flips it back to `pending` after `PROCESSING_LEASE_SECONDS` (30 min) — a natural retry backoff
44
+ - Unknown campaign `type`s and unknown `generator`s are marked `failed` with an `error` field (a config typo must not retry every 10 minutes forever)
45
+ - `PUT /marketing/campaign` only edits `pending` campaigns, so a mid-flight campaign can't be edited out from under the cron
46
+
35
47
  ## Recurring Campaigns
36
48
 
37
49
  Campaigns with a `recurrence` field repeat automatically:
38
50
  - Cron fires → creates a **history doc** (same collection, `recurringId` set) → advances `sendAt` to next occurrence
39
- - Status stays `pending` on the recurring template, history docs are `sent`/`failed`
51
+ - Status returns to `pending` on the recurring template after each run, history docs are `sent`/`failed`
40
52
  - `_` prefix on IDs groups them at top of Firestore console
41
53
 
42
54
  Recurrence patterns: `daily`, `weekly`, `monthly`, `monthly-weekday`, `quarterly`, `yearly`
43
55
 
44
56
  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
57
 
46
- All scheduling helpers live in `constants.js` (SSOT): `nextWeekday()`, `nextNthWeekday()`, `nextMonthDay()`, `getNextOccurrence()`. The cron job imports from there — no duplicated logic.
58
+ All scheduling helpers live in `constants.js` (SSOT): `nextWeekday()`, `nextNthWeekday()`, `nextMonthDay()`, `getNextOccurrence()`, `getNextFutureOccurrence()`. The cron job imports from there — no duplicated logic.
59
+
60
+ **Catch-up safety:** the cron advances recurring campaigns with `getNextFutureOccurrence()`, which skips ALL missed occurrences and always lands in the future. A weekly campaign stalled for 3 weeks (cron outage, dried-up sources) sends ONE issue and resumes its schedule — never a catch-up burst of back-to-back sends.
47
61
 
48
62
  ## Generator Campaigns
49
63
 
50
64
  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`
65
+ 1. Frequent cron claims the generator campaign past its `sendAt`
52
66
  2. Runs the generator module (e.g., `generators/newsletter.js`)
53
67
  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
68
+ 4. Stores a history record with generated content + send results (bulky byproducts — `article`, `structure`, `mjml`, image buffers — are stripped from the stored settings)
69
+ 5. Recurring: advances the template's `sendAt` to the next FUTURE occurrence and returns it to `pending`. One-off: finalized to `sent`/`failed` so it is never picked up again
70
+
71
+ **Empty-run retry cap:** when the generator yields nothing (no sources, filter dropped everything), the campaign returns to `pending` with `generatorAttempts` incremented and retries next run. After `GENERATOR_MAX_ATTEMPTS` (36 ≈ 6h at the 10-minute cadence) a recurring campaign skips to its next occurrence (counter reset); a one-off is marked `failed`.
72
+
73
+ **Test gating:** `generate()` short-circuits to `null` under `assistant.isTesting()` without `TEST_EXTENDED_MODE` — same convention as the marketing providers and the blog cron. A normal consumer test run never spends AI tokens or touches GitHub/Beehiiv; extended runs are production-equivalent.
56
74
 
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.
75
+ In production, Beehiiv posts are published (`status: 'confirmed'`). In testing, they're forced to draft (`status: 'draft'`). The report email always goes to `alerts@{brandDomain}` with all asset links; its subject adapts to whether Beehiiv upload succeeded.
58
76
 
59
77
  ## Email Rendering
60
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.11.2",
3
+ "version": "5.11.5",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "files": [
@@ -33,6 +33,15 @@ const {
33
33
  let postId;
34
34
 
35
35
  module.exports = async ({ Manager, assistant, context, libraries }) => {
36
+ // External boundary (live feed fetches + AI generation + publishing) — the
37
+ // cron entry gates itself like every other external call. Ungated, a normal
38
+ // test run harvests real feeds from the consumer's blog config, delaying the
39
+ // serial bm_cronDaily sequence past the usage suite's reset-usage deadline.
40
+ if (assistant.isTesting() && !process.env.TEST_EXTENDED_MODE) {
41
+ assistant.log('Blog auto-publisher skipped (test mode without TEST_EXTENDED_MODE)');
42
+ return;
43
+ }
44
+
36
45
  if (!Manager.config.blog?.enabled) {
37
46
  assistant.log('Blog auto-publisher disabled in config');
38
47
  return;
@@ -6,31 +6,82 @@
6
6
  * - email: fires through mailer.sendCampaign()
7
7
  * - push: fires through notification.send()
8
8
  *
9
+ * Claim/lease lifecycle (prevents double-sends across overlapping runs):
10
+ * - Each due campaign is CLAIMED by transactionally flipping status
11
+ * 'pending' → 'processing' before any work happens. A campaign another
12
+ * run already claimed is skipped.
13
+ * - Every terminal path writes a final status: 'sent'/'failed' for
14
+ * one-offs, back to 'pending' (with an advanced sendAt) for recurring.
15
+ * - If a run dies mid-flight (crash, function timeout), the doc stays
16
+ * 'processing' until the stale-lease reclaim flips it back to 'pending'
17
+ * after PROCESSING_LEASE_SECONDS — a natural retry backoff.
18
+ *
9
19
  * Generator campaigns (has `generator` field, e.g. 'newsletter'):
10
20
  * - Runs the content generation pipeline (AI content, images, uploads)
11
21
  * - Sends the generated content immediately
12
22
  * - Stores a history record with the generated content + send results
13
- * - Advances the recurring template's sendAt to the next occurrence
23
+ * - When generation yields nothing, retries next run up to
24
+ * GENERATOR_MAX_ATTEMPTS total, then the occurrence is skipped
25
+ * (recurring) or the campaign is marked failed (one-off)
14
26
  *
15
27
  * Recurring campaigns (has `recurrence` field):
16
28
  * - Creates a history doc in the same collection with results
17
- * - Advances the recurring doc's sendAt to the next occurrence
18
- * - Status stays 'pending' on the recurring doc
29
+ * - Advances the recurring doc's sendAt to the next FUTURE occurrence
30
+ * (missed occurrences are skipped no catch-up bursts)
31
+ * - Status returns to 'pending' on the recurring doc
32
+ *
33
+ * Unknown campaign types / generators are marked 'failed' (they can never
34
+ * succeed — usually a config typo — so retrying forever just burns runs).
19
35
  *
20
36
  * Runs on bm_cronFrequent (every 10 minutes).
21
37
  */
22
38
  const moment = require('moment');
23
39
  const pushid = require('pushid');
24
40
  const notification = require('../../../libraries/notification.js');
25
- const { getNextOccurrence } = require('../../../libraries/email/constants.js');
41
+ const { getNextFutureOccurrence } = require('../../../libraries/email/constants.js');
42
+ // firebase-admin v13 dropped the admin.firestore.FieldValue static — import
43
+ // the modular way (same pattern as admin/send-email.js).
44
+ const { FieldValue } = require('firebase-admin/firestore');
45
+
46
+ // How long a 'processing' lease is honored before the campaign of a crashed or
47
+ // timed-out run is reclaimed for retry. Must comfortably exceed the function
48
+ // timeout so an in-flight run is never reclaimed out from under itself.
49
+ const PROCESSING_LEASE_SECONDS = 30 * 60;
50
+
51
+ // How many times a generator campaign may yield nothing before its occurrence
52
+ // is skipped (recurring) or the campaign is failed (one-off). At the 10-minute
53
+ // cron cadence this is ~6 hours of retries.
54
+ const GENERATOR_MAX_ATTEMPTS = 36;
26
55
 
27
56
  module.exports = async ({ Manager, assistant, libraries }) => {
28
57
  const { admin } = libraries;
29
58
  const now = Math.round(Date.now() / 1000);
59
+ const collection = admin.firestore().collection('marketing-campaigns');
60
+
61
+ // --- Reclaim stale processing leases (crashed or timed-out runs) ---
62
+ // Single-equality query + in-memory cutoff — no composite index required.
63
+ const processingSnapshot = await collection
64
+ .where('status', '==', 'processing')
65
+ .limit(50)
66
+ .get();
67
+
68
+ for (const doc of processingSnapshot.docs) {
69
+ const startedAt = doc.data().processingStartedAt || 0;
70
+
71
+ if (startedAt > now - PROCESSING_LEASE_SECONDS) {
72
+ continue;
73
+ }
74
+
75
+ await doc.ref.set({
76
+ status: 'pending',
77
+ metadata: { updated: stamp() },
78
+ }, { merge: true });
79
+
80
+ assistant.log(`Reclaimed stale processing lease on ${doc.id} (started ${moment.unix(startedAt).toISOString()})`);
81
+ }
30
82
 
31
- // Query campaigns that are ready to send
32
- const snapshot = await admin.firestore()
33
- .collection('marketing-campaigns')
83
+ // --- Query campaigns that are ready to send ---
84
+ const snapshot = await collection
34
85
  .where('status', '==', 'pending')
35
86
  .where('sendAt', '<=', now)
36
87
  .limit(20)
@@ -47,9 +98,18 @@ module.exports = async ({ Manager, assistant, libraries }) => {
47
98
 
48
99
  const results = await Promise.allSettled(snapshot.docs.map(async (doc) => {
49
100
  const data = doc.data();
50
- let { settings, type, recurrence, generator } = data;
101
+ const { settings, type, recurrence, generator } = data;
51
102
  const campaignId = doc.id;
52
103
 
104
+ // Claim the campaign before doing ANY work. If another (overlapping) run
105
+ // already claimed it, skip — this is what makes double-sends impossible.
106
+ const claimed = await claimCampaign(admin, doc, now);
107
+
108
+ if (!claimed) {
109
+ assistant.log(`Campaign ${campaignId} already claimed by another run, skipping`);
110
+ return;
111
+ }
112
+
53
113
  assistant.log(`Processing campaign ${campaignId} (${type}): ${settings.name}`);
54
114
 
55
115
  // --- Generator campaigns: generate content + send in one shot ---
@@ -59,7 +119,13 @@ module.exports = async ({ Manager, assistant, libraries }) => {
59
119
  };
60
120
 
61
121
  if (!generators[generator]) {
62
- assistant.log(`Unknown generator "${generator}" on ${campaignId}, skipping`);
122
+ await doc.ref.set({
123
+ status: 'failed',
124
+ error: `Unknown generator "${generator}"`,
125
+ metadata: { updated: stamp() },
126
+ }, { merge: true });
127
+
128
+ assistant.log(`Unknown generator "${generator}" on ${campaignId} — marked failed`);
63
129
  return;
64
130
  }
65
131
 
@@ -72,16 +138,55 @@ module.exports = async ({ Manager, assistant, libraries }) => {
72
138
  publishArticle: Manager.isProduction(),
73
139
  });
74
140
 
141
+ // Nothing generated (no sources, filter dropped everything, disabled in
142
+ // test mode, ...). Retry next run — up to the attempts cap.
75
143
  if (!generated) {
76
- assistant.log(`Generator "${generator}" returned no content for ${campaignId}, will retry next run`);
144
+ const attempts = (data.generatorAttempts || 0) + 1;
145
+
146
+ if (attempts >= GENERATOR_MAX_ATTEMPTS) {
147
+ if (recurrence) {
148
+ const nextSendAt = getNextFutureOccurrence(data.sendAt, recurrence, now);
149
+
150
+ await doc.ref.set({
151
+ status: 'pending',
152
+ sendAt: nextSendAt,
153
+ generatorAttempts: FieldValue.delete(),
154
+ metadata: { updated: stamp() },
155
+ }, { merge: true });
156
+
157
+ assistant.log(`Generator "${generator}" yielded nothing ${attempts}x on ${campaignId} — skipping to next occurrence: ${moment.unix(nextSendAt).toISOString()}`);
158
+ } else {
159
+ await doc.ref.set({
160
+ status: 'failed',
161
+ error: `Generator "${generator}" yielded no content after ${attempts} attempts`,
162
+ metadata: { updated: stamp() },
163
+ }, { merge: true });
164
+
165
+ assistant.log(`Generator "${generator}" yielded nothing ${attempts}x on one-off ${campaignId} — marked failed`);
166
+ }
167
+
168
+ return;
169
+ }
170
+
171
+ await doc.ref.set({
172
+ status: 'pending',
173
+ generatorAttempts: attempts,
174
+ metadata: { updated: stamp() },
175
+ }, { merge: true });
176
+
177
+ assistant.log(`Generator "${generator}" returned no content for ${campaignId}, will retry next run (attempt ${attempts}/${GENERATOR_MAX_ATTEMPTS})`);
77
178
  return;
78
179
  }
79
180
 
181
+ // Strip generation byproducts: bulky debug/asset fields stay OUT of the
182
+ // send payload and the history doc's settings (article carries the full
183
+ // post body; assets/meta are stored as their own fields below).
80
184
  const {
81
185
  images: _images,
82
186
  mjml: _mjml,
83
187
  structure: _structure,
84
188
  contentMarkdown: _contentMarkdown,
189
+ article: _article,
85
190
  assets,
86
191
  meta,
87
192
  ...generatedSettings
@@ -93,9 +198,6 @@ module.exports = async ({ Manager, assistant, libraries }) => {
93
198
  const campaignResults = await email.sendCampaign({ ...generatedSettings, sendAt: 'now' });
94
199
  const success = Object.values(campaignResults).some(r => r.success || r.sent > 0);
95
200
 
96
- const nowISO = new Date().toISOString();
97
- const nowUNIX = Math.round(Date.now() / 1000);
98
-
99
201
  // Store history record
100
202
  const historyId = pushid();
101
203
  await admin.firestore().doc(`marketing-campaigns/${historyId}`).set({
@@ -107,23 +209,30 @@ module.exports = async ({ Manager, assistant, libraries }) => {
107
209
  status: success ? 'sent' : 'failed',
108
210
  results: campaignResults,
109
211
  generatedFrom: campaignId,
110
- metadata: {
111
- created: { timestamp: nowISO, timestampUNIX: nowUNIX },
112
- updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
113
- },
212
+ metadata: { created: stamp(), updated: stamp() },
114
213
  });
115
214
 
116
- // Advance sendAt to next occurrence
117
215
  if (recurrence) {
118
- const nextSendAt = getNextOccurrence(data.sendAt, recurrence);
216
+ // Advance sendAt to the next FUTURE occurrence
217
+ const nextSendAt = getNextFutureOccurrence(data.sendAt, recurrence, now);
218
+
119
219
  await doc.ref.set({
220
+ status: 'pending',
120
221
  sendAt: nextSendAt,
121
- metadata: {
122
- updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
123
- },
222
+ generatorAttempts: FieldValue.delete(),
223
+ metadata: { updated: stamp() },
124
224
  }, { merge: true });
225
+
125
226
  assistant.log(`${success ? 'Sent' : 'Failed'} generator campaign ${campaignId}, next: ${moment.unix(nextSendAt).toISOString()}`);
126
227
  } else {
228
+ // One-off: finalize so it is never picked up again
229
+ await doc.ref.set({
230
+ status: success ? 'sent' : 'failed',
231
+ results: campaignResults,
232
+ generatorAttempts: FieldValue.delete(),
233
+ metadata: { updated: stamp() },
234
+ }, { merge: true });
235
+
127
236
  assistant.log(`${success ? 'Sent' : 'Failed'} generator campaign ${campaignId} (one-off)`);
128
237
  }
129
238
 
@@ -150,13 +259,17 @@ module.exports = async ({ Manager, assistant, libraries }) => {
150
259
  }),
151
260
  };
152
261
  } else {
153
- assistant.log(`Unknown campaign type "${type}", skipping ${campaignId}`);
262
+ await doc.ref.set({
263
+ status: 'failed',
264
+ error: `Unknown campaign type "${type}"`,
265
+ metadata: { updated: stamp() },
266
+ }, { merge: true });
267
+
268
+ assistant.log(`Unknown campaign type "${type}" on ${campaignId} — marked failed`);
154
269
  return;
155
270
  }
156
271
 
157
272
  const success = Object.values(campaignResults).some(r => r.success || r.sent > 0);
158
- const nowISO = new Date().toISOString();
159
- const nowUNIX = Math.round(Date.now() / 1000);
160
273
 
161
274
  // --- Handle recurring vs one-off ---
162
275
  if (recurrence) {
@@ -170,20 +283,16 @@ module.exports = async ({ Manager, assistant, libraries }) => {
170
283
  status: success ? 'sent' : 'failed',
171
284
  results: campaignResults,
172
285
  recurringId: campaignId,
173
- metadata: {
174
- created: { timestamp: nowISO, timestampUNIX: nowUNIX },
175
- updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
176
- },
286
+ metadata: { created: stamp(), updated: stamp() },
177
287
  });
178
288
 
179
- // Advance sendAt to next occurrence
180
- const nextSendAt = getNextOccurrence(data.sendAt, recurrence);
289
+ // Advance sendAt to the next FUTURE occurrence
290
+ const nextSendAt = getNextFutureOccurrence(data.sendAt, recurrence, now);
181
291
 
182
292
  await doc.ref.set({
293
+ status: 'pending',
183
294
  sendAt: nextSendAt,
184
- metadata: {
185
- updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
186
- },
295
+ metadata: { updated: stamp() },
187
296
  }, { merge: true });
188
297
 
189
298
  assistant.log(`Recurring campaign ${campaignId} ${success ? 'sent' : 'failed'}, next: ${moment.unix(nextSendAt).toISOString()}`);
@@ -192,9 +301,7 @@ module.exports = async ({ Manager, assistant, libraries }) => {
192
301
  await doc.ref.set({
193
302
  status: success ? 'sent' : 'failed',
194
303
  results: campaignResults,
195
- metadata: {
196
- updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
197
- },
304
+ metadata: { updated: stamp() },
198
305
  }, { merge: true });
199
306
 
200
307
  assistant.log(`Campaign ${campaignId} ${success ? 'sent' : 'failed'}`);
@@ -206,9 +313,45 @@ module.exports = async ({ Manager, assistant, libraries }) => {
206
313
 
207
314
  for (const r of results) {
208
315
  if (r.status === 'rejected') {
316
+ // The campaign doc stays 'processing' — the stale-lease reclaim retries
317
+ // it after PROCESSING_LEASE_SECONDS instead of every 10 minutes.
209
318
  assistant.error(`Failed to process campaign: ${r.reason?.message}`, r.reason);
210
319
  }
211
320
  }
212
321
 
213
322
  assistant.log(`Completed! (${sent} processed, ${failed} failed)`);
214
323
  };
324
+
325
+ /**
326
+ * Transactionally claim a pending campaign by flipping status to 'processing'.
327
+ * Returns false when another run (or an earlier claim) got there first.
328
+ */
329
+ function claimCampaign(admin, doc, now) {
330
+ return admin.firestore().runTransaction(async (tx) => {
331
+ const fresh = await tx.get(doc.ref);
332
+
333
+ if (!fresh.exists || fresh.data().status !== 'pending') {
334
+ return false;
335
+ }
336
+
337
+ tx.set(doc.ref, {
338
+ status: 'processing',
339
+ processingStartedAt: now,
340
+ metadata: { updated: stamp() },
341
+ }, { merge: true });
342
+
343
+ return true;
344
+ });
345
+ }
346
+
347
+ function stamp() {
348
+ return {
349
+ timestamp: new Date().toISOString(),
350
+ timestampUNIX: Math.round(Date.now() / 1000),
351
+ };
352
+ }
353
+
354
+ // Exposed for tests (test/email/campaign-cron-pipeline.js) — single source of
355
+ // truth for the lease + retry-cap tuning.
356
+ module.exports.PROCESSING_LEASE_SECONDS = PROCESSING_LEASE_SECONDS;
357
+ module.exports.GENERATOR_MAX_ATTEMPTS = GENERATOR_MAX_ATTEMPTS;
@@ -1126,8 +1126,12 @@ Manager.prototype.setupFunctions = function (exporter, options) {
1126
1126
  .pubsub.schedule('0 0 * * *')
1127
1127
  .onRun((context) => self.EventMiddleware({ context }).run(`${cron}/daily.js`));
1128
1128
 
1129
+ // Frequent cron runs the inline newsletter generator (AI structure + section
1130
+ // images + article + uploads) — needs the v1 max timeout. If a run times out
1131
+ // or OOMs, the campaign lease reclaim in marketing-campaigns.js retries it
1132
+ // safely.
1129
1133
  exporter.bm_cronFrequent =
1130
- fn({memory: '256MB', timeoutSeconds: 60 * 5})
1134
+ fn({memory: '256MB', timeoutSeconds: 540})
1131
1135
  .pubsub.schedule('*/10 * * * *')
1132
1136
  .onRun((context) => self.EventMiddleware({ context }).run(`${cron}/frequent.js`));
1133
1137
  };
@@ -526,7 +526,14 @@ function getURLContent(url) {
526
526
  });
527
527
  }
528
528
 
529
+ // http(s) only — a bare `new URL()` check misclassifies colon-prefixed text
530
+ // seeds ("AI: the future of work" parses with scheme "ai:") as URLs, which
531
+ // then fail to fetch and silently lose the seed.
529
532
  function isURL(url) {
533
+ if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
534
+ return false;
535
+ }
536
+
530
537
  try {
531
538
  return !!new URL(url);
532
539
  } catch (e) {
@@ -193,6 +193,37 @@ function getNextOccurrence(currentSendAt, recurrence) {
193
193
  }
194
194
  }
195
195
 
196
+ /**
197
+ * Like getNextOccurrence, but guarantees the returned time is in the FUTURE.
198
+ *
199
+ * A recurring campaign that stalled for several periods (cron outage, sources
200
+ * dried up, repeated failures) advances past ALL missed occurrences instead of
201
+ * firing a catch-up burst — one send per cron tick until sendAt catches up.
202
+ *
203
+ * @param {number} currentSendAt - Current fire time (unix)
204
+ * @param {object} recurrence - { pattern, hour, day, nth?, minute? }
205
+ * @param {number} [now] - Unix time to advance past (defaults to Date.now())
206
+ * @returns {number} Next fire time strictly after `now`
207
+ */
208
+ function getNextFutureOccurrence(currentSendAt, recurrence, now) {
209
+ now = now || Math.round(Date.now() / 1000);
210
+
211
+ let next = getNextOccurrence(currentSendAt, recurrence);
212
+
213
+ while (next <= now) {
214
+ const after = getNextOccurrence(next, recurrence);
215
+
216
+ // Safety: a recurrence must strictly advance, otherwise bail out
217
+ if (after <= next) {
218
+ break;
219
+ }
220
+
221
+ next = after;
222
+ }
223
+
224
+ return next;
225
+ }
226
+
196
227
  /**
197
228
  * Convert SVG image URLs to PNG equivalents — email clients don't render SVGs.
198
229
  * CDN naming convention: `-x.svg` -> `-1024.png`
@@ -401,4 +432,5 @@ module.exports = {
401
432
  nextNthWeekday,
402
433
  nextMonthDay,
403
434
  getNextOccurrence,
435
+ getNextFutureOccurrence,
404
436
  };
@@ -238,10 +238,9 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
238
238
  });
239
239
  }
240
240
 
241
- // 4. Commit + push with retry. The newsletter pipeline calls uploadAssets
242
- // twice sequentially (images then HTML). GitHub's API can return a stale
243
- // ref on the second getRef if the first updateRef hasn't fully propagated.
244
- // Retry with a fresh ref read + jitter handles this.
241
+ // 4. Commit + push with retry. Per-brand branches make ref races effectively
242
+ // impossible (one writer per branch), but keep a defensive retry in case
243
+ // the same brand ever uploads concurrently or GitHub returns a stale ref.
245
244
  const defaultSubject = subject ? subject.trim() : `${files.length} newsletter asset${files.length === 1 ? '' : 's'}`;
246
245
  const message = commitMessage || `[${brandId}] ${campaignId} — ${defaultSubject}`;
247
246
  const MAX_RETRIES = 3;
@@ -69,6 +69,15 @@ const { trackContentSource, contentSourceHash, resolveSources } = require('../..
69
69
  * @returns {object|null} Updated settings with content filled in, or null if unavailable
70
70
  */
71
71
  async function generate(Manager, assistant, settings, opts = {}) {
72
+ // Same convention as the marketing providers (email/marketing/index.js):
73
+ // default test runs never hit the real pipeline (AI calls + GitHub commit +
74
+ // Beehiiv post + report email). TEST_EXTENDED_MODE opts into
75
+ // production-equivalent side effects.
76
+ if (assistant.isTesting() && !process.env.TEST_EXTENDED_MODE) {
77
+ assistant.log('Newsletter generator: skipped in test mode (set TEST_EXTENDED_MODE to run the real pipeline)');
78
+ return null;
79
+ }
80
+
72
81
  const newsletterRoleConfig = Manager.config?.marketing?.newsletter;
73
82
  const rawContent = newsletterRoleConfig?.content;
74
83
 
@@ -270,6 +279,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
270
279
  brand,
271
280
  config,
272
281
  structure,
282
+ sources: filteredSources,
273
283
  publish: !!opts.publishArticle,
274
284
  }).catch((e) => {
275
285
  assistant.error(`Newsletter generator: linked article failed — ${e.message}`);
@@ -523,10 +533,12 @@ async function generate(Manager, assistant, settings, opts = {}) {
523
533
  * @param {object} args.brand - { id, name, url, ... }
524
534
  * @param {object} args.config - marketing.newsletter.content (tone, instructions, article.author)
525
535
  * @param {object} args.structure - newsletter structure (sections[0] is the lead)
536
+ * @param {object[]} [args.sources] - The brand-fit sources that fed the issue. Their URLs are
537
+ * passed to admin/post as the article's `source` attribution.
526
538
  * @param {boolean} [args.publish] - Commit the post to GitHub via admin/post. Default false.
527
539
  * @returns {Promise<{url, slug, path, published}|null>}
528
540
  */
529
- async function buildLinkedArticle({ Manager, assistant, brand, config, structure, publish }) {
541
+ async function buildLinkedArticle({ Manager, assistant, brand, config, structure, sources, publish }) {
530
542
  const lead = structure.sections[0] || {};
531
543
  const publicConfig = buildPublicConfig(Manager.config);
532
544
 
@@ -566,7 +578,7 @@ async function buildLinkedArticle({ Manager, assistant, brand, config, structure
566
578
  }
567
579
 
568
580
  try {
569
- const sourceUrls = sources.map(s => s.url || s.id).filter(Boolean);
581
+ const sourceUrls = (sources || []).map(s => s.url || s.id).filter(Boolean);
570
582
  const result = await publishArticle(assistant, {
571
583
  brand: publicConfig,
572
584
  article,
@@ -2,5 +2,5 @@
2
2
  "env": {
3
3
  "TEST_EXTENDED_MODE": ""
4
4
  },
5
- "updatedAt": "2026-07-02T07:57:05.070Z"
5
+ "updatedAt": "2026-07-02T08:47:59.846Z"
6
6
  }
@@ -2,11 +2,11 @@ WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
2
2
  WARNING: sun.misc.Unsafe::objectFieldOffset has been called by akka.util.Unsafe (file:/Users/ian/.cache/firebase/emulators/firebase-database-emulator-v4.11.2.jar)
3
3
  WARNING: Please consider reporting this to the maintainers of class akka.util.Unsafe
4
4
  WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release
5
- 00:57:11.886 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started
6
- 00:57:11.979 [main] INFO com.firebase.server.forge.App$ - Listening at 127.0.0.1:9000
7
- 00:57:21.840 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - demo-backend-manager-default-rtdb successfully activated FBKV (SurveyIdle(0)) wait: 71ms, init: 0ms
8
- 00:57:21.874 [NamespaceSystem-blocking-namespace-operation-dispatcher-6] INFO com.firebase.core.namespace.StateManager - Namespace demo-backend-manager-default-rtdb status Active to Active
9
- 00:57:32.880 [Thread-0] INFO com.firebase.server.forge.App$ - Attempting graceful shutdown.
10
- 00:57:32.901 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.Terminator$Terminator - 1 actors left to terminate: demo-backend-manager-default-rtdb
11
- 00:57:32.922 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - stopped namespace actor for demo-backend-manager-default-rtdb
12
- 00:57:32.926 [Thread-0] INFO com.firebase.server.forge.App$ - Graceful shutdown complete.
5
+ 01:48:07.812 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started
6
+ 01:48:07.913 [main] INFO com.firebase.server.forge.App$ - Listening at 127.0.0.1:9000
7
+ 01:48:18.289 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - demo-backend-manager-default-rtdb successfully activated FBKV (SurveyIdle(0)) wait: 73ms, init: 0ms
8
+ 01:48:18.324 [NamespaceSystem-blocking-namespace-operation-dispatcher-6] INFO com.firebase.core.namespace.StateManager - Namespace demo-backend-manager-default-rtdb status Active to Active
9
+ 01:48:29.496 [Thread-0] INFO com.firebase.server.forge.App$ - Attempting graceful shutdown.
10
+ 01:48:29.562 [NamespaceSystem-akka.actor.default-dispatcher-5] INFO com.firebase.core.namespace.Terminator$Terminator - 1 actors left to terminate: demo-backend-manager-default-rtdb
11
+ 01:48:29.581 [NamespaceSystem-akka.actor.default-dispatcher-7] INFO com.firebase.core.namespace.NamespaceActor - stopped namespace actor for demo-backend-manager-default-rtdb
12
+ 01:48:29.604 [Thread-0] INFO com.firebase.server.forge.App$ - Graceful shutdown complete.