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 +14 -0
- package/package.json +1 -1
- package/src/cli/commands/setup-tests/helpers/seed-campaigns.js +24 -28
- package/src/manager/cron/daily/marketing-prune.js +1 -1
- package/src/manager/libraries/email/marketing/index.js +89 -7
- package/src/manager/libraries/email/providers/sendgrid.js +13 -2
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
|
@@ -13,25 +13,19 @@ const moment = require('moment');
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Get the next occurrence of a
|
|
17
|
-
*
|
|
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
|
|
20
|
-
const
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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-
|
|
53
|
+
id: '_recurring-monthly-sale',
|
|
60
54
|
doc: {
|
|
61
55
|
settings: {
|
|
62
|
-
name: '
|
|
63
|
-
subject: '
|
|
64
|
-
preheader: '
|
|
56
|
+
name: '{holiday.name} Sale',
|
|
57
|
+
subject: '{holiday.name} Sale — Upgrade & Save!',
|
|
58
|
+
preheader: 'Limited time offer from {brand.name}',
|
|
65
59
|
content: [
|
|
66
|
-
'#
|
|
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:
|
|
72
|
+
sendAt: nextMonthDay(15, 14),
|
|
79
73
|
status: 'pending',
|
|
80
74
|
type: 'email',
|
|
81
75
|
recurrence: {
|
|
82
|
-
pattern: '
|
|
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': '
|
|
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
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
-
...
|
|
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:
|
|
328
|
-
subject:
|
|
329
|
-
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);
|