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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.162",
3
+ "version": "5.0.164",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -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 → check and fix enforced fields
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 = moment();
26
+ const { docFields, campaignSettings, now } = buildCampaignDoc(settings);
28
27
 
29
- // Normalize sendAt unix timestamp
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
- settings: settingsCloned,
72
- sendAt,
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 _ = require('lodash');
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 merge provided fields into existing settings
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
- // Update sendAt if provided
53
- if (settings.sendAt !== undefined && settings.sendAt !== '') {
54
- update.sendAt = normalizeSendAt(settings.sendAt);
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 };