backend-manager 5.0.156 → 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.
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.157] - 2026-03-17
18
+ ### Added
19
+ - Campaign template variables via `powertools.template()` — `{brand.name}`, `{season.name}`, `{holiday.name}`, `{date.month}`, `{date.year}`, `{date.full}`
20
+ - Separate SEASONS (Winter/Spring/Summer/Fall) and HOLIDAYS (New Year, Valentine's Day, Black Friday, Christmas, etc.) maps
21
+ - Audit logging in `getSegmentContacts()` — logs export start, poll status, download count, timeout
22
+
23
+ ### Changed
24
+ - Seed sale campaign: quarterly → monthly on 15th, uses `{holiday.name}` template vars, targets free + cancelled + churned users, excludes paid
25
+ - Prune cron calls segment export with 3-minute timeout for large segments
26
+
27
+ ### Fixed
28
+ - S3 presigned URL download broken by wonderful-fetch cache buster — set `cacheBreaker: false`
29
+ - CSV header parsing: normalize to lowercase for case-insensitive column matching
30
+
17
31
  # [5.0.156] - 2026-03-17
18
32
  ### Added
19
33
  - Marketing campaign system with full CRUD routes (`POST/GET/PUT/DELETE /marketing/campaign`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.156",
3
+ "version": "5.0.157",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -13,25 +13,19 @@ const moment = require('moment');
13
13
  */
14
14
 
15
15
  /**
16
- * Get the next occurrence of a quarterly date from now.
17
- * Quarters: Jan 1, Apr 1, Jul 1, Oct 1.
16
+ * Get the next occurrence of a specific day of month.
17
+ * @param {number} dayOfMonth - Day (1-31)
18
+ * @param {number} hour - Hour (UTC)
18
19
  */
19
- function nextQuarter(hour) {
20
- const now = moment.utc();
21
- const quarters = [
22
- moment.utc({ month: 0, day: 1, hour }),
23
- moment.utc({ month: 3, day: 1, hour }),
24
- moment.utc({ month: 6, day: 1, hour }),
25
- moment.utc({ month: 9, day: 1, hour }),
26
- ];
20
+ function nextMonthDay(dayOfMonth, hour) {
21
+ const next = moment.utc().startOf('month').date(dayOfMonth).hour(hour);
27
22
 
28
- for (const q of quarters) {
29
- if (q.year(now.year()).isAfter(now)) {
30
- return q.year(now.year()).unix();
31
- }
23
+ // If this month's date has passed, go to next month
24
+ if (next.isBefore(moment.utc())) {
25
+ next.add(1, 'month');
32
26
  }
33
27
 
34
- return quarters[0].year(now.year() + 1).unix();
28
+ return next.unix();
35
29
  }
36
30
 
37
31
  /**
@@ -56,30 +50,31 @@ function buildSeedCampaigns() {
56
50
 
57
51
  return [
58
52
  {
59
- id: '_recurring-quarterly-sale',
53
+ id: '_recurring-monthly-sale',
60
54
  doc: {
61
55
  settings: {
62
- name: 'Quarterly Sale',
63
- subject: 'Limited Time — Upgrade & Save!',
64
- preheader: 'Our biggest discount this quarter',
56
+ name: '{holiday.name} Sale',
57
+ subject: '{holiday.name} Sale — Upgrade & Save!',
58
+ preheader: 'Limited time offer from {brand.name}',
65
59
  content: [
66
- '# Quarterly Sale',
60
+ '# {holiday.name} Sale',
67
61
  '',
68
- 'For a limited time, upgrade your plan and save big.',
62
+ 'For a limited time, upgrade your **{brand.name}** plan and save big.',
69
63
  '',
70
64
  'Don\'t miss out — this offer ends soon!',
71
65
  ].join('\n'),
72
66
  template: 'default',
73
67
  sender: 'marketing',
74
68
  providers: ['sendgrid'],
75
- segments: ['subscription_free'],
76
- excludeSegments: [],
69
+ segments: ['subscription_free', 'subscription_cancelled', 'subscription_churned'],
70
+ excludeSegments: ['subscription_paid'],
77
71
  },
78
- sendAt: nextQuarter(14),
72
+ sendAt: nextMonthDay(15, 14),
79
73
  status: 'pending',
80
74
  type: 'email',
81
75
  recurrence: {
82
- pattern: 'quarterly',
76
+ pattern: 'monthly',
77
+ day: 15,
83
78
  hour: 14,
84
79
  },
85
80
  metadata: {
@@ -87,14 +82,15 @@ function buildSeedCampaigns() {
87
82
  updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
88
83
  },
89
84
  },
90
- // Fields enforced on every setup run (deep path → value)
91
85
  enforced: {
92
86
  'type': 'email',
93
- 'recurrence.pattern': 'quarterly',
87
+ 'recurrence.pattern': 'monthly',
88
+ 'recurrence.day': 15,
94
89
  'recurrence.hour': 14,
95
90
  'settings.providers': ['sendgrid'],
96
91
  'settings.sender': 'marketing',
97
- 'settings.segments': ['subscription_free'],
92
+ 'settings.segments': ['subscription_free', 'subscription_cancelled', 'subscription_churned'],
93
+ 'settings.excludeSegments': ['subscription_paid'],
98
94
  },
99
95
  },
100
96
  {
@@ -95,7 +95,7 @@ async function stagePrune(Manager, assistant) {
95
95
  return;
96
96
  }
97
97
 
98
- const exportResult = await sendgridProvider.getSegmentContacts(pruneSegmentId);
98
+ const exportResult = await sendgridProvider.getSegmentContacts(pruneSegmentId, 180000);
99
99
 
100
100
  if (!exportResult.success) {
101
101
  assistant.error('Marketing prune: Failed to export segment:', exportResult.error);
@@ -271,9 +271,22 @@ Marketing.prototype.sendCampaign = async function (settings) {
271
271
  const results = {};
272
272
  const promises = [];
273
273
 
274
- // Convert markdown content to HTML, then tag links with UTM params
274
+ // Resolve campaign-level variables: {brand.name}, {season}, {year}, etc.
275
+ // Uses single braces via powertools.template() — distinct from {{template}} vars handled by SendGrid
275
276
  const brand = Manager.config?.brand;
276
- let contentHtml = settings.content ? md.render(settings.content) : '';
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) : '';
277
290
 
278
291
  if (contentHtml) {
279
292
  contentHtml = tagLinks(contentHtml, {
@@ -300,7 +313,7 @@ Marketing.prototype.sendCampaign = async function (settings) {
300
313
  // Beehiiv: segment resolution will go here when Beehiiv segments are supported
301
314
 
302
315
  assistant.log('Marketing.sendCampaign():', {
303
- name: settings.name,
316
+ name: resolvedSettings.name,
304
317
  providers: useProviders,
305
318
  sendAt: settings.sendAt || 'draft',
306
319
  });
@@ -308,7 +321,7 @@ Marketing.prototype.sendCampaign = async function (settings) {
308
321
  // --- SendGrid ---
309
322
  if (useProviders.includes('sendgrid') && self.providers.sendgrid) {
310
323
  const sgSettings = {
311
- ...settings,
324
+ ...resolvedSettings,
312
325
  segments: resolvedSegments.sendgrid?.segments || [],
313
326
  excludeSegments: resolvedSegments.sendgrid?.excludeSegments || [],
314
327
  };
@@ -324,9 +337,9 @@ Marketing.prototype.sendCampaign = async function (settings) {
324
337
  if (useProviders.includes('beehiiv') && self.providers.beehiiv) {
325
338
  promises.push(
326
339
  beehiivProvider.createPost({
327
- title: settings.name,
328
- subject: settings.subject,
329
- preheader: settings.preheader,
340
+ title: resolvedSettings.name,
341
+ subject: resolvedSettings.subject,
342
+ preheader: resolvedSettings.preheader,
330
343
  content: contentHtml,
331
344
  sendAt: settings.sendAt,
332
345
  segments: settings.segments,
@@ -459,4 +472,73 @@ Marketing.prototype.listCampaigns = async function (options) {
459
472
  return sendgridProvider.listSingleSends(options);
460
473
  };
461
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
+
462
544
  module.exports = Marketing;
@@ -495,11 +495,15 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
495
495
  return { success: false, error: 'Failed to start export' };
496
496
  }
497
497
 
498
+ console.log(`SendGrid getSegmentContacts: Export started (job: ${exportData.id})`);
499
+
498
500
  // Poll for completion
499
501
  const startTime = Date.now();
502
+ let pollCount = 0;
500
503
 
501
504
  while (Date.now() - startTime < maxWaitMs) {
502
505
  await new Promise(r => setTimeout(r, 3000));
506
+ pollCount++;
503
507
 
504
508
  const statusData = await fetch(`${BASE_URL}/marketing/contacts/exports/${exportData.id}`, {
505
509
  response: 'json',
@@ -507,11 +511,14 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
507
511
  timeout: 10000,
508
512
  });
509
513
 
514
+ console.log(`SendGrid getSegmentContacts: Poll #${pollCount} — ${statusData.status} (${Date.now() - startTime}ms)`);
515
+
510
516
  if (statusData.status === 'ready' && statusData.urls?.length) {
511
- // Download CSV
517
+ // Download CSV — disable cacheBreaker to preserve presigned S3 URL signature
512
518
  const csvText = await fetch(statusData.urls[0], {
513
519
  response: 'text',
514
520
  timeout: 30000,
521
+ cacheBreaker: false,
515
522
  });
516
523
 
517
524
  // Parse CSV — first line is headers, find email and id columns
@@ -521,7 +528,7 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
521
528
  return { success: true, contacts: [] };
522
529
  }
523
530
 
524
- const headerCols = lines[0].split(',');
531
+ const headerCols = lines[0].split(',').map(h => h.replace(/"/g, '').trim().toLowerCase());
525
532
  const emailIdx = headerCols.indexOf('email');
526
533
  const idIdx = headerCols.indexOf('contact_id');
527
534
 
@@ -537,14 +544,18 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
537
544
  }
538
545
  }
539
546
 
547
+ console.log(`SendGrid getSegmentContacts: Downloaded ${contacts.length} contacts (${Date.now() - startTime}ms)`);
548
+
540
549
  return { success: true, contacts };
541
550
  }
542
551
 
543
552
  if (statusData.status === 'failure') {
553
+ console.error('SendGrid getSegmentContacts: Export failed');
544
554
  return { success: false, error: 'Export failed' };
545
555
  }
546
556
  }
547
557
 
558
+ console.error(`SendGrid getSegmentContacts: Timed out after ${maxWaitMs}ms (${pollCount} polls)`);
548
559
  return { success: false, error: 'Export timed out' };
549
560
  } catch (e) {
550
561
  console.error('SendGrid getSegmentContacts error:', e);