backend-manager 5.0.154 → 5.0.157

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CLAUDE.md +2 -2
  3. package/package.json +1 -1
  4. package/src/cli/commands/setup-tests/firestore-indexes-required.js +1 -1
  5. package/src/cli/commands/setup-tests/functions-package.js +3 -1
  6. package/src/cli/commands/setup-tests/{required-indexes.js → helpers/required-indexes.js} +22 -0
  7. package/src/cli/commands/setup-tests/helpers/seed-campaigns.js +132 -0
  8. package/src/cli/commands/setup-tests/index.js +2 -0
  9. package/src/cli/commands/setup-tests/marketing-campaigns-seeded.js +109 -0
  10. package/src/manager/cron/daily/marketing-prune.js +140 -0
  11. package/src/manager/cron/frequent/marketing-campaigns.js +158 -0
  12. package/src/manager/events/auth/on-create.js +36 -1
  13. package/src/manager/libraries/email/constants.js +46 -2
  14. package/src/manager/libraries/email/index.js +13 -3
  15. package/src/manager/libraries/email/marketing/index.js +205 -33
  16. package/src/manager/libraries/email/providers/beehiiv.js +90 -0
  17. package/src/manager/libraries/email/providers/sendgrid.js +179 -1
  18. package/src/manager/libraries/email/transactional/index.js +17 -0
  19. package/src/manager/libraries/email/utm.js +116 -0
  20. package/src/manager/libraries/notification.js +223 -0
  21. package/src/manager/routes/admin/notification/post.js +16 -241
  22. package/src/manager/routes/marketing/campaign/delete.js +45 -0
  23. package/src/manager/routes/marketing/campaign/get.js +69 -0
  24. package/src/manager/routes/marketing/campaign/post.js +161 -0
  25. package/src/manager/routes/marketing/campaign/put.js +122 -0
  26. package/src/manager/routes/user/data-request/delete.js +3 -1
  27. package/src/manager/routes/user/data-request/get.js +3 -1
  28. package/src/manager/routes/user/data-request/post.js +3 -1
  29. package/src/manager/routes/user/delete.js +4 -3
  30. package/src/manager/routes/user/signup/post.js +10 -8
  31. package/src/manager/schemas/marketing/campaign/delete.js +6 -0
  32. package/src/manager/schemas/marketing/campaign/get.js +11 -0
  33. package/src/manager/schemas/marketing/campaign/post.js +35 -0
  34. package/src/manager/schemas/marketing/campaign/put.js +35 -0
  35. package/templates/backend-manager-config.json +3 -0
@@ -14,7 +14,10 @@ const RETRY_DELAY_MS = 1000;
14
14
  * - Batch writes user doc + increment count atomically
15
15
  * - Retries up to 3 times with exponential backoff on failure
16
16
  *
17
- * Non-critical work (name inference, welcome emails, marketing contact) is handled
17
+ * If the user signed up via a provider (Google, Facebook, etc.), their display name
18
+ * is extracted and stored as personal.name.first/last on the user doc.
19
+ *
20
+ * Non-critical work (welcome emails, marketing contact) is handled
18
21
  * by the user/signup endpoint, which the frontend calls after account creation.
19
22
  */
20
23
  module.exports = async ({ Manager, assistant, user, context, libraries }) => {
@@ -42,12 +45,20 @@ module.exports = async ({ Manager, assistant, user, context, libraries }) => {
42
45
  return;
43
46
  }
44
47
 
48
+ // Extract name from provider data (e.g., Google, Facebook, GitHub)
49
+ const providerName = extractProviderName(user);
50
+
51
+ assistant.log(`onCreate: Inferred name from provider:`, providerName);
52
+
45
53
  // Create user record using Manager.User() helper
46
54
  const userRecord = Manager.User({
47
55
  auth: {
48
56
  uid: user.uid,
49
57
  email: user.email,
50
58
  },
59
+ personal: providerName ? {
60
+ name: providerName,
61
+ } : undefined,
51
62
  }).properties;
52
63
 
53
64
  // Add metadata tag (merge into existing metadata to preserve metadata.created from User schema)
@@ -81,6 +92,30 @@ module.exports = async ({ Manager, assistant, user, context, libraries }) => {
81
92
  }
82
93
  };
83
94
 
95
+ /**
96
+ * Extract first/last name from provider data (Google, Facebook, GitHub, etc.)
97
+ * Returns { first, last } or null if no name found
98
+ */
99
+ function extractProviderName(user) {
100
+ // Try provider-specific displayName first, then top-level displayName
101
+ const displayName = user.providerData?.find(p =>
102
+ p.providerId !== 'password'
103
+ && p.providerId !== 'anonymous'
104
+ && p.displayName
105
+ )?.displayName || user.displayName;
106
+
107
+ if (!displayName) {
108
+ return null;
109
+ }
110
+
111
+ const parts = displayName.trim().split(/\s+/);
112
+
113
+ return {
114
+ first: parts[0] || null,
115
+ last: parts.slice(1).join(' ') || null,
116
+ };
117
+ }
118
+
84
119
  /**
85
120
  * Retry a function up to maxRetries times with exponential backoff
86
121
  */
@@ -151,7 +151,7 @@ const FIELDS = {
151
151
  user_personal_name_first: { display: 'First Name', source: 'user', path: 'personal.name.first', type: 'text', skip: ['sendgrid'] },
152
152
  user_personal_name_last: { display: 'Last Name', source: 'user', path: 'personal.name.last', type: 'text', skip: ['sendgrid'] },
153
153
  user_personal_company: { display: 'Company', source: 'user', path: 'personal.company.name', type: 'text' },
154
- user_personal_country: { display: 'Country', source: 'user', path: 'personal.location.country', type: 'text' },
154
+ user_personal_country: { display: 'Country', source: 'user', path: 'personal.location.country', type: 'text', skip: ['beehiiv'] },
155
155
  user_metadata_signup_date: { display: 'Signup Date', source: 'user', path: 'metadata.created.timestamp', type: 'date' },
156
156
  user_metadata_last_activity: { display: 'Last Activity', source: 'user', path: 'metadata.updated.timestamp', type: 'date' },
157
157
 
@@ -167,9 +167,52 @@ const FIELDS = {
167
167
  user_subscription_payment_last_date: { display: 'Last Payment Date', source: 'user', path: 'subscription.payment.updatedBy.date.timestamp', type: 'date' },
168
168
 
169
169
  // Attribution
170
- user_attribution_utm_source: { display: 'UTM Source', source: 'user', path: 'attribution.utm.tags.utm_source', type: 'text' },
170
+ user_attribution_utm_source: { display: 'UTM Source', source: 'user', path: 'attribution.utm.tags.utm_source', type: 'text', skip: ['beehiiv'] },
171
171
  };
172
172
 
173
+ // Master segment dictionary — SSOT for all marketing segments.
174
+ //
175
+ // Segments are created in each provider by OMEGA (like custom fields).
176
+ // BEM references them by key. Provider-specific IDs are resolved at runtime.
177
+ //
178
+ // Condition types:
179
+ // 'field' — custom field condition (uses FIELDS above)
180
+ // 'engagement' — provider built-in engagement tracking (opens, clicks)
181
+ //
182
+ // To add a new segment:
183
+ // 1. Add an entry here
184
+ // 2. Add matching entry in OMEGA's src/lib/bem-segments.js
185
+ // 3. Run OMEGA: npm start -- --service=sendgrid,beehiiv --brand=X
186
+ //
187
+ // Providers:
188
+ // skip — Array of provider names to skip segment creation for
189
+ // (e.g., engagement segments may not be supported on all providers)
190
+ const SEGMENTS = {
191
+ // Subscription
192
+ subscription_free: { display: 'Free Users', conditions: [{ field: 'user_subscription_plan', op: '==', value: 'basic' }] },
193
+ subscription_paid: { display: 'Paid Users', conditions: [{ field: 'user_subscription_plan', op: '!=', value: 'basic' }, { field: 'user_subscription_status', op: '==', value: 'active' }] },
194
+ subscription_trialing: { display: 'Trialing', conditions: [{ field: 'user_subscription_trialing', op: '==', value: 'true' }] },
195
+ subscription_cancelling: { display: 'Cancelling', conditions: [{ field: 'user_subscription_cancelling', op: '==', value: 'true' }] },
196
+ subscription_suspended: { display: 'Suspended', conditions: [{ field: 'user_subscription_status', op: '==', value: 'suspended' }] },
197
+ subscription_cancelled: { display: 'Cancelled', conditions: [{ field: 'user_subscription_status', op: '==', value: 'cancelled' }] },
198
+ subscription_churned: { display: 'Churned (Paid → Cancelled)', conditions: [{ field: 'user_subscription_ever_paid', op: '==', value: 'true' }, { field: 'user_subscription_status', op: '==', value: 'cancelled' }] },
199
+ subscription_ever_paid: { display: 'Ever Paid', conditions: [{ field: 'user_subscription_ever_paid', op: '==', value: 'true' }] },
200
+ subscription_never_paid: { display: 'Never Paid', conditions: [{ field: 'user_subscription_ever_paid', op: '!=', value: 'true' }] },
201
+
202
+ // Lifecycle (time since signup)
203
+ lifecycle_7d: { display: 'Signed Up Last 7 Days', conditions: [{ field: 'user_metadata_signup_date', op: 'within', value: '7d' }] },
204
+ lifecycle_30d: { display: 'Signed Up Last 30 Days', conditions: [{ field: 'user_metadata_signup_date', op: 'within', value: '30d' }] },
205
+ lifecycle_90d: { display: 'Signed Up Last 90 Days', conditions: [{ field: 'user_metadata_signup_date', op: 'within', value: '90d' }] },
206
+ lifecycle_6m: { display: 'Signed Up Last 6 Months', conditions: [{ field: 'user_metadata_signup_date', op: 'within', value: '180d' }] },
207
+ lifecycle_1y: { display: 'Signed Up Last 1 Year', conditions: [{ field: 'user_metadata_signup_date', op: 'within', value: '365d' }] },
208
+
209
+ // Engagement (provider built-in open/click tracking)
210
+ engagement_active_30d: { display: 'Engaged Last 30 Days', conditions: [{ type: 'engagement', op: 'opened_or_clicked', value: '30d' }] },
211
+ engagement_active_90d: { display: 'Engaged Last 90 Days', conditions: [{ type: 'engagement', op: 'opened_or_clicked', value: '90d' }] },
212
+ engagement_inactive_90d: { display: 'Inactive 90+ Days', conditions: [{ type: 'engagement', op: 'not_opened', value: '90d' }] },
213
+ engagement_inactive_5m: { display: 'Inactive 5+ Months', conditions: [{ type: 'engagement', op: 'not_opened', value: '150d' }] },
214
+ engagement_inactive_6m: { display: 'Inactive 6+ Months', conditions: [{ type: 'engagement', op: 'not_opened', value: '180d' }] },
215
+ };
173
216
 
174
217
  /**
175
218
  * Resolve all field values from a user doc + config.
@@ -239,6 +282,7 @@ module.exports = {
239
282
  GROUPS,
240
283
  SENDERS,
241
284
  FIELDS,
285
+ SEGMENTS,
242
286
  SEND_AT_LIMIT,
243
287
  sanitizeImagesForEmail,
244
288
  encode,
@@ -104,7 +104,17 @@ Email.prototype.remove = function (email) {
104
104
  };
105
105
 
106
106
  /**
107
- * Cancel a scheduled marketing campaign.
107
+ * Create and optionally schedule a marketing campaign across enabled providers.
108
+ *
109
+ * @param {object} settings - See Marketing.sendCampaign() for full options
110
+ * @returns {{ sendgrid?: object, beehiiv?: object }}
111
+ */
112
+ Email.prototype.sendCampaign = function (settings) {
113
+ return this._marketing.sendCampaign(settings);
114
+ };
115
+
116
+ /**
117
+ * Cancel a scheduled marketing campaign (SendGrid only).
108
118
  *
109
119
  * @param {string} campaignId - Single Send ID
110
120
  * @returns {{ success: boolean, error?: string }}
@@ -114,7 +124,7 @@ Email.prototype.cancelCampaign = function (campaignId) {
114
124
  };
115
125
 
116
126
  /**
117
- * Get a marketing campaign by ID.
127
+ * Get a marketing campaign by ID (SendGrid only).
118
128
  *
119
129
  * @param {string} campaignId - Single Send ID
120
130
  * @returns {object|null}
@@ -124,7 +134,7 @@ Email.prototype.getCampaign = function (campaignId) {
124
134
  };
125
135
 
126
136
  /**
127
- * List marketing campaigns.
137
+ * List marketing campaigns (SendGrid only).
128
138
  *
129
139
  * @param {object} [options] - { status: 'draft' | 'scheduled' | 'triggered' }
130
140
  * @returns {Array<object>}
@@ -24,8 +24,11 @@
24
24
  * - Campaign cron jobs (send campaigns)
25
25
  */
26
26
  const _ = require('lodash');
27
+ const MarkdownIt = require('markdown-it');
28
+ const md = new MarkdownIt({ html: true, breaks: true, linkify: true });
27
29
 
28
30
  const { TEMPLATES, GROUPS, SENDERS } = require('../constants.js');
31
+ const { tagLinks } = require('../utm.js');
29
32
  const sendgridProvider = require('../providers/sendgrid.js');
30
33
  const beehiivProvider = require('../providers/beehiiv.js');
31
34
 
@@ -235,29 +238,133 @@ Marketing.prototype.remove = async function (email) {
235
238
  };
236
239
 
237
240
  /**
238
- * Create and optionally schedule a marketing campaign (SendGrid Single Send).
241
+ * Create and optionally schedule a marketing campaign across enabled providers.
242
+ *
243
+ * Unified interface — each provider handles what it supports:
244
+ * SendGrid: Single Send with lists, segments, excludes, templates
245
+ * Beehiiv: Post with segments, HTML content, scheduling
239
246
  *
240
247
  * @param {object} settings
241
- * @param {string} settings.name - Campaign name
242
- * @param {string} settings.subject - Email subject
248
+ * @param {string} settings.name - Campaign name (internal, used as title for Beehiiv)
249
+ * @param {string} settings.subject - Email subject line
250
+ * @param {string} [settings.preheader] - Email preview text
243
251
  * @param {string} [settings.template] - Template shortcut or SendGrid template ID
252
+ * @param {string} [settings.content] - Markdown content (converted to HTML per provider)
253
+ * @param {object} [settings.data] - Dynamic template variables (SendGrid only)
244
254
  * @param {string} [settings.sender] - Sender category ('marketing', 'newsletter', etc.)
245
- * @param {Array<string>} [settings.segments] - Segment IDs to target
246
- * @param {Array<string>} [settings.lists] - List IDs to target
247
- * @param {boolean} [settings.all] - Target all contacts
248
- * @param {string|number} [settings.sendAt] - ISO datetime or 'now' to schedule immediately
249
- * @param {Array<string>} [settings.categories] - Email categories
250
- * @returns {{ success: boolean, id?: string, scheduled?: boolean, error?: string }}
255
+ * @param {Array<string>} [settings.lists] - SendGrid list IDs (defaults to brand list)
256
+ * @param {Array<string>} [settings.segments] - Segment IDs to target (both providers)
257
+ * @param {Array<string>} [settings.excludeSegments] - Segment IDs to exclude (both providers)
258
+ * @param {boolean} [settings.all] - Target all contacts (SendGrid only)
259
+ * @param {string} [settings.sendAt] - ISO datetime, 'now', or omit for draft
260
+ * @param {string} [settings.group] - ASM unsubscribe group (SendGrid only)
261
+ * @param {Array<string>} [settings.categories] - Analytics categories (SendGrid only)
262
+ * @param {Array<string>} [settings.providers] - Override which providers to use
263
+ * @returns {{ sendgrid?: object, beehiiv?: object }}
251
264
  */
252
265
  Marketing.prototype.sendCampaign = async function (settings) {
253
266
  const self = this;
254
267
  const Manager = self.Manager;
255
268
  const assistant = self.assistant;
256
269
 
257
- if (!self.providers.sendgrid) {
258
- return { success: false, error: 'SendGrid not enabled' };
270
+ const useProviders = settings.providers || Object.keys(self.providers).filter(p => self.providers[p]);
271
+ const results = {};
272
+ const promises = [];
273
+
274
+ // Resolve campaign-level variables: {brand.name}, {season}, {year}, etc.
275
+ // Uses single braces via powertools.template() — distinct from {{template}} vars handled by SendGrid
276
+ const brand = Manager.config?.brand;
277
+ const templateContext = buildTemplateContext(brand);
278
+ const template = require('node-powertools').template;
279
+
280
+ const resolvedSettings = {
281
+ ...settings,
282
+ name: template(settings.name || '', templateContext),
283
+ subject: template(settings.subject || '', templateContext),
284
+ preheader: template(settings.preheader || '', templateContext),
285
+ content: template(settings.content || '', templateContext),
286
+ };
287
+
288
+ // Convert markdown content to HTML, then tag links with UTM params
289
+ let contentHtml = resolvedSettings.content ? md.render(resolvedSettings.content) : '';
290
+
291
+ if (contentHtml) {
292
+ contentHtml = tagLinks(contentHtml, {
293
+ brandUrl: brand?.url,
294
+ brandId: brand?.id,
295
+ campaign: settings.name,
296
+ type: 'marketing',
297
+ utm: settings.utm,
298
+ });
299
+ }
300
+
301
+ // Resolve SSOT segment keys → provider segment IDs
302
+ const resolvedSegments = {};
303
+
304
+ if (useProviders.includes('sendgrid') && self.providers.sendgrid) {
305
+ const segmentIdMap = await sendgridProvider.resolveSegmentIds();
306
+
307
+ resolvedSegments.sendgrid = {
308
+ segments: (settings.segments || []).map(key => segmentIdMap[key] || key).filter(Boolean),
309
+ excludeSegments: (settings.excludeSegments || []).map(key => segmentIdMap[key] || key).filter(Boolean),
310
+ };
311
+ }
312
+
313
+ // Beehiiv: segment resolution will go here when Beehiiv segments are supported
314
+
315
+ assistant.log('Marketing.sendCampaign():', {
316
+ name: resolvedSettings.name,
317
+ providers: useProviders,
318
+ sendAt: settings.sendAt || 'draft',
319
+ });
320
+
321
+ // --- SendGrid ---
322
+ if (useProviders.includes('sendgrid') && self.providers.sendgrid) {
323
+ const sgSettings = {
324
+ ...resolvedSettings,
325
+ segments: resolvedSegments.sendgrid?.segments || [],
326
+ excludeSegments: resolvedSegments.sendgrid?.excludeSegments || [],
327
+ };
328
+
329
+ promises.push(
330
+ self._sendCampaignSendGrid(sgSettings, contentHtml)
331
+ .then((r) => { results.sendgrid = r; })
332
+ .catch((e) => { results.sendgrid = { success: false, error: e.message }; })
333
+ );
259
334
  }
260
335
 
336
+ // --- Beehiiv ---
337
+ if (useProviders.includes('beehiiv') && self.providers.beehiiv) {
338
+ promises.push(
339
+ beehiivProvider.createPost({
340
+ title: resolvedSettings.name,
341
+ subject: resolvedSettings.subject,
342
+ preheader: resolvedSettings.preheader,
343
+ content: contentHtml,
344
+ sendAt: settings.sendAt,
345
+ segments: settings.segments,
346
+ excludeSegments: settings.excludeSegments,
347
+ })
348
+ .then((r) => { results.beehiiv = r; })
349
+ .catch((e) => { results.beehiiv = { success: false, error: e.message }; })
350
+ );
351
+ }
352
+
353
+ await Promise.all(promises);
354
+
355
+ assistant.log('Marketing.sendCampaign() results:', results);
356
+
357
+ return results;
358
+ };
359
+
360
+ /**
361
+ * SendGrid-specific campaign creation (Single Send + optional schedule).
362
+ * @private
363
+ */
364
+ Marketing.prototype._sendCampaignSendGrid = async function (settings, contentHtml) {
365
+ const self = this;
366
+ const Manager = self.Manager;
367
+
261
368
  const templateId = TEMPLATES[settings.template] || settings.template || TEMPLATES['default'];
262
369
 
263
370
  // Resolve sender
@@ -295,61 +402,57 @@ Marketing.prototype.sendCampaign = async function (settings) {
295
402
  ...require('node-powertools').arrayify(settings.categories),
296
403
  ].filter(Boolean));
297
404
 
298
- assistant.log('Marketing.sendCampaign():', { name: settings.name, sendTo, templateId });
299
-
300
405
  // Create the Single Send
301
406
  const createResult = await sendgridProvider.createSingleSend({
302
407
  name: settings.name,
303
408
  subject: settings.subject,
409
+ preheader: settings.preheader,
304
410
  templateId,
305
411
  from,
306
412
  sendTo,
413
+ excludeSegments: settings.excludeSegments,
307
414
  asmGroupId,
308
415
  categories,
416
+ dynamicTemplateData: {
417
+ ...settings.data,
418
+ ...(contentHtml ? { content: contentHtml } : {}),
419
+ },
309
420
  });
310
421
 
311
422
  if (!createResult.success) {
312
- assistant.error('Marketing.sendCampaign() create failed:', createResult.error);
313
423
  return createResult;
314
424
  }
315
425
 
316
426
  // Schedule if sendAt is provided
317
- if (settings.sendAt) {
318
- const sendAt = settings.sendAt === 'now' ? 'now' : new Date(settings.sendAt).toISOString();
319
-
320
- const scheduleResult = await sendgridProvider.scheduleSingleSend(createResult.id, sendAt);
321
-
322
- if (!scheduleResult.success) {
323
- assistant.error('Marketing.sendCampaign() schedule failed:', scheduleResult.error);
324
- return { success: false, id: createResult.id, error: scheduleResult.error };
325
- }
427
+ if (!settings.sendAt) {
428
+ return { success: true, id: createResult.id, scheduled: false };
429
+ }
326
430
 
327
- assistant.log('Marketing.sendCampaign() scheduled:', createResult.id);
431
+ const sendAt = settings.sendAt === 'now' ? 'now' : new Date(settings.sendAt).toISOString();
432
+ const scheduleResult = await sendgridProvider.scheduleSingleSend(createResult.id, sendAt);
328
433
 
329
- return { success: true, id: createResult.id, scheduled: true };
434
+ if (!scheduleResult.success) {
435
+ return { success: false, id: createResult.id, error: scheduleResult.error };
330
436
  }
331
437
 
332
- // Created but not scheduled (draft)
333
- return { success: true, id: createResult.id, scheduled: false };
438
+ return { success: true, id: createResult.id, scheduled: true };
334
439
  };
335
440
 
336
441
  /**
337
- * Cancel a scheduled campaign.
442
+ * Cancel a scheduled campaign (SendGrid only).
338
443
  *
339
444
  * @param {string} campaignId - Single Send ID
340
445
  * @returns {{ success: boolean, error?: string }}
341
446
  */
342
447
  Marketing.prototype.cancelCampaign = async function (campaignId) {
343
448
  const self = this;
344
- const assistant = self.assistant;
345
-
346
- assistant.log('Marketing.cancelCampaign():', campaignId);
449
+ self.assistant.log('Marketing.cancelCampaign():', campaignId);
347
450
 
348
451
  return sendgridProvider.cancelSingleSend(campaignId);
349
452
  };
350
453
 
351
454
  /**
352
- * Get a campaign by ID.
455
+ * Get a campaign by ID (SendGrid only).
353
456
  *
354
457
  * @param {string} campaignId - Single Send ID
355
458
  * @returns {object|null}
@@ -359,7 +462,7 @@ Marketing.prototype.getCampaign = async function (campaignId) {
359
462
  };
360
463
 
361
464
  /**
362
- * List campaigns with optional status filter.
465
+ * List campaigns with optional status filter (SendGrid only).
363
466
  *
364
467
  * @param {object} [options]
365
468
  * @param {string} [options.status] - Filter: draft, scheduled, triggered
@@ -369,4 +472,73 @@ Marketing.prototype.listCampaigns = async function (options) {
369
472
  return sendgridProvider.listSingleSends(options);
370
473
  };
371
474
 
475
+ // --- Campaign variable resolution ---
476
+
477
+ const SEASONS = {
478
+ 0: 'Winter', // Jan
479
+ 1: 'Winter', // Feb
480
+ 2: 'Spring', // Mar
481
+ 3: 'Spring', // Apr
482
+ 4: 'Spring', // May
483
+ 5: 'Summer', // Jun
484
+ 6: 'Summer', // Jul
485
+ 7: 'Summer', // Aug
486
+ 8: 'Fall', // Sep
487
+ 9: 'Fall', // Oct
488
+ 10: 'Fall', // Nov
489
+ 11: 'Winter', // Dec
490
+ };
491
+
492
+ const HOLIDAYS = {
493
+ 0: 'New Year', // Jan
494
+ 1: 'Valentine\'s Day', // Feb
495
+ 2: 'Spring', // Mar
496
+ 3: 'Spring', // Apr
497
+ 4: 'Memorial Day', // May
498
+ 5: 'Summer', // Jun
499
+ 6: 'Independence Day', // Jul
500
+ 7: 'Back to School', // Aug
501
+ 8: 'Labor Day', // Sep
502
+ 9: 'Halloween', // Oct
503
+ 10: 'Black Friday', // Nov
504
+ 11: 'Christmas', // Dec
505
+ };
506
+
507
+ const MONTH_NAMES = [
508
+ 'January', 'February', 'March', 'April', 'May', 'June',
509
+ 'July', 'August', 'September', 'October', 'November', 'December',
510
+ ];
511
+
512
+ /**
513
+ * Build template context for campaign variable resolution.
514
+ * Used with powertools.template() — supports nested paths like {brand.name}.
515
+ *
516
+ * Available variables:
517
+ * {brand.name}, {brand.id}, {brand.url}
518
+ * {season.name} — Winter, Spring, Summer, Fall
519
+ * {holiday.name} — New Year, Valentine's Day, Black Friday, Christmas, etc.
520
+ * {date.month} — January, February, etc.
521
+ * {date.year} — 2026
522
+ * {date.full} — March 17, 2026
523
+ */
524
+ function buildTemplateContext(brand) {
525
+ const now = new Date();
526
+ const month = now.getMonth();
527
+
528
+ return {
529
+ brand: brand || {},
530
+ season: {
531
+ name: SEASONS[month],
532
+ },
533
+ holiday: {
534
+ name: HOLIDAYS[month],
535
+ },
536
+ date: {
537
+ month: MONTH_NAMES[month],
538
+ year: String(now.getFullYear()),
539
+ full: now.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
540
+ },
541
+ };
542
+ }
543
+
372
544
  module.exports = Marketing;
@@ -274,9 +274,99 @@ function buildFields(userDoc) {
274
274
  return fields;
275
275
  }
276
276
 
277
+ // --- Campaigns (Posts) ---
278
+
279
+ /**
280
+ * Create a Beehiiv post (their equivalent of a campaign/newsletter).
281
+ *
282
+ * @param {object} options
283
+ * @param {string} options.title - Post title (required)
284
+ * @param {string} [options.subject] - Email subject line (defaults to title)
285
+ * @param {string} [options.preheader] - Email preview text
286
+ * @param {string} [options.content] - HTML content body
287
+ * @param {string} [options.status] - 'draft' or 'confirmed' (default: confirmed = send)
288
+ * @param {string} [options.sendAt] - ISO datetime to schedule, or null for immediate
289
+ * @param {Array<string>} [options.segments] - Segment IDs to include
290
+ * @param {Array<string>} [options.excludeSegments] - Segment IDs to exclude
291
+ * @returns {{ success: boolean, id?: string, scheduled?: boolean, error?: string }}
292
+ */
293
+ async function createPost(options) {
294
+ const publicationId = await getPublicationId();
295
+
296
+ if (!publicationId) {
297
+ return { success: false, error: 'Publication not found' };
298
+ }
299
+
300
+ const { title, subject, preheader, content, status, sendAt, segments, excludeSegments } = options;
301
+
302
+ try {
303
+ const body = {
304
+ title,
305
+ status: sendAt ? 'confirmed' : (status || 'confirmed'),
306
+ };
307
+
308
+ // Content
309
+ if (content) {
310
+ body.body_content = content;
311
+ }
312
+
313
+ // Scheduling
314
+ if (sendAt && sendAt !== 'now') {
315
+ body.scheduled_at = new Date(sendAt).toISOString();
316
+ }
317
+
318
+ // Email settings
319
+ const emailSettings = {};
320
+
321
+ if (subject) {
322
+ emailSettings.subject_line = subject;
323
+ }
324
+ if (preheader) {
325
+ emailSettings.preview_text = preheader;
326
+ }
327
+
328
+ if (Object.keys(emailSettings).length) {
329
+ body.email_settings = emailSettings;
330
+ }
331
+
332
+ // Audience targeting (segments)
333
+ if ((segments && segments.length) || (excludeSegments && excludeSegments.length)) {
334
+ body.recipients = {};
335
+
336
+ if (segments && segments.length) {
337
+ body.recipients.segment_ids = segments;
338
+ }
339
+ if (excludeSegments && excludeSegments.length) {
340
+ body.recipients.exclude_segment_ids = excludeSegments;
341
+ }
342
+ }
343
+
344
+ const data = await fetch(`${BASE_URL}/publications/${publicationId}/posts`, {
345
+ method: 'post',
346
+ response: 'json',
347
+ headers: headers(),
348
+ timeout: 15000,
349
+ body,
350
+ });
351
+
352
+ if (data.data?.id) {
353
+ const scheduled = !!sendAt;
354
+ return { success: true, id: data.data.id, scheduled };
355
+ }
356
+
357
+ return { success: false, error: data.message || 'Unknown error' };
358
+ } catch (e) {
359
+ console.error('Beehiiv createPost error:', e);
360
+ return { success: false, error: e.message };
361
+ }
362
+ }
363
+
277
364
  module.exports = {
278
365
  // Contacts
279
366
  addContact,
280
367
  removeContact,
281
368
  buildFields,
369
+
370
+ // Campaigns
371
+ createPost,
282
372
  };