backend-manager 5.0.162 → 5.0.164
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 +9 -0
- package/package.json +1 -1
- package/src/cli/commands/setup-tests/marketing-campaigns-seeded.js +65 -2
- package/src/manager/routes/marketing/campaign/post.js +9 -82
- package/src/manager/routes/marketing/campaign/put.js +8 -58
- package/src/manager/routes/marketing/campaign/utils.js +58 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,15 @@ 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.164] - 2026-03-18
|
|
18
|
+
### Added
|
|
19
|
+
- Default field backfill in campaign seed setup — missing fields are restored from seed defaults without overwriting user edits
|
|
20
|
+
|
|
21
|
+
# [5.0.163] - 2026-03-18
|
|
22
|
+
### Changed
|
|
23
|
+
- Refactored campaign POST/PUT routes to generic field passthrough — schema-validated fields flow through automatically via shared `buildCampaignDoc()` utility, no manual field assignments needed
|
|
24
|
+
- Extracted `normalizeSendAt()` and `DOC_LEVEL_FIELDS` into `routes/marketing/campaign/utils.js`
|
|
25
|
+
|
|
17
26
|
# [5.0.161] - 2026-03-18
|
|
18
27
|
### Added
|
|
19
28
|
- Port conflict detection in `serve` command — checks and kills blocking processes before starting Firebase server
|
package/package.json
CHANGED
|
@@ -25,7 +25,7 @@ class MarketingCampaignsSeededTest extends BaseTest {
|
|
|
25
25
|
return false;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
// Check enforced fields
|
|
28
|
+
// Check enforced fields + missing defaults
|
|
29
29
|
const data = doc.data();
|
|
30
30
|
|
|
31
31
|
for (const [path, expected] of Object.entries(seed.enforced)) {
|
|
@@ -35,6 +35,11 @@ class MarketingCampaignsSeededTest extends BaseTest {
|
|
|
35
35
|
return false;
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
// Check for missing fields that should exist from seed
|
|
40
|
+
if (hasMissingFields(data, seed.doc)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
return true;
|
|
@@ -62,10 +67,14 @@ class MarketingCampaignsSeededTest extends BaseTest {
|
|
|
62
67
|
continue;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
// Doc exists →
|
|
70
|
+
// Doc exists → fill missing defaults + enforce required fields
|
|
66
71
|
const data = doc.data();
|
|
67
72
|
const updates = {};
|
|
68
73
|
|
|
74
|
+
// Fill missing fields from seed defaults (never overwrite existing values)
|
|
75
|
+
fillMissing(data, seed.doc, updates, '');
|
|
76
|
+
|
|
77
|
+
// Enforce required fields (always overwrite to match seed)
|
|
69
78
|
for (const [path, expected] of Object.entries(seed.enforced)) {
|
|
70
79
|
const actual = _.get(data, path);
|
|
71
80
|
|
|
@@ -106,4 +115,58 @@ class MarketingCampaignsSeededTest extends BaseTest {
|
|
|
106
115
|
}
|
|
107
116
|
}
|
|
108
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Check if the live doc is missing any fields defined in the seed.
|
|
120
|
+
*/
|
|
121
|
+
function hasMissingFields(live, seed, prefix) {
|
|
122
|
+
for (const [key, seedValue] of Object.entries(seed)) {
|
|
123
|
+
if (key === 'metadata') {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
128
|
+
const liveValue = _.get(live, path);
|
|
129
|
+
|
|
130
|
+
if (liveValue === undefined) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (_.isPlainObject(seedValue) && _.isPlainObject(liveValue)) {
|
|
135
|
+
if (hasMissingFields(live, seedValue, path)) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Recursively fill missing fields from seed into updates.
|
|
146
|
+
* Only sets fields that don't exist in the live doc — never overwrites.
|
|
147
|
+
* Skips metadata (managed separately).
|
|
148
|
+
*/
|
|
149
|
+
function fillMissing(live, seed, updates, prefix) {
|
|
150
|
+
for (const [key, seedValue] of Object.entries(seed)) {
|
|
151
|
+
if (key === 'metadata') {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
156
|
+
const liveValue = _.get(live, path);
|
|
157
|
+
|
|
158
|
+
// If live doc is missing this field entirely, set it from seed
|
|
159
|
+
if (liveValue === undefined) {
|
|
160
|
+
_.set(updates, path, seedValue);
|
|
161
|
+
console.log(chalk.blue(` + ${path}: ${chalk.dim('(missing)')} → ${chalk.bold(JSON.stringify(seedValue).slice(0, 80))}`));
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// If both are plain objects, recurse to check nested fields
|
|
166
|
+
if (_.isPlainObject(seedValue) && _.isPlainObject(liveValue)) {
|
|
167
|
+
fillMissing(live, seedValue, updates, path);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
109
172
|
module.exports = MarketingCampaignsSeededTest;
|
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Content is markdown — converted to HTML at send time per provider.
|
|
11
11
|
*/
|
|
12
|
-
const _ = require('lodash');
|
|
13
|
-
const moment = require('moment');
|
|
14
12
|
const pushid = require('pushid');
|
|
13
|
+
const { buildCampaignDoc } = require('./utils');
|
|
15
14
|
|
|
16
15
|
module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
17
16
|
|
|
@@ -24,55 +23,14 @@ module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
|
24
23
|
|
|
25
24
|
const { admin } = Manager.libraries;
|
|
26
25
|
const campaignId = settings.id || pushid();
|
|
27
|
-
const now =
|
|
26
|
+
const { docFields, campaignSettings, now } = buildCampaignDoc(settings);
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
const sendAt = normalizeSendAt(settings.sendAt, now);
|
|
31
|
-
|
|
32
|
-
// Build the campaign document (settings nested, like emails-queue)
|
|
33
|
-
const campaignSettings = {};
|
|
34
|
-
|
|
35
|
-
// Required
|
|
36
|
-
campaignSettings.name = settings.name;
|
|
37
|
-
campaignSettings.subject = settings.subject;
|
|
38
|
-
|
|
39
|
-
// Content
|
|
40
|
-
if (settings.preheader) { campaignSettings.preheader = settings.preheader; }
|
|
41
|
-
if (settings.template && settings.template !== 'default') { campaignSettings.template = settings.template; }
|
|
42
|
-
if (settings.content) { campaignSettings.content = settings.content; }
|
|
43
|
-
if (settings.data && Object.keys(settings.data).length) { campaignSettings.data = settings.data; }
|
|
44
|
-
|
|
45
|
-
// Targeting
|
|
46
|
-
if (settings.lists && settings.lists.length) { campaignSettings.lists = settings.lists; }
|
|
47
|
-
if (settings.segments && settings.segments.length) { campaignSettings.segments = settings.segments; }
|
|
48
|
-
if (settings.excludeSegments && settings.excludeSegments.length) { campaignSettings.excludeSegments = settings.excludeSegments; }
|
|
49
|
-
if (settings.all) { campaignSettings.all = true; }
|
|
50
|
-
|
|
51
|
-
// UTM
|
|
52
|
-
if (settings.utm && Object.keys(settings.utm).length) { campaignSettings.utm = settings.utm; }
|
|
53
|
-
|
|
54
|
-
// Config
|
|
55
|
-
if (settings.sender) { campaignSettings.sender = settings.sender; }
|
|
56
|
-
if (settings.providers && settings.providers.length) { campaignSettings.providers = settings.providers; }
|
|
57
|
-
if (settings.group) { campaignSettings.group = settings.group; }
|
|
58
|
-
if (settings.categories && settings.categories.length) { campaignSettings.categories = settings.categories; }
|
|
59
|
-
|
|
60
|
-
// Clone and clean undefined values for Firestore
|
|
61
|
-
const settingsCloned = _.cloneDeepWith(campaignSettings, (value) => {
|
|
62
|
-
if (typeof value === 'undefined') {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
const isFuture = sendAt > now.unix();
|
|
68
|
-
const type = settings.type || 'email';
|
|
28
|
+
const isFuture = docFields.sendAt > now.unix();
|
|
69
29
|
|
|
70
30
|
const doc = {
|
|
71
|
-
|
|
72
|
-
|
|
31
|
+
...docFields,
|
|
32
|
+
settings: campaignSettings,
|
|
73
33
|
status: 'pending',
|
|
74
|
-
type,
|
|
75
|
-
...(settings.recurrence ? { recurrence: settings.recurrence } : {}),
|
|
76
34
|
metadata: {
|
|
77
35
|
created: {
|
|
78
36
|
timestamp: now.toISOString(),
|
|
@@ -88,12 +46,12 @@ module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
|
88
46
|
// Save to Firestore
|
|
89
47
|
await admin.firestore().doc(`marketing-campaigns/${campaignId}`).set(doc);
|
|
90
48
|
|
|
91
|
-
assistant.log('marketing/campaign created:', { campaignId, sendAt, isFuture, type });
|
|
49
|
+
assistant.log('marketing/campaign created:', { campaignId, sendAt: docFields.sendAt, isFuture, type: docFields.type });
|
|
92
50
|
|
|
93
51
|
// If sendAt is now/past, fire immediately
|
|
94
52
|
let results = null;
|
|
95
53
|
|
|
96
|
-
if (!isFuture && type === 'email') {
|
|
54
|
+
if (!isFuture && docFields.type === 'email') {
|
|
97
55
|
const mailer = Manager.Email(assistant);
|
|
98
56
|
results = await mailer.sendCampaign({ ...campaignSettings, sendAt: 'now' });
|
|
99
57
|
|
|
@@ -117,45 +75,14 @@ module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
|
117
75
|
// Analytics
|
|
118
76
|
analytics.event('marketing/campaign', {
|
|
119
77
|
action: isFuture ? 'schedule' : 'send',
|
|
120
|
-
type,
|
|
78
|
+
type: docFields.type,
|
|
121
79
|
});
|
|
122
80
|
|
|
123
81
|
return assistant.respond({
|
|
124
82
|
success: true,
|
|
125
83
|
id: campaignId,
|
|
126
84
|
status: isFuture ? 'pending' : (results ? 'sent' : 'pending'),
|
|
127
|
-
sendAt,
|
|
85
|
+
sendAt: docFields.sendAt,
|
|
128
86
|
providers: results,
|
|
129
87
|
});
|
|
130
88
|
};
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Normalize sendAt to unix timestamp.
|
|
134
|
-
* Accepts: 'now', ISO string, unix timestamp (number or string), undefined/empty.
|
|
135
|
-
* Defaults to now.
|
|
136
|
-
*/
|
|
137
|
-
function normalizeSendAt(sendAt, now) {
|
|
138
|
-
if (!sendAt || sendAt === 'now') {
|
|
139
|
-
return now.unix();
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Unix timestamp (number)
|
|
143
|
-
if (typeof sendAt === 'number') {
|
|
144
|
-
return sendAt;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Unix timestamp as string (all digits)
|
|
148
|
-
if (/^\d+$/.test(sendAt)) {
|
|
149
|
-
return parseInt(sendAt, 10);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ISO string or other parseable date
|
|
153
|
-
const parsed = moment(sendAt);
|
|
154
|
-
|
|
155
|
-
if (parsed.isValid()) {
|
|
156
|
-
return parsed.unix();
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Fallback to now
|
|
160
|
-
return now.unix();
|
|
161
|
-
}
|
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
* Accepts any field from the POST schema. Only provided fields are updated.
|
|
6
6
|
* Changing sendAt reschedules the campaign (if still pending).
|
|
7
7
|
*/
|
|
8
|
-
const
|
|
9
|
-
const moment = require('moment');
|
|
8
|
+
const { buildCampaignDoc } = require('./utils');
|
|
10
9
|
|
|
11
10
|
module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
12
11
|
|
|
@@ -39,8 +38,11 @@ module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
|
39
38
|
return assistant.respond(`Cannot edit campaign with status "${existing.status}"`, { code: 400 });
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
// Build update
|
|
41
|
+
// Build update from provided fields using shared utility
|
|
42
|
+
const { docFields, campaignSettings } = buildCampaignDoc(settings);
|
|
43
|
+
|
|
43
44
|
const update = {
|
|
45
|
+
...docFields,
|
|
44
46
|
metadata: {
|
|
45
47
|
updated: {
|
|
46
48
|
timestamp: new Date().toISOString(),
|
|
@@ -49,44 +51,9 @@ module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
|
49
51
|
},
|
|
50
52
|
};
|
|
51
53
|
|
|
52
|
-
//
|
|
53
|
-
if (
|
|
54
|
-
update.
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Update type if provided
|
|
58
|
-
if (settings.type) {
|
|
59
|
-
update.type = settings.type;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Update recurrence if provided
|
|
63
|
-
if (settings.recurrence !== undefined) {
|
|
64
|
-
update.recurrence = settings.recurrence;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Update settings fields — only merge what's provided
|
|
68
|
-
const settingsUpdate = {};
|
|
69
|
-
const settingsFields = [
|
|
70
|
-
'name', 'subject', 'preheader', 'template', 'content', 'data',
|
|
71
|
-
'lists', 'segments', 'excludeSegments', 'all',
|
|
72
|
-
'utm', 'sender', 'providers', 'group', 'categories',
|
|
73
|
-
];
|
|
74
|
-
|
|
75
|
-
for (const field of settingsFields) {
|
|
76
|
-
if (settings[field] !== undefined && settings[field] !== '') {
|
|
77
|
-
settingsUpdate[field] = settings[field];
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (Object.keys(settingsUpdate).length) {
|
|
82
|
-
// Clean undefined values for Firestore
|
|
83
|
-
const cleaned = _.cloneDeepWith(settingsUpdate, (value) => {
|
|
84
|
-
if (typeof value === 'undefined') {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
update.settings = { ...existing.settings, ...cleaned };
|
|
54
|
+
// Merge provided settings into existing
|
|
55
|
+
if (Object.keys(campaignSettings).length) {
|
|
56
|
+
update.settings = { ...existing.settings, ...campaignSettings };
|
|
90
57
|
}
|
|
91
58
|
|
|
92
59
|
await docRef.set(update, { merge: true });
|
|
@@ -103,20 +70,3 @@ module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
|
103
70
|
campaign: { id: campaignId, ...updated.data() },
|
|
104
71
|
});
|
|
105
72
|
};
|
|
106
|
-
|
|
107
|
-
function normalizeSendAt(sendAt) {
|
|
108
|
-
if (!sendAt || sendAt === 'now') {
|
|
109
|
-
return Math.round(Date.now() / 1000);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (typeof sendAt === 'number') {
|
|
113
|
-
return sendAt;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (/^\d+$/.test(sendAt)) {
|
|
117
|
-
return parseInt(sendAt, 10);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const parsed = moment(sendAt);
|
|
121
|
-
return parsed.isValid() ? parsed.unix() : Math.round(Date.now() / 1000);
|
|
122
|
-
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for marketing campaign routes.
|
|
3
|
+
*/
|
|
4
|
+
const _ = require('lodash');
|
|
5
|
+
const moment = require('moment');
|
|
6
|
+
|
|
7
|
+
// Fields that live at the doc level, not inside doc.settings
|
|
8
|
+
const DOC_LEVEL_FIELDS = ['id', 'sendAt', 'type', 'recurrence', 'generator'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Separate settings into doc-level fields and nested settings.
|
|
12
|
+
* Returns { docFields, campaignSettings }.
|
|
13
|
+
*/
|
|
14
|
+
function buildCampaignDoc(settings) {
|
|
15
|
+
const now = moment();
|
|
16
|
+
|
|
17
|
+
// Extract doc-level fields
|
|
18
|
+
const sendAt = normalizeSendAt(settings.sendAt, now);
|
|
19
|
+
const type = settings.type || 'email';
|
|
20
|
+
|
|
21
|
+
const docFields = {
|
|
22
|
+
sendAt,
|
|
23
|
+
type,
|
|
24
|
+
...(settings.recurrence ? { recurrence: settings.recurrence } : {}),
|
|
25
|
+
...(settings.generator ? { generator: settings.generator } : {}),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Everything else goes into doc.settings — strip empties
|
|
29
|
+
const campaignSettings = _.omitBy(
|
|
30
|
+
_.omit(settings, DOC_LEVEL_FIELDS),
|
|
31
|
+
(v) => v === undefined || v === '' || (Array.isArray(v) && !v.length) || (_.isPlainObject(v) && !Object.keys(v).length),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return { docFields, campaignSettings, now };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalize sendAt to unix timestamp.
|
|
39
|
+
* Accepts: 'now', ISO string, unix timestamp (number or string), undefined/empty.
|
|
40
|
+
*/
|
|
41
|
+
function normalizeSendAt(sendAt, now) {
|
|
42
|
+
if (!sendAt || sendAt === 'now') {
|
|
43
|
+
return (now || moment()).unix();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typeof sendAt === 'number') {
|
|
47
|
+
return sendAt;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (/^\d+$/.test(sendAt)) {
|
|
51
|
+
return parseInt(sendAt, 10);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const parsed = moment(sendAt);
|
|
55
|
+
return parsed.isValid() ? parsed.unix() : (now || moment()).unix();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { DOC_LEVEL_FIELDS, buildCampaignDoc, normalizeSendAt };
|