backend-manager 5.11.1 → 5.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -183,7 +183,7 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
183
183
 
184
184
  ### Testing & CLI
185
185
 
186
- - [docs/test-framework.md](docs/test-framework.md) — running, filtering, log files, test types (standalone/suite/group), context object, assertions, auth levels. **NEVER mock — test against the real emulator.** No `mockManager`/`mockAdmin`/fake `firestore`/stubbed `assistant`; every `run()` gets the real `Manager`/`assistant`/`firestore`/`http`/`accounts` — use them. Pure functions (zero I/O) are the only thing you call directly; anything touching Firestore or an external API runs for real. Real external APIs (OpenAI/PayPal/GitHub/SendGrid/Stripe) are gated behind `TEST_EXTENDED_MODE` in-source (not mocked) — opt in with `--extended` or `TEST_EXTENDED_MODE=true` (shared, unprefixed across BEM/BXM/UJM/EM; propagates to BOTH runner + emulator) — and anything an extended test creates externally must be cleaned up by the test. **Each test file `module.exports` a `{ description, type, tests }` object — NOT raw Mocha (`describe`/`it`/`beforeEach`); those globals are not injected and the file fails to load. Split tests one-file-per-concern under `test/<area>/`, never one giant `test/test.js`.** **All cleanup runs at the START of every run, never at the end** — the runner flushes the ENTIRE emulator Firestore before every run, so there's nothing to register; seed any needed fixtures in `test/_init.js`'s `setup()`, and never add a trailing cleanup step. Marketing providers (SendGrid/Beehiiv) don't need a special exception — `_test.*` emails are blocked at the validation layer so test signups never reach providers. The `_test.allow_*` carve-out exists only for the live-provider lifecycle test (`test/marketing/consent-lifecycle.js`), which manages its own teardown.
186
+ - [docs/test-framework.md](docs/test-framework.md) — running, filtering, log files, test types (standalone/suite/group), context object, assertions, auth levels. **NEVER mock — test against the real emulator.** No `mockManager`/`mockAdmin`/fake `firestore`/stubbed `assistant`; every `run()` gets the real `Manager`/`assistant`/`firestore`/`http`/`accounts` — use them. Pure functions (zero I/O) are the only thing you call directly; anything touching Firestore or an external API runs for real. Real external APIs (OpenAI/PayPal/GitHub/SendGrid/Stripe) are gated behind `TEST_EXTENDED_MODE` in-source (not mocked) — opt in with `--extended` or `TEST_EXTENDED_MODE=true` (shared, unprefixed across BEM/BXM/UJM/EM; propagates to BOTH runner + emulator) — and anything an extended test creates externally must be cleaned up by the test. **Each test file `module.exports` a `{ description, type, tests }` object — NOT raw Mocha (`describe`/`it`/`beforeEach`); those globals are not injected and the file fails to load. The only lifecycle hook is `cleanup` — exported `before`/`after` properties are silently IGNORED (do setup inside the tests via an idempotent helper, or in `test/_init.js`). Split tests one-file-per-concern under `test/<area>/`, never one giant `test/test.js`.** **All cleanup runs at the START of every run, never at the end** — the runner flushes the ENTIRE emulator Firestore before every run, so there's nothing to register; seed any needed fixtures in `test/_init.js`'s `setup()`, and never add a trailing cleanup step. Marketing providers (SendGrid/Beehiiv) don't need a special exception — `_test.*` emails are blocked at the validation layer so test signups never reach providers. The `_test.allow_*` carve-out exists only for the live-provider lifecycle test (`test/marketing/consent-lifecycle.js`), which manages its own teardown.
187
187
  - [docs/test-boot-layer.md](docs/test-boot-layer.md) — the `boot/` smoke layer: framework self-test from the repo via the bundled fixture project + `BEM_TEST_BOOT_PROJECT` (BEM's analog of BXM/UJM `*_TEST_BOOT_PROJECT`)
188
188
  - [docs/cli-firestore-auth.md](docs/cli-firestore-auth.md) — `npx mgr firestore:*` and `auth:*` commands, shared flags, examples
189
189
  - [docs/cli-logs.md](docs/cli-logs.md) — `npx mgr logs:read` / `logs:tail` with full flag reference and built-in Cloud Function names
@@ -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
 
@@ -236,6 +236,8 @@ Use `bem:` or `project:` prefix to filter by source. **Mirror the source path so
236
236
  ## Test Types
237
237
 
238
238
  > **The runner reads each file's `module.exports` object — it does NOT inject Mocha/Jest globals.** A test file that calls `describe`/`it`/`before`/`beforeEach`/`after` at top level throws `ReferenceError: beforeEach is not defined` and shows as `Failed to load`. There is no global `assert` either — use the `assert` passed into `run({ assert })`. Every test file MUST export one of the shapes below.
239
+ >
240
+ > **The only lifecycle hook is `cleanup`** (per-test and module-level). Exporting `before`/`after`/`beforeEach` properties on the module does NOT error — the runner silently ignores them, so setup they were supposed to do never happens (this bit `helpers/ai-schema-resolve`, which failed for weeks because its fixtures were written in a `before()` that never ran). Seed disk/DB fixtures inside the tests themselves via an idempotent helper, or in `test/_init.js`'s `setup()`.
239
241
 
240
242
  | Type | Use When | Behavior |
241
243
  |------|----------|----------|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.11.1",
3
+ "version": "5.11.4",
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,79 @@
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
+
43
+ // How long a 'processing' lease is honored before the campaign of a crashed or
44
+ // timed-out run is reclaimed for retry. Must comfortably exceed the function
45
+ // timeout so an in-flight run is never reclaimed out from under itself.
46
+ const PROCESSING_LEASE_SECONDS = 30 * 60;
47
+
48
+ // How many times a generator campaign may yield nothing before its occurrence
49
+ // is skipped (recurring) or the campaign is failed (one-off). At the 10-minute
50
+ // cron cadence this is ~6 hours of retries.
51
+ const GENERATOR_MAX_ATTEMPTS = 36;
26
52
 
27
53
  module.exports = async ({ Manager, assistant, libraries }) => {
28
54
  const { admin } = libraries;
29
55
  const now = Math.round(Date.now() / 1000);
56
+ const collection = admin.firestore().collection('marketing-campaigns');
57
+
58
+ // --- Reclaim stale processing leases (crashed or timed-out runs) ---
59
+ // Single-equality query + in-memory cutoff — no composite index required.
60
+ const processingSnapshot = await collection
61
+ .where('status', '==', 'processing')
62
+ .limit(50)
63
+ .get();
64
+
65
+ for (const doc of processingSnapshot.docs) {
66
+ const startedAt = doc.data().processingStartedAt || 0;
67
+
68
+ if (startedAt > now - PROCESSING_LEASE_SECONDS) {
69
+ continue;
70
+ }
71
+
72
+ await doc.ref.set({
73
+ status: 'pending',
74
+ metadata: { updated: stamp() },
75
+ }, { merge: true });
76
+
77
+ assistant.log(`Reclaimed stale processing lease on ${doc.id} (started ${moment.unix(startedAt).toISOString()})`);
78
+ }
30
79
 
31
- // Query campaigns that are ready to send
32
- const snapshot = await admin.firestore()
33
- .collection('marketing-campaigns')
80
+ // --- Query campaigns that are ready to send ---
81
+ const snapshot = await collection
34
82
  .where('status', '==', 'pending')
35
83
  .where('sendAt', '<=', now)
36
84
  .limit(20)
@@ -47,9 +95,18 @@ module.exports = async ({ Manager, assistant, libraries }) => {
47
95
 
48
96
  const results = await Promise.allSettled(snapshot.docs.map(async (doc) => {
49
97
  const data = doc.data();
50
- let { settings, type, recurrence, generator } = data;
98
+ const { settings, type, recurrence, generator } = data;
51
99
  const campaignId = doc.id;
52
100
 
101
+ // Claim the campaign before doing ANY work. If another (overlapping) run
102
+ // already claimed it, skip — this is what makes double-sends impossible.
103
+ const claimed = await claimCampaign(admin, doc, now);
104
+
105
+ if (!claimed) {
106
+ assistant.log(`Campaign ${campaignId} already claimed by another run, skipping`);
107
+ return;
108
+ }
109
+
53
110
  assistant.log(`Processing campaign ${campaignId} (${type}): ${settings.name}`);
54
111
 
55
112
  // --- Generator campaigns: generate content + send in one shot ---
@@ -59,7 +116,13 @@ module.exports = async ({ Manager, assistant, libraries }) => {
59
116
  };
60
117
 
61
118
  if (!generators[generator]) {
62
- assistant.log(`Unknown generator "${generator}" on ${campaignId}, skipping`);
119
+ await doc.ref.set({
120
+ status: 'failed',
121
+ error: `Unknown generator "${generator}"`,
122
+ metadata: { updated: stamp() },
123
+ }, { merge: true });
124
+
125
+ assistant.log(`Unknown generator "${generator}" on ${campaignId} — marked failed`);
63
126
  return;
64
127
  }
65
128
 
@@ -72,16 +135,55 @@ module.exports = async ({ Manager, assistant, libraries }) => {
72
135
  publishArticle: Manager.isProduction(),
73
136
  });
74
137
 
138
+ // Nothing generated (no sources, filter dropped everything, disabled in
139
+ // test mode, ...). Retry next run — up to the attempts cap.
75
140
  if (!generated) {
76
- assistant.log(`Generator "${generator}" returned no content for ${campaignId}, will retry next run`);
141
+ const attempts = (data.generatorAttempts || 0) + 1;
142
+
143
+ if (attempts >= GENERATOR_MAX_ATTEMPTS) {
144
+ if (recurrence) {
145
+ const nextSendAt = getNextFutureOccurrence(data.sendAt, recurrence, now);
146
+
147
+ await doc.ref.set({
148
+ status: 'pending',
149
+ sendAt: nextSendAt,
150
+ generatorAttempts: admin.firestore.FieldValue.delete(),
151
+ metadata: { updated: stamp() },
152
+ }, { merge: true });
153
+
154
+ assistant.log(`Generator "${generator}" yielded nothing ${attempts}x on ${campaignId} — skipping to next occurrence: ${moment.unix(nextSendAt).toISOString()}`);
155
+ } else {
156
+ await doc.ref.set({
157
+ status: 'failed',
158
+ error: `Generator "${generator}" yielded no content after ${attempts} attempts`,
159
+ metadata: { updated: stamp() },
160
+ }, { merge: true });
161
+
162
+ assistant.log(`Generator "${generator}" yielded nothing ${attempts}x on one-off ${campaignId} — marked failed`);
163
+ }
164
+
165
+ return;
166
+ }
167
+
168
+ await doc.ref.set({
169
+ status: 'pending',
170
+ generatorAttempts: attempts,
171
+ metadata: { updated: stamp() },
172
+ }, { merge: true });
173
+
174
+ assistant.log(`Generator "${generator}" returned no content for ${campaignId}, will retry next run (attempt ${attempts}/${GENERATOR_MAX_ATTEMPTS})`);
77
175
  return;
78
176
  }
79
177
 
178
+ // Strip generation byproducts: bulky debug/asset fields stay OUT of the
179
+ // send payload and the history doc's settings (article carries the full
180
+ // post body; assets/meta are stored as their own fields below).
80
181
  const {
81
182
  images: _images,
82
183
  mjml: _mjml,
83
184
  structure: _structure,
84
185
  contentMarkdown: _contentMarkdown,
186
+ article: _article,
85
187
  assets,
86
188
  meta,
87
189
  ...generatedSettings
@@ -93,9 +195,6 @@ module.exports = async ({ Manager, assistant, libraries }) => {
93
195
  const campaignResults = await email.sendCampaign({ ...generatedSettings, sendAt: 'now' });
94
196
  const success = Object.values(campaignResults).some(r => r.success || r.sent > 0);
95
197
 
96
- const nowISO = new Date().toISOString();
97
- const nowUNIX = Math.round(Date.now() / 1000);
98
-
99
198
  // Store history record
100
199
  const historyId = pushid();
101
200
  await admin.firestore().doc(`marketing-campaigns/${historyId}`).set({
@@ -107,23 +206,30 @@ module.exports = async ({ Manager, assistant, libraries }) => {
107
206
  status: success ? 'sent' : 'failed',
108
207
  results: campaignResults,
109
208
  generatedFrom: campaignId,
110
- metadata: {
111
- created: { timestamp: nowISO, timestampUNIX: nowUNIX },
112
- updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
113
- },
209
+ metadata: { created: stamp(), updated: stamp() },
114
210
  });
115
211
 
116
- // Advance sendAt to next occurrence
117
212
  if (recurrence) {
118
- const nextSendAt = getNextOccurrence(data.sendAt, recurrence);
213
+ // Advance sendAt to the next FUTURE occurrence
214
+ const nextSendAt = getNextFutureOccurrence(data.sendAt, recurrence, now);
215
+
119
216
  await doc.ref.set({
217
+ status: 'pending',
120
218
  sendAt: nextSendAt,
121
- metadata: {
122
- updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
123
- },
219
+ generatorAttempts: admin.firestore.FieldValue.delete(),
220
+ metadata: { updated: stamp() },
124
221
  }, { merge: true });
222
+
125
223
  assistant.log(`${success ? 'Sent' : 'Failed'} generator campaign ${campaignId}, next: ${moment.unix(nextSendAt).toISOString()}`);
126
224
  } else {
225
+ // One-off: finalize so it is never picked up again
226
+ await doc.ref.set({
227
+ status: success ? 'sent' : 'failed',
228
+ results: campaignResults,
229
+ generatorAttempts: admin.firestore.FieldValue.delete(),
230
+ metadata: { updated: stamp() },
231
+ }, { merge: true });
232
+
127
233
  assistant.log(`${success ? 'Sent' : 'Failed'} generator campaign ${campaignId} (one-off)`);
128
234
  }
129
235
 
@@ -150,13 +256,17 @@ module.exports = async ({ Manager, assistant, libraries }) => {
150
256
  }),
151
257
  };
152
258
  } else {
153
- assistant.log(`Unknown campaign type "${type}", skipping ${campaignId}`);
259
+ await doc.ref.set({
260
+ status: 'failed',
261
+ error: `Unknown campaign type "${type}"`,
262
+ metadata: { updated: stamp() },
263
+ }, { merge: true });
264
+
265
+ assistant.log(`Unknown campaign type "${type}" on ${campaignId} — marked failed`);
154
266
  return;
155
267
  }
156
268
 
157
269
  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
270
 
161
271
  // --- Handle recurring vs one-off ---
162
272
  if (recurrence) {
@@ -170,20 +280,16 @@ module.exports = async ({ Manager, assistant, libraries }) => {
170
280
  status: success ? 'sent' : 'failed',
171
281
  results: campaignResults,
172
282
  recurringId: campaignId,
173
- metadata: {
174
- created: { timestamp: nowISO, timestampUNIX: nowUNIX },
175
- updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
176
- },
283
+ metadata: { created: stamp(), updated: stamp() },
177
284
  });
178
285
 
179
- // Advance sendAt to next occurrence
180
- const nextSendAt = getNextOccurrence(data.sendAt, recurrence);
286
+ // Advance sendAt to the next FUTURE occurrence
287
+ const nextSendAt = getNextFutureOccurrence(data.sendAt, recurrence, now);
181
288
 
182
289
  await doc.ref.set({
290
+ status: 'pending',
183
291
  sendAt: nextSendAt,
184
- metadata: {
185
- updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
186
- },
292
+ metadata: { updated: stamp() },
187
293
  }, { merge: true });
188
294
 
189
295
  assistant.log(`Recurring campaign ${campaignId} ${success ? 'sent' : 'failed'}, next: ${moment.unix(nextSendAt).toISOString()}`);
@@ -192,9 +298,7 @@ module.exports = async ({ Manager, assistant, libraries }) => {
192
298
  await doc.ref.set({
193
299
  status: success ? 'sent' : 'failed',
194
300
  results: campaignResults,
195
- metadata: {
196
- updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
197
- },
301
+ metadata: { updated: stamp() },
198
302
  }, { merge: true });
199
303
 
200
304
  assistant.log(`Campaign ${campaignId} ${success ? 'sent' : 'failed'}`);
@@ -206,9 +310,45 @@ module.exports = async ({ Manager, assistant, libraries }) => {
206
310
 
207
311
  for (const r of results) {
208
312
  if (r.status === 'rejected') {
313
+ // The campaign doc stays 'processing' — the stale-lease reclaim retries
314
+ // it after PROCESSING_LEASE_SECONDS instead of every 10 minutes.
209
315
  assistant.error(`Failed to process campaign: ${r.reason?.message}`, r.reason);
210
316
  }
211
317
  }
212
318
 
213
319
  assistant.log(`Completed! (${sent} processed, ${failed} failed)`);
214
320
  };
321
+
322
+ /**
323
+ * Transactionally claim a pending campaign by flipping status to 'processing'.
324
+ * Returns false when another run (or an earlier claim) got there first.
325
+ */
326
+ function claimCampaign(admin, doc, now) {
327
+ return admin.firestore().runTransaction(async (tx) => {
328
+ const fresh = await tx.get(doc.ref);
329
+
330
+ if (!fresh.exists || fresh.data().status !== 'pending') {
331
+ return false;
332
+ }
333
+
334
+ tx.set(doc.ref, {
335
+ status: 'processing',
336
+ processingStartedAt: now,
337
+ metadata: { updated: stamp() },
338
+ }, { merge: true });
339
+
340
+ return true;
341
+ });
342
+ }
343
+
344
+ function stamp() {
345
+ return {
346
+ timestamp: new Date().toISOString(),
347
+ timestampUNIX: Math.round(Date.now() / 1000),
348
+ };
349
+ }
350
+
351
+ // Exposed for tests (test/email/campaign-cron-pipeline.js) — single source of
352
+ // truth for the lease + retry-cap tuning.
353
+ module.exports.PROCESSING_LEASE_SECONDS = PROCESSING_LEASE_SECONDS;
354
+ 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 and headroom for
1131
+ // image buffers. If a run still times out, the campaign lease reclaim in
1132
+ // marketing-campaigns.js retries it safely.
1129
1133
  exporter.bm_cronFrequent =
1130
- fn({memory: '256MB', timeoutSeconds: 60 * 5})
1134
+ fn({memory: '512MB', 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:46:48.765Z"
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:46:55.447 [NamespaceSystem-akka.actor.default-dispatcher-5] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started
6
- 00:46:55.566 [main] INFO com.firebase.server.forge.App$ - Listening at 127.0.0.1:9000
7
- 00:47:05.782 [NamespaceSystem-akka.actor.default-dispatcher-5] INFO com.firebase.core.namespace.NamespaceActor - demo-backend-manager-default-rtdb successfully activated FBKV (SurveyIdle(0)) wait: 82ms, init: 0ms
8
- 00:47:05.814 [NamespaceSystem-blocking-namespace-operation-dispatcher-7] INFO com.firebase.core.namespace.StateManager - Namespace demo-backend-manager-default-rtdb status Active to Active
9
- 00:47:19.548 [Thread-0] INFO com.firebase.server.forge.App$ - Attempting graceful shutdown.
10
- 00:47:19.556 [NamespaceSystem-akka.actor.default-dispatcher-5] INFO com.firebase.core.namespace.Terminator$Terminator - 1 actors left to terminate: demo-backend-manager-default-rtdb
11
- 00:47:19.561 [NamespaceSystem-akka.actor.default-dispatcher-6] INFO com.firebase.core.namespace.NamespaceActor - stopped namespace actor for demo-backend-manager-default-rtdb
12
- 00:47:19.565 [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.