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.
- package/docs/marketing-campaigns.md +25 -7
- package/package.json +1 -1
- package/src/manager/events/cron/daily/blog-auto-publisher.js +9 -0
- package/src/manager/events/cron/frequent/marketing-campaigns.js +180 -37
- package/src/manager/index.js +5 -1
- package/src/manager/libraries/content/source-resolver.js +7 -0
- package/src/manager/libraries/email/constants.js +32 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +3 -4
- package/src/manager/libraries/email/generators/newsletter.js +14 -2
- package/src/test/fixtures/firebase-project/.temp/test-mode.json +1 -1
- package/src/test/fixtures/firebase-project/database-debug.log +8 -8
- package/src/test/fixtures/firebase-project/firestore-debug.log +54 -58
- package/src/test/fixtures/firebase-project/pubsub-debug.log +3 -3
- package/test/email/campaign-cron-pipeline.js +361 -98
- package/test/email/newsletter-generate.js +12 -0
- package/test/events/auth-delete-race.js +11 -2
- package/test/helpers/content/blog-auto-publisher.js +13 -0
|
@@ -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
|
|
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
|
|
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.
|
|
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'`).
|
|
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
|
@@ -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
|
-
* -
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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 =
|
|
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;
|
package/src/manager/index.js
CHANGED
|
@@ -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:
|
|
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.
|
|
242
|
-
//
|
|
243
|
-
//
|
|
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,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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.
|