backend-manager 5.9.27 → 5.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/marketing-campaigns.md +11 -10
- package/package.json +1 -1
- package/src/manager/events/cron/frequent/marketing-campaigns.js +80 -2
- package/src/manager/libraries/email/generators/newsletter.js +17 -21
- package/src/mcp/tools.js +2 -2
- package/test/email/campaign-cron-pipeline.js +272 -0
- package/test/routes/test/usage.js +1 -1
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +0 -160
|
@@ -43,16 +43,18 @@ Recurrence patterns: `daily`, `weekly`, `monthly`, `monthly-weekday`, `quarterly
|
|
|
43
43
|
|
|
44
44
|
The `monthly-weekday` pattern targets the Nth weekday of each month (e.g., 2nd Wednesday). Requires `nth` (1-4) and `day` (0=Sun–6=Sat) in the recurrence object. All other patterns use simple interval addition from the current `sendAt`.
|
|
45
45
|
|
|
46
|
-
All scheduling helpers live in `constants.js` (SSOT): `nextWeekday()`, `nextNthWeekday()`, `nextMonthDay()`, `getNextOccurrence()`.
|
|
46
|
+
All scheduling helpers live in `constants.js` (SSOT): `nextWeekday()`, `nextNthWeekday()`, `nextMonthDay()`, `getNextOccurrence()`. The cron job imports from there — no duplicated logic.
|
|
47
47
|
|
|
48
48
|
## Generator Campaigns
|
|
49
49
|
|
|
50
|
-
Campaigns with a `generator` field
|
|
51
|
-
1.
|
|
50
|
+
Campaigns with a `generator` field (e.g. `generator: 'newsletter'`) are handled by the frequent cron inline — generate content and send in one shot when `sendAt` is due:
|
|
51
|
+
1. Frequent cron finds the generator campaign past its `sendAt`
|
|
52
52
|
2. Runs the generator module (e.g., `generators/newsletter.js`)
|
|
53
|
-
3.
|
|
54
|
-
4.
|
|
55
|
-
5.
|
|
53
|
+
3. Sends the generated content immediately
|
|
54
|
+
4. Stores a history record with generated content + send results
|
|
55
|
+
5. Advances the recurring template's `sendAt` to the next occurrence
|
|
56
|
+
|
|
57
|
+
In production, Beehiiv posts are published (`status: 'confirmed'`). In testing, they're forced to draft (`status: 'draft'`). If Beehiiv upload fails, a fallback alert email is sent to `alerts@{brandDomain}` with all asset links for manual upload.
|
|
56
58
|
|
|
57
59
|
## Email Rendering
|
|
58
60
|
|
|
@@ -229,7 +231,7 @@ AI provider defaults live in code (openai for structure, anthropic for SVG — e
|
|
|
229
231
|
|
|
230
232
|
## Asset hosting (production cron flow)
|
|
231
233
|
|
|
232
|
-
The
|
|
234
|
+
The frequent cron uploads per-section PNGs + the rendered `newsletter.html` + `newsletter.md` + `summary.md` to the public `itw-creative-works/newsletter-assets` repo as two atomic Git Trees commits per issue (PNGs first so URLs exist for embedding, then HTML/MD/summary in a second commit). Folder layout:
|
|
233
235
|
|
|
234
236
|
```
|
|
235
237
|
{brandId}/{campaignId}/
|
|
@@ -366,7 +368,6 @@ marketing: {
|
|
|
366
368
|
| SendGrid provider | `src/manager/libraries/email/providers/sendgrid.js` |
|
|
367
369
|
| Beehiiv provider | `src/manager/libraries/email/providers/beehiiv.js` |
|
|
368
370
|
| Campaign routes | `src/manager/routes/marketing/campaign/{get,post,put,delete}.js` |
|
|
369
|
-
| Campaign cron | `src/manager/cron/frequent/marketing-campaigns.js` |
|
|
370
|
-
|
|
|
371
|
-
| Pruning cron | `src/manager/cron/daily/marketing-prune.js` |
|
|
371
|
+
| Campaign + newsletter cron | `src/manager/events/cron/frequent/marketing-campaigns.js` |
|
|
372
|
+
| Pruning cron | `src/manager/events/cron/daily/marketing-prune.js` |
|
|
372
373
|
| Seed campaigns | `src/cli/commands/setup-tests/helpers/seed-campaigns.js` |
|
package/package.json
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* - email: fires through mailer.sendCampaign()
|
|
7
7
|
* - push: fires through notification.send()
|
|
8
8
|
*
|
|
9
|
+
* Generator campaigns (has `generator` field, e.g. 'newsletter'):
|
|
10
|
+
* - Runs the content generation pipeline (AI content, images, uploads)
|
|
11
|
+
* - Sends the generated content immediately
|
|
12
|
+
* - Stores a history record with the generated content + send results
|
|
13
|
+
* - Advances the recurring template's sendAt to the next occurrence
|
|
14
|
+
*
|
|
9
15
|
* Recurring campaigns (has `recurrence` field):
|
|
10
16
|
* - Creates a history doc in the same collection with results
|
|
11
17
|
* - Advances the recurring doc's sendAt to the next occurrence
|
|
@@ -46,9 +52,81 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
46
52
|
|
|
47
53
|
assistant.log(`Processing campaign ${campaignId} (${type}): ${settings.name}`);
|
|
48
54
|
|
|
49
|
-
// --- Generator campaigns
|
|
55
|
+
// --- Generator campaigns: generate content + send in one shot ---
|
|
50
56
|
if (generator) {
|
|
51
|
-
|
|
57
|
+
const generators = {
|
|
58
|
+
newsletter: require('../../../libraries/email/generators/newsletter.js'),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (!generators[generator]) {
|
|
62
|
+
assistant.log(`Unknown generator "${generator}" on ${campaignId}, skipping`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
assistant.log(`Running generator "${generator}" for ${campaignId}...`);
|
|
67
|
+
|
|
68
|
+
const generatedId = pushid();
|
|
69
|
+
const generated = await generators[generator].generate(Manager, assistant, settings, {
|
|
70
|
+
campaignId: generatedId,
|
|
71
|
+
imageHost: 'github',
|
|
72
|
+
publishArticle: Manager.isProduction(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!generated) {
|
|
76
|
+
assistant.log(`Generator "${generator}" returned no content for ${campaignId}, will retry next run`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const {
|
|
81
|
+
images: _images,
|
|
82
|
+
mjml: _mjml,
|
|
83
|
+
structure: _structure,
|
|
84
|
+
contentMarkdown: _contentMarkdown,
|
|
85
|
+
assets,
|
|
86
|
+
meta,
|
|
87
|
+
...generatedSettings
|
|
88
|
+
} = generated;
|
|
89
|
+
|
|
90
|
+
assistant.log(`Generated content for ${campaignId}: "${generated.subject}"`);
|
|
91
|
+
|
|
92
|
+
// Send immediately
|
|
93
|
+
const campaignResults = await email.sendCampaign({ ...generatedSettings, sendAt: 'now' });
|
|
94
|
+
const success = Object.values(campaignResults).some(r => r.success || r.sent > 0);
|
|
95
|
+
|
|
96
|
+
const nowISO = new Date().toISOString();
|
|
97
|
+
const nowUNIX = Math.round(Date.now() / 1000);
|
|
98
|
+
|
|
99
|
+
// Store history record
|
|
100
|
+
const historyId = pushid();
|
|
101
|
+
await admin.firestore().doc(`marketing-campaigns/${historyId}`).set({
|
|
102
|
+
settings: generatedSettings,
|
|
103
|
+
assets: assets || null,
|
|
104
|
+
meta: meta || null,
|
|
105
|
+
type,
|
|
106
|
+
sendAt: data.sendAt,
|
|
107
|
+
status: success ? 'sent' : 'failed',
|
|
108
|
+
results: campaignResults,
|
|
109
|
+
generatedFrom: campaignId,
|
|
110
|
+
metadata: {
|
|
111
|
+
created: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
112
|
+
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Advance sendAt to next occurrence
|
|
117
|
+
if (recurrence) {
|
|
118
|
+
const nextSendAt = getNextOccurrence(data.sendAt, recurrence);
|
|
119
|
+
await doc.ref.set({
|
|
120
|
+
sendAt: nextSendAt,
|
|
121
|
+
metadata: {
|
|
122
|
+
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
123
|
+
},
|
|
124
|
+
}, { merge: true });
|
|
125
|
+
assistant.log(`${success ? 'Sent' : 'Failed'} generator campaign ${campaignId}, next: ${moment.unix(nextSendAt).toISOString()}`);
|
|
126
|
+
} else {
|
|
127
|
+
assistant.log(`${success ? 'Sent' : 'Failed'} generator campaign ${campaignId} (one-off)`);
|
|
128
|
+
}
|
|
129
|
+
|
|
52
130
|
return;
|
|
53
131
|
}
|
|
54
132
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Newsletter generator — pulls content from parent server and assembles a branded newsletter.
|
|
3
3
|
*
|
|
4
|
-
* Called by the
|
|
4
|
+
* Called by the frequent cron (and the iteration test) when a campaign has
|
|
5
5
|
* `generator: 'newsletter'`. Produces a fully rendered email-safe HTML newsletter ready
|
|
6
6
|
* to ship to Beehiiv / SendGrid.
|
|
7
7
|
*
|
|
@@ -267,14 +267,14 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
267
267
|
|
|
268
268
|
const [, articleResult] = await Promise.all([buildImages(), buildArticle()]);
|
|
269
269
|
|
|
270
|
-
// Inject the "Read the full article" CTA onto the lead section BEFORE render
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
// (derived from the title slug), so the newsletter links correctly either way.
|
|
275
|
-
if (articleResult?.url) {
|
|
270
|
+
// Inject the "Read the full article" CTA onto the lead section BEFORE render,
|
|
271
|
+
// but ONLY if the article was actually published. A CTA pointing to a
|
|
272
|
+
// non-existent post is worse than no CTA at all.
|
|
273
|
+
if (articleResult?.url && articleResult.published) {
|
|
276
274
|
structure.sections[0].cta = { label: 'Read the full article', url: articleResult.url };
|
|
277
|
-
assistant.log(`Newsletter generator: linked article
|
|
275
|
+
assistant.log(`Newsletter generator: linked article published — ${articleResult.url}`);
|
|
276
|
+
} else if (articleResult?.url) {
|
|
277
|
+
assistant.log(`Newsletter generator: linked article generated (not published, CTA omitted) — ${articleResult.url}`);
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
// 3. MJML → HTML
|
|
@@ -337,13 +337,10 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
-
// 3c. Upload to Beehiiv
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
// never thrown — the rest of the pipeline (GH archive, Firestore doc)
|
|
345
|
-
// succeeds regardless. newsletterRoleConfig was already resolved at the top
|
|
346
|
-
// of the function for the initial enabled-check.
|
|
340
|
+
// 3c. Upload to Beehiiv. Published in production, forced to draft in
|
|
341
|
+
// testing so real subscribers never receive test newsletters. Failure
|
|
342
|
+
// is logged, never thrown — the rest of the pipeline (GH archive,
|
|
343
|
+
// Firestore doc) succeeds regardless.
|
|
347
344
|
let beehiivPostId = null;
|
|
348
345
|
let beehiivFailureReason = null;
|
|
349
346
|
|
|
@@ -351,22 +348,21 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
351
348
|
try {
|
|
352
349
|
const beehiivProvider = require('../providers/beehiiv.js');
|
|
353
350
|
const result = await beehiivProvider.createPost({
|
|
354
|
-
publicationId: newsletterRoleConfig.publicationId,
|
|
351
|
+
publicationId: newsletterRoleConfig.publicationId,
|
|
355
352
|
title: structure.subject,
|
|
356
353
|
subject: structure.subject,
|
|
357
354
|
preheader: structure.preheader,
|
|
358
355
|
content: html,
|
|
359
356
|
contentTags: Array.isArray(structure.tags) ? structure.tags : [],
|
|
360
|
-
status: 'draft',
|
|
357
|
+
status: assistant.isTesting() ? 'draft' : 'confirmed',
|
|
361
358
|
});
|
|
362
359
|
|
|
363
360
|
if (result?.success && result.id) {
|
|
364
361
|
beehiivPostId = result.id;
|
|
365
|
-
assistant.log(`Newsletter generator: Beehiiv draft created — ${beehiivPostId}`);
|
|
362
|
+
assistant.log(`Newsletter generator: Beehiiv ${assistant.isTesting() ? 'draft' : 'post'} created — ${beehiivPostId}`);
|
|
366
363
|
} else {
|
|
367
|
-
// Expected today until Enterprise plan — log, do not throw.
|
|
368
364
|
beehiivFailureReason = result?.error || 'unknown error';
|
|
369
|
-
assistant.log(`Newsletter generator: Beehiiv
|
|
365
|
+
assistant.log(`Newsletter generator: Beehiiv upload failed — ${beehiivFailureReason}`);
|
|
370
366
|
}
|
|
371
367
|
} catch (e) {
|
|
372
368
|
beehiivFailureReason = e.message;
|
|
@@ -750,4 +746,4 @@ function generatePushId() {
|
|
|
750
746
|
return id;
|
|
751
747
|
}
|
|
752
748
|
|
|
753
|
-
module.exports = { generate,
|
|
749
|
+
module.exports = { generate, generatePushId };
|
package/src/mcp/tools.js
CHANGED
|
@@ -459,7 +459,7 @@ module.exports = [
|
|
|
459
459
|
// --- Hooks ---
|
|
460
460
|
{
|
|
461
461
|
name: 'run_hook',
|
|
462
|
-
description: 'Execute a hook or BEM cron job by path. Searches: BEM internal crons (e.g. "cron/daily/blog-auto-publisher", "cron/daily/
|
|
462
|
+
description: 'Execute a hook or BEM cron job by path. Searches: BEM internal crons (e.g. "cron/daily/blog-auto-publisher", "cron/daily/reset-usage"), consumer hooks/ directory, and consumer project root. Supports both function exports and class-based hooks.',
|
|
463
463
|
role: 'admin',
|
|
464
464
|
method: 'POST',
|
|
465
465
|
path: 'admin/hook',
|
|
@@ -467,7 +467,7 @@ module.exports = [
|
|
|
467
467
|
inputSchema: {
|
|
468
468
|
type: 'object',
|
|
469
469
|
properties: {
|
|
470
|
-
path: { type: 'string', description: 'Hook path (e.g. "cron/daily/blog-auto-publisher", "cron/daily/
|
|
470
|
+
path: { type: 'string', description: 'Hook path (e.g. "cron/daily/blog-auto-publisher", "cron/daily/reset-usage", "cron/frequent/marketing-campaigns", or a consumer hook path)' },
|
|
471
471
|
},
|
|
472
472
|
required: ['path'],
|
|
473
473
|
},
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Campaign cron pipeline
|
|
3
|
+
*
|
|
4
|
+
* Verifies that bm_cronFrequent correctly picks up and processes campaigns
|
|
5
|
+
* from the marketing-campaigns collection when their sendAt is past due.
|
|
6
|
+
*
|
|
7
|
+
* Covers:
|
|
8
|
+
* 1. One-off campaigns: status changes from 'pending' to 'sent'/'failed'
|
|
9
|
+
* 2. Recurring campaigns: sendAt advances, history doc created
|
|
10
|
+
* 3. Generator campaigns (e.g. newsletter): NOT skipped — the generator
|
|
11
|
+
* pipeline runs inline and produces a new campaign doc
|
|
12
|
+
*
|
|
13
|
+
* Default mode: seeds campaigns and triggers the cron, verifies doc state
|
|
14
|
+
* changes. The generator test verifies the campaign is attempted (not skipped);
|
|
15
|
+
* if the consumer config has newsletter disabled, the generator returns null
|
|
16
|
+
* and the test confirms the graceful "will retry" path.
|
|
17
|
+
*
|
|
18
|
+
* Extended mode: same, but the newsletter generator runs the full AI pipeline.
|
|
19
|
+
*/
|
|
20
|
+
const { getNextOccurrence } = require('../../src/manager/libraries/email/constants.js');
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
description: 'Campaign cron pipeline (frequent cron processes all campaign types)',
|
|
24
|
+
type: 'suite',
|
|
25
|
+
timeout: 60000,
|
|
26
|
+
|
|
27
|
+
tests: [
|
|
28
|
+
{
|
|
29
|
+
name: 'seed-campaigns',
|
|
30
|
+
auth: 'none',
|
|
31
|
+
|
|
32
|
+
async run({ firestore, state }) {
|
|
33
|
+
const now = Math.round(Date.now() / 1000);
|
|
34
|
+
const pastSendAt = now - 600;
|
|
35
|
+
|
|
36
|
+
// One-off email campaign (sendAt 10 min ago)
|
|
37
|
+
await firestore.set('marketing-campaigns/_test-oneoff', {
|
|
38
|
+
status: 'pending',
|
|
39
|
+
type: 'email',
|
|
40
|
+
sendAt: pastSendAt,
|
|
41
|
+
settings: {
|
|
42
|
+
name: '[TEST] One-off blast',
|
|
43
|
+
subject: 'Test subject',
|
|
44
|
+
preheader: 'Test preheader',
|
|
45
|
+
template: 'card',
|
|
46
|
+
data: {
|
|
47
|
+
content: {
|
|
48
|
+
title: 'Test',
|
|
49
|
+
message: 'Test campaign body',
|
|
50
|
+
button: { text: 'Click', url: 'https://example.com' },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
test: true,
|
|
54
|
+
},
|
|
55
|
+
metadata: {
|
|
56
|
+
created: { timestamp: new Date().toISOString(), timestampUNIX: now },
|
|
57
|
+
updated: { timestamp: new Date().toISOString(), timestampUNIX: now },
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Recurring email campaign (sendAt 10 min ago, weekly recurrence)
|
|
62
|
+
await firestore.set('marketing-campaigns/_test-recurring', {
|
|
63
|
+
status: 'pending',
|
|
64
|
+
type: 'email',
|
|
65
|
+
sendAt: pastSendAt,
|
|
66
|
+
recurrence: {
|
|
67
|
+
pattern: 'weekly',
|
|
68
|
+
hour: 10,
|
|
69
|
+
minute: 0,
|
|
70
|
+
day: 1,
|
|
71
|
+
},
|
|
72
|
+
settings: {
|
|
73
|
+
name: '[TEST] Weekly digest',
|
|
74
|
+
subject: 'Weekly digest',
|
|
75
|
+
preheader: 'Your weekly summary',
|
|
76
|
+
template: 'card',
|
|
77
|
+
data: {
|
|
78
|
+
content: {
|
|
79
|
+
title: 'Weekly Digest',
|
|
80
|
+
message: 'Here is your weekly digest.',
|
|
81
|
+
button: { text: 'Read more', url: 'https://example.com' },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
test: true,
|
|
85
|
+
},
|
|
86
|
+
metadata: {
|
|
87
|
+
created: { timestamp: new Date().toISOString(), timestampUNIX: now },
|
|
88
|
+
updated: { timestamp: new Date().toISOString(), timestampUNIX: now },
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Generator campaign (newsletter — sendAt 10 min ago)
|
|
93
|
+
await firestore.set('marketing-campaigns/_test-generator', {
|
|
94
|
+
status: 'pending',
|
|
95
|
+
type: 'email',
|
|
96
|
+
generator: 'newsletter',
|
|
97
|
+
sendAt: pastSendAt,
|
|
98
|
+
recurrence: {
|
|
99
|
+
pattern: 'weekly',
|
|
100
|
+
hour: 17,
|
|
101
|
+
minute: 30,
|
|
102
|
+
day: 2,
|
|
103
|
+
},
|
|
104
|
+
settings: {
|
|
105
|
+
name: '{brand.name} Newsletter — {date.month} {date.year}',
|
|
106
|
+
subject: '',
|
|
107
|
+
preheader: '',
|
|
108
|
+
sender: 'newsletter',
|
|
109
|
+
providers: ['newsletter'],
|
|
110
|
+
},
|
|
111
|
+
metadata: {
|
|
112
|
+
created: { timestamp: new Date().toISOString(), timestampUNIX: now },
|
|
113
|
+
updated: { timestamp: new Date().toISOString(), timestampUNIX: now },
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
state.pastSendAt = pastSendAt;
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
name: 'trigger-frequent-cron',
|
|
123
|
+
auth: 'none',
|
|
124
|
+
timeout: 120000,
|
|
125
|
+
|
|
126
|
+
async run({ pubsub, waitFor, firestore, state }) {
|
|
127
|
+
await pubsub.trigger('bm_cronFrequent');
|
|
128
|
+
|
|
129
|
+
// Wait for BOTH the one-off and recurring campaigns to be processed.
|
|
130
|
+
// The cron processes all campaigns in parallel via Promise.allSettled,
|
|
131
|
+
// but individual Firestore writes may land at different times.
|
|
132
|
+
await waitFor(
|
|
133
|
+
async () => {
|
|
134
|
+
const oneoff = await firestore.get('marketing-campaigns/_test-oneoff');
|
|
135
|
+
const recurring = await firestore.get('marketing-campaigns/_test-recurring');
|
|
136
|
+
return oneoff?.status !== 'pending' && recurring?.sendAt !== state.pastSendAt;
|
|
137
|
+
},
|
|
138
|
+
90000,
|
|
139
|
+
1000,
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
name: 'oneoff-campaign-processed',
|
|
146
|
+
auth: 'none',
|
|
147
|
+
|
|
148
|
+
async run({ firestore, assert }) {
|
|
149
|
+
const doc = await firestore.get('marketing-campaigns/_test-oneoff');
|
|
150
|
+
|
|
151
|
+
assert.ok(doc, 'One-off campaign doc should exist');
|
|
152
|
+
assert.ok(
|
|
153
|
+
doc.status === 'sent' || doc.status === 'failed',
|
|
154
|
+
`One-off campaign status should be sent or failed, got: ${doc.status}`,
|
|
155
|
+
);
|
|
156
|
+
assert.ok(doc.metadata?.updated, 'Should have updated metadata');
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
{
|
|
161
|
+
name: 'recurring-campaign-advanced',
|
|
162
|
+
auth: 'none',
|
|
163
|
+
|
|
164
|
+
async run({ firestore, assert, state }) {
|
|
165
|
+
const doc = await firestore.get('marketing-campaigns/_test-recurring');
|
|
166
|
+
|
|
167
|
+
assert.ok(doc, 'Recurring campaign doc should exist');
|
|
168
|
+
assert.equal(doc.status, 'pending', 'Recurring campaign status stays pending');
|
|
169
|
+
|
|
170
|
+
const expectedNext = getNextOccurrence(state.pastSendAt, {
|
|
171
|
+
pattern: 'weekly',
|
|
172
|
+
hour: 10,
|
|
173
|
+
minute: 0,
|
|
174
|
+
day: 1,
|
|
175
|
+
});
|
|
176
|
+
assert.equal(doc.sendAt, expectedNext, 'sendAt should advance to next occurrence');
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
{
|
|
181
|
+
name: 'recurring-campaign-has-history',
|
|
182
|
+
auth: 'none',
|
|
183
|
+
|
|
184
|
+
async run({ firestore, assert }) {
|
|
185
|
+
const snapshot = await firestore.collection('marketing-campaigns')
|
|
186
|
+
.where('recurringId', '==', '_test-recurring')
|
|
187
|
+
.limit(1)
|
|
188
|
+
.get();
|
|
189
|
+
|
|
190
|
+
assert.ok(!snapshot.empty, 'Should have a history doc from the recurring campaign');
|
|
191
|
+
|
|
192
|
+
const history = snapshot.docs[0].data();
|
|
193
|
+
assert.ok(
|
|
194
|
+
history.status === 'sent' || history.status === 'failed',
|
|
195
|
+
'History doc status should be sent or failed',
|
|
196
|
+
);
|
|
197
|
+
assert.equal(history.recurringId, '_test-recurring', 'Should reference the recurring campaign');
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
{
|
|
202
|
+
name: 'generator-campaign-not-skipped',
|
|
203
|
+
auth: 'none',
|
|
204
|
+
|
|
205
|
+
async run({ firestore, assert, state, config }) {
|
|
206
|
+
const doc = await firestore.get('marketing-campaigns/_test-generator');
|
|
207
|
+
assert.ok(doc, 'Generator campaign doc should still exist');
|
|
208
|
+
|
|
209
|
+
const newsletterEnabled = config.marketing?.newsletter?.enabled;
|
|
210
|
+
|
|
211
|
+
if (newsletterEnabled && process.env.TEST_EXTENDED_MODE) {
|
|
212
|
+
// Extended mode with newsletter enabled: generator should have
|
|
213
|
+
// generated + sent in one shot, created a history doc, and
|
|
214
|
+
// advanced sendAt
|
|
215
|
+
const expectedNext = getNextOccurrence(state.pastSendAt, {
|
|
216
|
+
pattern: 'weekly',
|
|
217
|
+
hour: 17,
|
|
218
|
+
minute: 30,
|
|
219
|
+
day: 2,
|
|
220
|
+
});
|
|
221
|
+
assert.equal(doc.sendAt, expectedNext, 'Generator campaign sendAt should advance');
|
|
222
|
+
|
|
223
|
+
const histSnapshot = await firestore.collection('marketing-campaigns')
|
|
224
|
+
.where('generatedFrom', '==', '_test-generator')
|
|
225
|
+
.limit(1)
|
|
226
|
+
.get();
|
|
227
|
+
assert.ok(!histSnapshot.empty, 'Should have a history doc from the generator');
|
|
228
|
+
const histDoc = histSnapshot.docs[0].data();
|
|
229
|
+
assert.ok(
|
|
230
|
+
histDoc.status === 'sent' || histDoc.status === 'failed',
|
|
231
|
+
'History doc status should be sent or failed',
|
|
232
|
+
);
|
|
233
|
+
assert.ok(!histDoc.generator, 'History doc should NOT have a generator field');
|
|
234
|
+
} else {
|
|
235
|
+
// Non-extended or newsletter disabled: generator returns null,
|
|
236
|
+
// sendAt stays unchanged (will retry next run). The critical
|
|
237
|
+
// assertion is that the campaign was ATTEMPTED, not skipped —
|
|
238
|
+
// verified by the cron logs showing "Running generator" and
|
|
239
|
+
// "returned no content" instead of "Skipping generator campaign".
|
|
240
|
+
assert.equal(doc.status, 'pending', 'Status stays pending when generator returns null');
|
|
241
|
+
assert.equal(doc.sendAt, state.pastSendAt, 'sendAt stays unchanged for retry when generator returns null');
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
{
|
|
247
|
+
name: 'cleanup',
|
|
248
|
+
auth: 'none',
|
|
249
|
+
|
|
250
|
+
async run({ firestore }) {
|
|
251
|
+
await firestore.delete('marketing-campaigns/_test-oneoff');
|
|
252
|
+
await firestore.delete('marketing-campaigns/_test-recurring');
|
|
253
|
+
await firestore.delete('marketing-campaigns/_test-generator');
|
|
254
|
+
|
|
255
|
+
// Clean up history docs (from both recurring and generator campaigns)
|
|
256
|
+
const recurringHist = await firestore.collection('marketing-campaigns')
|
|
257
|
+
.where('recurringId', '==', '_test-recurring')
|
|
258
|
+
.get();
|
|
259
|
+
for (const doc of recurringHist.docs) {
|
|
260
|
+
await doc.ref.delete();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const generatorHist = await firestore.collection('marketing-campaigns')
|
|
264
|
+
.where('generatedFrom', '==', '_test-generator')
|
|
265
|
+
.get();
|
|
266
|
+
for (const doc of generatorHist.docs) {
|
|
267
|
+
await doc.ref.delete();
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
};
|
|
@@ -257,7 +257,7 @@ module.exports = {
|
|
|
257
257
|
|
|
258
258
|
// Wait for cron to reset daily counter.
|
|
259
259
|
// bm_cronDaily executes every registered daily job sequentially. In EXTENDED
|
|
260
|
-
// mode the real-API jobs (
|
|
260
|
+
// mode the real-API jobs (expire-paypal-cancellations,
|
|
261
261
|
// blog-auto-publisher, etc.) can take 40-50s combined before reset-usage
|
|
262
262
|
// (alphabetical tail) gets its turn. 70s gives that the headroom it needs;
|
|
263
263
|
// the per-test `timeout` below matches.
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Newsletter pre-generation cron job
|
|
3
|
-
*
|
|
4
|
-
* Runs daily. Looks for generator campaigns (e.g., _recurring-newsletter)
|
|
5
|
-
* with sendAt within the next 24 hours. For each due campaign it runs the
|
|
6
|
-
* FULL pipeline:
|
|
7
|
-
*
|
|
8
|
-
* 1. Fetch + filter sources from parent server (per brand category set)
|
|
9
|
-
* 2. AI authors structured content (subject, sections/dispatches, signoff, ...)
|
|
10
|
-
* 3. AI authors per-section SVG, rasterized to PNG
|
|
11
|
-
* 4. Upload PNGs to itw-creative-works/newsletter-assets/{brandId}/{newId}/
|
|
12
|
-
* and render MJML → email-safe HTML embedding those URLs
|
|
13
|
-
* 5. Upload the rendered newsletter.html into the same folder so the issue
|
|
14
|
-
* has a browseable, downloadable archive (and a paste-into-Beehiiv URL)
|
|
15
|
-
* 6. Create a NEW pending campaign doc with the generated content + asset URLs
|
|
16
|
-
* 7. Advance the recurring template's sendAt to the next occurrence
|
|
17
|
-
*
|
|
18
|
-
* The generated campaign appears on the calendar for review.
|
|
19
|
-
* The frequent cron picks it up and sends it when sendAt is due.
|
|
20
|
-
*
|
|
21
|
-
* Generated doc shape (marketing-campaigns/{newId}):
|
|
22
|
-
* {
|
|
23
|
-
* settings: { ...generated content, subject, contentHtml, ... },
|
|
24
|
-
* assets: { folderUrl, htmlUrl, imageUrls, campaignId },
|
|
25
|
-
* meta: { telemetry — tokens, cost, durations, source scores },
|
|
26
|
-
* type, sendAt, status: 'pending', generatedFrom, metadata
|
|
27
|
-
* }
|
|
28
|
-
*
|
|
29
|
-
* Runs on bm_cronDaily.
|
|
30
|
-
*/
|
|
31
|
-
const moment = require('moment');
|
|
32
|
-
const pushid = require('pushid');
|
|
33
|
-
const { getNextOccurrence } = require('../../../libraries/email/constants.js');
|
|
34
|
-
|
|
35
|
-
// Generator modules — keyed by generator field value
|
|
36
|
-
const generators = {
|
|
37
|
-
newsletter: require('../../../libraries/email/generators/newsletter.js'),
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
module.exports = async ({ Manager, assistant, libraries }) => {
|
|
41
|
-
const { admin } = libraries;
|
|
42
|
-
|
|
43
|
-
const now = Math.round(Date.now() / 1000);
|
|
44
|
-
const oneDayFromNow = now + (24 * 60 * 60);
|
|
45
|
-
|
|
46
|
-
// Find generator campaigns with sendAt within the next 24 hours
|
|
47
|
-
const snapshot = await admin.firestore()
|
|
48
|
-
.collection('marketing-campaigns')
|
|
49
|
-
.where('status', '==', 'pending')
|
|
50
|
-
.where('sendAt', '<=', oneDayFromNow)
|
|
51
|
-
.get();
|
|
52
|
-
|
|
53
|
-
// Filter to only generator campaigns (can't query on field existence in Firestore)
|
|
54
|
-
const generatorDocs = snapshot.docs.filter(doc => doc.data().generator);
|
|
55
|
-
|
|
56
|
-
if (!generatorDocs.length) {
|
|
57
|
-
assistant.log('No generator campaigns due within 24 hours');
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
assistant.log(`Pre-generating ${generatorDocs.length} campaign(s)...`);
|
|
62
|
-
|
|
63
|
-
for (const doc of generatorDocs) {
|
|
64
|
-
const data = doc.data();
|
|
65
|
-
const { settings, type, generator, recurrence } = data;
|
|
66
|
-
const campaignId = doc.id;
|
|
67
|
-
|
|
68
|
-
if (!generators[generator]) {
|
|
69
|
-
assistant.log(`Unknown generator "${generator}" on ${campaignId}, skipping`);
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
assistant.log(`Generating content for ${campaignId} (${generator}): ${settings.name}`);
|
|
74
|
-
|
|
75
|
-
// Reserve the new doc ID UP FRONT so the generator can use it as the
|
|
76
|
-
// GitHub folder name. The asset URLs (raw.githubusercontent.com/.../{newId}/...)
|
|
77
|
-
// get baked into the rendered HTML, and we want those URLs to match the
|
|
78
|
-
// Firestore doc that hosts the campaign. Stable, predictable, browseable.
|
|
79
|
-
const newId = pushid();
|
|
80
|
-
|
|
81
|
-
// Run the generator with imageHost forced to 'github' (production cron
|
|
82
|
-
// path always uploads — that's what "production" means here) and the
|
|
83
|
-
// campaignId pinned so all assets land in marketing-campaigns/{newId}/'s
|
|
84
|
-
// matching folder. publishArticle: true so the linked blog post (when
|
|
85
|
-
// config.article.enabled is on) is actually committed to the website repo.
|
|
86
|
-
const generated = await generators[generator].generate(Manager, assistant, settings, {
|
|
87
|
-
campaignId: newId,
|
|
88
|
-
imageHost: 'github',
|
|
89
|
-
publishArticle: true,
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
if (!generated) {
|
|
93
|
-
assistant.log(`Generator "${generator}" returned no content for ${campaignId}, skipping`);
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const nowISO = new Date().toISOString();
|
|
98
|
-
const nowUNIX = Math.round(Date.now() / 1000);
|
|
99
|
-
|
|
100
|
-
// Strip non-serializable / oversized fields out of the generator's return
|
|
101
|
-
// before writing to Firestore.
|
|
102
|
-
// images: Buffer[] — not safe to persist
|
|
103
|
-
// mjml: raw template string — pollutes the doc, available in the GH archive
|
|
104
|
-
// structure: full JSON dump (5-10kb) — available via assets.markdownUrl + assets.htmlUrl
|
|
105
|
-
// contentMarkdown: large markdown blob — available via assets.markdownUrl
|
|
106
|
-
const {
|
|
107
|
-
images: _images,
|
|
108
|
-
mjml: _mjml,
|
|
109
|
-
structure: _structure,
|
|
110
|
-
contentMarkdown: _contentMarkdown,
|
|
111
|
-
assets,
|
|
112
|
-
meta,
|
|
113
|
-
...campaignSettings
|
|
114
|
-
} = generated;
|
|
115
|
-
|
|
116
|
-
await admin.firestore().doc(`marketing-campaigns/${newId}`).set({
|
|
117
|
-
settings: campaignSettings,
|
|
118
|
-
assets: assets || null, // { folderUrl, htmlUrl, markdownUrl, summaryUrl, imageUrls, beehiivPostId, tags, campaignId }
|
|
119
|
-
meta: meta || null, // tokens, cost, durations, source scores
|
|
120
|
-
type,
|
|
121
|
-
sendAt: data.sendAt,
|
|
122
|
-
status: 'pending',
|
|
123
|
-
generatedFrom: campaignId,
|
|
124
|
-
metadata: {
|
|
125
|
-
created: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
126
|
-
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
127
|
-
},
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
assistant.log(`Created campaign ${newId} from generator ${campaignId}: "${generated.subject}"`);
|
|
131
|
-
if (assets?.htmlUrl) {
|
|
132
|
-
assistant.log(` HTML: ${assets.htmlUrl}`);
|
|
133
|
-
assistant.log(` Markdown: ${assets.markdownUrl || '(none)'}`);
|
|
134
|
-
assistant.log(` Summary: ${assets.summaryUrl || '(none)'}`);
|
|
135
|
-
assistant.log(` Folder: ${assets.folderUrl}`);
|
|
136
|
-
}
|
|
137
|
-
if (assets?.beehiivPostId) {
|
|
138
|
-
assistant.log(` Beehiiv: draft post ${assets.beehiivPostId}`);
|
|
139
|
-
}
|
|
140
|
-
if (assets?.tags?.length) {
|
|
141
|
-
assistant.log(` Tags: ${assets.tags.join(', ')}`);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Advance the recurring doc's sendAt to the next occurrence
|
|
145
|
-
if (recurrence) {
|
|
146
|
-
const nextSendAt = getNextOccurrence(data.sendAt, recurrence);
|
|
147
|
-
|
|
148
|
-
await doc.ref.set({
|
|
149
|
-
sendAt: nextSendAt,
|
|
150
|
-
metadata: {
|
|
151
|
-
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
152
|
-
},
|
|
153
|
-
}, { merge: true });
|
|
154
|
-
|
|
155
|
-
assistant.log(`Advanced ${campaignId} sendAt to ${moment.unix(nextSendAt).toISOString()}`);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
assistant.log('Pre-generation complete');
|
|
160
|
-
};
|