backend-manager 5.0.157 → 5.0.158
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/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/cli/commands/setup-tests/helpers/seed-campaigns.js +12 -14
- package/src/manager/cron/daily/marketing-newsletter-generate.js +118 -0
- package/src/manager/cron/frequent/marketing-campaigns.js +7 -1
- package/src/manager/libraries/email/generators/newsletter.js +184 -0
- package/src/manager/routes/admin/cron/post.js +1 -1
- package/templates/backend-manager-config.json +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
+
# [5.0.158] - 2026-03-17
|
|
18
|
+
### Added
|
|
19
|
+
- Newsletter generator system (`libraries/email/generators/newsletter.js`) — fetches sources from parent server, AI assembles branded content with subject/preheader
|
|
20
|
+
- Daily pre-generation cron (`cron/daily/marketing-newsletter-generate.js`) — generates newsletter content 24 hours before sendAt for calendar review
|
|
21
|
+
- `marketing.newsletter.enabled` and `marketing.newsletter.categories` config options
|
|
22
|
+
- `generator` field on campaign docs — tells cron to run content generation instead of sending directly
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- Seed campaign IDs are now timing-agnostic: `_recurring-sale`, `_recurring-newsletter`
|
|
26
|
+
- Recurrence timing removed from enforced fields — consuming projects can freely change schedule
|
|
27
|
+
- Newsletter subject/preheader are now AI-generated (empty in seed template)
|
|
28
|
+
- Frequent cron skips generator campaigns (handled by daily pre-generation cron)
|
|
29
|
+
- Admin cron route now passes `libraries` to cron handlers
|
|
30
|
+
|
|
17
31
|
# [5.0.157] - 2026-03-17
|
|
18
32
|
### Added
|
|
19
33
|
- Campaign template variables via `powertools.template()` — `{brand.name}`, `{season.name}`, `{holiday.name}`, `{date.month}`, `{date.year}`, `{date.full}`
|
package/package.json
CHANGED
|
@@ -9,7 +9,10 @@ const moment = require('moment');
|
|
|
9
9
|
* - enforced: Fields that MUST match on every setup run (overwritten if changed)
|
|
10
10
|
*
|
|
11
11
|
* Fields NOT in `enforced` are only set on creation and never touched again,
|
|
12
|
-
* allowing runtime changes (sendAt advances,
|
|
12
|
+
* allowing runtime changes (sendAt advances, recurrence timing, content edits).
|
|
13
|
+
*
|
|
14
|
+
* IDs and names are timing-agnostic so consuming projects can change
|
|
15
|
+
* the recurrence pattern without breaking the ID.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
18
|
/**
|
|
@@ -20,7 +23,6 @@ const moment = require('moment');
|
|
|
20
23
|
function nextMonthDay(dayOfMonth, hour) {
|
|
21
24
|
const next = moment.utc().startOf('month').date(dayOfMonth).hour(hour);
|
|
22
25
|
|
|
23
|
-
// If this month's date has passed, go to next month
|
|
24
26
|
if (next.isBefore(moment.utc())) {
|
|
25
27
|
next.add(1, 'month');
|
|
26
28
|
}
|
|
@@ -50,7 +52,7 @@ function buildSeedCampaigns() {
|
|
|
50
52
|
|
|
51
53
|
return [
|
|
52
54
|
{
|
|
53
|
-
id: '_recurring-
|
|
55
|
+
id: '_recurring-sale',
|
|
54
56
|
doc: {
|
|
55
57
|
settings: {
|
|
56
58
|
name: '{holiday.name} Sale',
|
|
@@ -84,9 +86,6 @@ function buildSeedCampaigns() {
|
|
|
84
86
|
},
|
|
85
87
|
enforced: {
|
|
86
88
|
'type': 'email',
|
|
87
|
-
'recurrence.pattern': 'monthly',
|
|
88
|
-
'recurrence.day': 15,
|
|
89
|
-
'recurrence.hour': 14,
|
|
90
89
|
'settings.providers': ['sendgrid'],
|
|
91
90
|
'settings.sender': 'marketing',
|
|
92
91
|
'settings.segments': ['subscription_free', 'subscription_cancelled', 'subscription_churned'],
|
|
@@ -94,19 +93,20 @@ function buildSeedCampaigns() {
|
|
|
94
93
|
},
|
|
95
94
|
},
|
|
96
95
|
{
|
|
97
|
-
id: '_recurring-
|
|
96
|
+
id: '_recurring-newsletter',
|
|
98
97
|
doc: {
|
|
99
98
|
settings: {
|
|
100
|
-
name: '
|
|
101
|
-
subject: '
|
|
102
|
-
preheader: '
|
|
103
|
-
content: '',
|
|
99
|
+
name: '{brand.name} Newsletter — {date.month} {date.year}',
|
|
100
|
+
subject: '', // Generated by AI
|
|
101
|
+
preheader: '', // Generated by AI
|
|
102
|
+
content: '', // Generated at send time by newsletter generator
|
|
104
103
|
sender: 'newsletter',
|
|
105
104
|
providers: ['beehiiv'],
|
|
106
105
|
},
|
|
107
106
|
sendAt: nextWeekday(1, 10),
|
|
108
107
|
status: 'pending',
|
|
109
108
|
type: 'email',
|
|
109
|
+
generator: 'newsletter',
|
|
110
110
|
recurrence: {
|
|
111
111
|
pattern: 'weekly',
|
|
112
112
|
hour: 10,
|
|
@@ -119,9 +119,7 @@ function buildSeedCampaigns() {
|
|
|
119
119
|
},
|
|
120
120
|
enforced: {
|
|
121
121
|
'type': 'email',
|
|
122
|
-
'
|
|
123
|
-
'recurrence.hour': 10,
|
|
124
|
-
'recurrence.day': 1,
|
|
122
|
+
'generator': 'newsletter',
|
|
125
123
|
'settings.providers': ['beehiiv'],
|
|
126
124
|
'settings.sender': 'newsletter',
|
|
127
125
|
},
|
|
@@ -0,0 +1,118 @@
|
|
|
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. Generates content via AI and creates
|
|
6
|
+
* a NEW standalone pending campaign with the real content.
|
|
7
|
+
*
|
|
8
|
+
* The generated campaign appears on the calendar for review.
|
|
9
|
+
* The frequent cron picks it up and sends it when sendAt is due.
|
|
10
|
+
*
|
|
11
|
+
* After generating, advances the recurring doc's sendAt to the next occurrence.
|
|
12
|
+
*
|
|
13
|
+
* Runs on bm_cronDaily.
|
|
14
|
+
*/
|
|
15
|
+
const moment = require('moment');
|
|
16
|
+
const pushid = require('pushid');
|
|
17
|
+
|
|
18
|
+
// Generator modules — keyed by generator field value
|
|
19
|
+
const generators = {
|
|
20
|
+
newsletter: require('../../libraries/email/generators/newsletter.js'),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
module.exports = async ({ Manager, assistant, libraries }) => {
|
|
24
|
+
const { admin } = libraries;
|
|
25
|
+
|
|
26
|
+
const now = Math.round(Date.now() / 1000);
|
|
27
|
+
const oneDayFromNow = now + (24 * 60 * 60);
|
|
28
|
+
|
|
29
|
+
// Find generator campaigns with sendAt within the next 24 hours
|
|
30
|
+
const snapshot = await admin.firestore()
|
|
31
|
+
.collection('marketing-campaigns')
|
|
32
|
+
.where('status', '==', 'pending')
|
|
33
|
+
.where('sendAt', '<=', oneDayFromNow)
|
|
34
|
+
.get();
|
|
35
|
+
|
|
36
|
+
// Filter to only generator campaigns (can't query on field existence in Firestore)
|
|
37
|
+
const generatorDocs = snapshot.docs.filter(doc => doc.data().generator);
|
|
38
|
+
|
|
39
|
+
if (!generatorDocs.length) {
|
|
40
|
+
assistant.log('No generator campaigns due within 24 hours');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
assistant.log(`Pre-generating ${generatorDocs.length} campaign(s)...`);
|
|
45
|
+
|
|
46
|
+
for (const doc of generatorDocs) {
|
|
47
|
+
const data = doc.data();
|
|
48
|
+
const { settings, type, generator, recurrence } = data;
|
|
49
|
+
const campaignId = doc.id;
|
|
50
|
+
|
|
51
|
+
if (!generators[generator]) {
|
|
52
|
+
assistant.log(`Unknown generator "${generator}" on ${campaignId}, skipping`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
assistant.log(`Generating content for ${campaignId} (${generator}): ${settings.name}`);
|
|
57
|
+
|
|
58
|
+
// Run the generator
|
|
59
|
+
const generated = await generators[generator].generate(Manager, assistant, settings);
|
|
60
|
+
|
|
61
|
+
if (!generated) {
|
|
62
|
+
assistant.log(`Generator "${generator}" returned no content for ${campaignId}, skipping`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Create a new standalone campaign with the generated content
|
|
67
|
+
const newId = pushid();
|
|
68
|
+
const nowISO = new Date().toISOString();
|
|
69
|
+
const nowUNIX = Math.round(Date.now() / 1000);
|
|
70
|
+
|
|
71
|
+
await admin.firestore().doc(`marketing-campaigns/${newId}`).set({
|
|
72
|
+
settings: generated,
|
|
73
|
+
type,
|
|
74
|
+
sendAt: data.sendAt,
|
|
75
|
+
status: 'pending',
|
|
76
|
+
generatedFrom: campaignId,
|
|
77
|
+
metadata: {
|
|
78
|
+
created: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
79
|
+
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
assistant.log(`Created campaign ${newId} from generator ${campaignId}: "${generated.subject}"`);
|
|
84
|
+
|
|
85
|
+
// Advance the recurring doc's sendAt to the next occurrence
|
|
86
|
+
if (recurrence) {
|
|
87
|
+
const nextSendAt = getNextOccurrence(data.sendAt, recurrence);
|
|
88
|
+
|
|
89
|
+
await doc.ref.set({
|
|
90
|
+
sendAt: nextSendAt,
|
|
91
|
+
metadata: {
|
|
92
|
+
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
93
|
+
},
|
|
94
|
+
}, { merge: true });
|
|
95
|
+
|
|
96
|
+
assistant.log(`Advanced ${campaignId} sendAt to ${moment.unix(nextSendAt).toISOString()}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
assistant.log('Pre-generation complete');
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Calculate the next occurrence unix timestamp.
|
|
105
|
+
*/
|
|
106
|
+
function getNextOccurrence(currentSendAt, recurrence) {
|
|
107
|
+
const current = moment.unix(currentSendAt);
|
|
108
|
+
const { pattern } = recurrence;
|
|
109
|
+
|
|
110
|
+
switch (pattern) {
|
|
111
|
+
case 'daily': return current.add(1, 'day').unix();
|
|
112
|
+
case 'weekly': return current.add(1, 'week').unix();
|
|
113
|
+
case 'monthly': return current.add(1, 'month').unix();
|
|
114
|
+
case 'quarterly': return current.add(3, 'months').unix();
|
|
115
|
+
case 'yearly': return current.add(1, 'year').unix();
|
|
116
|
+
default: return current.add(1, 'month').unix();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -40,11 +40,17 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
40
40
|
|
|
41
41
|
const results = await Promise.allSettled(snapshot.docs.map(async (doc) => {
|
|
42
42
|
const data = doc.data();
|
|
43
|
-
|
|
43
|
+
let { settings, type, recurrence, generator } = data;
|
|
44
44
|
const campaignId = doc.id;
|
|
45
45
|
|
|
46
46
|
assistant.log(`Processing campaign ${campaignId} (${type}): ${settings.name}`);
|
|
47
47
|
|
|
48
|
+
// --- Generator campaigns are handled by the daily pre-generation cron, not here ---
|
|
49
|
+
if (generator) {
|
|
50
|
+
assistant.log(`Skipping generator campaign ${campaignId} — handled by daily pre-generation cron`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
// --- Dispatch by type ---
|
|
49
55
|
let campaignResults;
|
|
50
56
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Newsletter generator — pulls content from parent server and assembles a branded newsletter.
|
|
3
|
+
*
|
|
4
|
+
* Called by the marketing-campaigns cron when a campaign has `generator: 'newsletter'`.
|
|
5
|
+
* Instead of sending the campaign directly, this generates the content first,
|
|
6
|
+
* then returns the assembled settings for the cron to send.
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. Read newsletter categories from Manager.config.marketing.newsletter.categories
|
|
10
|
+
* 2. Fetch ready sources from parent server (GET /newsletter/sources)
|
|
11
|
+
* 3. AI assembles sources into branded markdown newsletter
|
|
12
|
+
* 4. Mark sources as used on parent server (PUT /newsletter/sources)
|
|
13
|
+
* 5. Return assembled settings with content filled in
|
|
14
|
+
*/
|
|
15
|
+
const fetch = require('wonderful-fetch');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate newsletter content from parent server sources.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} Manager - BEM Manager instance
|
|
21
|
+
* @param {object} assistant - BEM assistant instance
|
|
22
|
+
* @param {object} settings - Campaign settings from the recurring template
|
|
23
|
+
* @returns {object} Updated settings with content filled in, or null if no content available
|
|
24
|
+
*/
|
|
25
|
+
async function generate(Manager, assistant, settings) {
|
|
26
|
+
const config = Manager.config?.marketing?.newsletter;
|
|
27
|
+
|
|
28
|
+
if (!config?.enabled) {
|
|
29
|
+
assistant.log('Newsletter generator: disabled in config');
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const categories = config.categories || [];
|
|
34
|
+
|
|
35
|
+
if (!categories.length) {
|
|
36
|
+
assistant.log('Newsletter generator: no categories configured');
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const parentUrl = Manager.config?.parent?.apiUrl;
|
|
41
|
+
|
|
42
|
+
if (!parentUrl) {
|
|
43
|
+
assistant.log('Newsletter generator: no parent API URL configured');
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Fetch and atomically claim sources from parent server
|
|
48
|
+
const brandId = Manager.config?.brand?.id;
|
|
49
|
+
const sources = await fetchSources(parentUrl, categories, brandId, assistant);
|
|
50
|
+
|
|
51
|
+
if (!sources.length) {
|
|
52
|
+
assistant.log('Newsletter generator: no sources available');
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
assistant.log(`Newsletter generator: ${sources.length} sources found, assembling...`);
|
|
57
|
+
|
|
58
|
+
const brand = Manager.config?.brand;
|
|
59
|
+
|
|
60
|
+
// AI assembles sources into newsletter with subject + preheader + content
|
|
61
|
+
const assembled = await assembleNewsletter(Manager, assistant, sources, brand);
|
|
62
|
+
|
|
63
|
+
if (!assembled) {
|
|
64
|
+
assistant.log('Newsletter generator: AI assembly failed');
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Mark sources as used on parent server
|
|
69
|
+
await claimSources(parentUrl, sources, brand?.id, assistant);
|
|
70
|
+
|
|
71
|
+
// Return updated settings — AI-generated fields override template placeholders
|
|
72
|
+
return {
|
|
73
|
+
...settings,
|
|
74
|
+
subject: assembled.subject,
|
|
75
|
+
preheader: assembled.preheader,
|
|
76
|
+
content: assembled.content,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fetch ready newsletter sources from the parent server.
|
|
82
|
+
*/
|
|
83
|
+
async function fetchSources(parentUrl, categories, brandId, assistant) {
|
|
84
|
+
const allSources = [];
|
|
85
|
+
|
|
86
|
+
for (const category of categories) {
|
|
87
|
+
try {
|
|
88
|
+
const data = await fetch(`${parentUrl}/backend-manager/newsletter/sources`, {
|
|
89
|
+
method: 'get',
|
|
90
|
+
response: 'json',
|
|
91
|
+
timeout: 15000,
|
|
92
|
+
query: {
|
|
93
|
+
category,
|
|
94
|
+
limit: 3,
|
|
95
|
+
claimFor: brandId,
|
|
96
|
+
backendManagerKey: process.env.BACKEND_MANAGER_KEY,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (data.sources?.length) {
|
|
101
|
+
allSources.push(...data.sources);
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
assistant.error(`Newsletter generator: Failed to fetch ${category} sources:`, e.message);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return allSources;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Assemble newsletter sources into a branded newsletter via AI.
|
|
113
|
+
* Returns { subject, preheader, content } or null on failure.
|
|
114
|
+
*/
|
|
115
|
+
async function assembleNewsletter(Manager, assistant, sources, brand) {
|
|
116
|
+
const ai = require('../../openai.js');
|
|
117
|
+
|
|
118
|
+
const sourceSummaries = sources.map((s, i) =>
|
|
119
|
+
`[${i + 1}] ${s.ai?.headline || s.subject}\n${s.ai?.summary || ''}\nTakeaways: ${(s.ai?.takeaways || []).join('; ')}`
|
|
120
|
+
).join('\n\n');
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const result = await ai.request({
|
|
124
|
+
model: 'gpt-4o-mini',
|
|
125
|
+
messages: [
|
|
126
|
+
{
|
|
127
|
+
role: 'system',
|
|
128
|
+
content: `You are a newsletter writer for ${brand?.name || 'a tech company'}. ${brand?.description || ''}
|
|
129
|
+
|
|
130
|
+
Given source articles, write a branded newsletter in markdown. Be concise, engaging, and professional.
|
|
131
|
+
|
|
132
|
+
Respond in JSON:
|
|
133
|
+
{
|
|
134
|
+
"subject": "Catchy email subject line (max 60 chars, no emojis)",
|
|
135
|
+
"preheader": "Preview text that complements the subject (max 100 chars)",
|
|
136
|
+
"content": "Full newsletter body in markdown with ## section headers"
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
Guidelines:
|
|
140
|
+
- Start with a brief intro (1-2 sentences)
|
|
141
|
+
- Each source becomes a section with ## header
|
|
142
|
+
- Rewrite in your own voice — don't copy verbatim
|
|
143
|
+
- End with a short sign-off
|
|
144
|
+
- Keep it scannable — use bold, bullets, short paragraphs`,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
role: 'user',
|
|
148
|
+
content: `Write a newsletter from these ${sources.length} sources:\n\n${sourceSummaries}`,
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
response_format: { type: 'json_object' },
|
|
152
|
+
apiKey: process.env.BACKEND_MANAGER_OPENAI_API_KEY,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return result.content;
|
|
156
|
+
} catch (e) {
|
|
157
|
+
assistant.error('Newsletter AI assembly failed:', e.message);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Mark sources as used on the parent server.
|
|
164
|
+
*/
|
|
165
|
+
async function claimSources(parentUrl, sources, brandId, assistant) {
|
|
166
|
+
for (const source of sources) {
|
|
167
|
+
try {
|
|
168
|
+
await fetch(`${parentUrl}/backend-manager/newsletter/sources`, {
|
|
169
|
+
method: 'put',
|
|
170
|
+
response: 'json',
|
|
171
|
+
timeout: 10000,
|
|
172
|
+
body: {
|
|
173
|
+
id: source.id,
|
|
174
|
+
usedBy: brandId || 'unknown',
|
|
175
|
+
backendManagerKey: process.env.BACKEND_MANAGER_KEY,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
} catch (e) {
|
|
179
|
+
assistant.error(`Newsletter generator: Failed to claim source ${source.id}:`, e.message);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { generate };
|
|
@@ -24,7 +24,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
24
24
|
// Run the cron job
|
|
25
25
|
const cronPath = require('path').resolve(__dirname, `../../../cron/${settings.id}.js`);
|
|
26
26
|
const cronHandler = require(cronPath);
|
|
27
|
-
const result = await cronHandler({ Manager, assistant, context: {} }).catch(e => e);
|
|
27
|
+
const result = await cronHandler({ Manager, assistant, context: {}, libraries: Manager.libraries }).catch(e => e);
|
|
28
28
|
|
|
29
29
|
if (result instanceof Error) {
|
|
30
30
|
return assistant.respond(result.message, { code: 500 });
|