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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.157",
3
+ "version": "5.0.158",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -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, status changes, content edits).
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-monthly-sale',
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-weekly-newsletter',
96
+ id: '_recurring-newsletter',
98
97
  doc: {
99
98
  settings: {
100
- name: 'Weekly Newsletter',
101
- subject: 'This Week\'s Update',
102
- preheader: 'News, tips, and updates',
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
- 'recurrence.pattern': 'weekly',
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
- const { settings, type, recurrence } = data;
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 });
@@ -133,6 +133,10 @@
133
133
  prune: {
134
134
  enabled: true,
135
135
  },
136
+ newsletter: {
137
+ enabled: false,
138
+ categories: [], // e.g., ['social-media', 'marketing'] — content categories to pull from parent server
139
+ },
136
140
  },
137
141
  firebaseConfig: {
138
142
  apiKey: '123-456',