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
@@ -1,262 +1,37 @@
1
1
  /**
2
2
  * POST /admin/notification - Send FCM push notification
3
- * Admin-only endpoint to send push notifications
3
+ * Admin-only endpoint to send push notifications.
4
+ * Uses shared notification library (also used by marketing-campaigns cron).
4
5
  */
5
- const PATH_NOTIFICATIONS = 'notifications';
6
- const BAD_TOKEN_REASONS = [
7
- 'messaging/invalid-registration-token',
8
- 'messaging/registration-token-not-registered',
9
- ];
10
- const BATCH_SIZE = 500;
6
+ const notification = require('../../../libraries/notification.js');
11
7
 
12
8
  module.exports = async ({ assistant, user, settings, analytics }) => {
13
- // Require authentication
14
9
  if (!user.authenticated) {
15
10
  return assistant.respond('Authentication required', { code: 401 });
16
11
  }
17
-
18
- // Require admin
19
12
  if (!user.roles.admin) {
20
13
  return assistant.respond('Admin required.', { code: 403 });
21
14
  }
22
15
 
23
- // Set up response tracking
24
- const response = {
25
- subscribers: 0,
26
- batches: 0,
27
- sent: 0,
28
- deleted: 0,
29
- };
30
-
31
- // Validate required fields
32
16
  if (!settings.notification.title || !settings.notification.body) {
33
17
  return assistant.respond('Parameters <title> and <body> required', { code: 400 });
34
18
  }
35
19
 
36
- // Build notification payload
37
- const notification = {
20
+ const result = await notification.send(assistant, {
38
21
  title: settings.notification.title,
39
22
  body: settings.notification.body,
40
- imageUrl: settings.notification.icon
41
- || 'https://cdn.itwcreativeworks.com/assets/itw-creative-works/images/socials/itw-creative-works-brandmark-square-black-1024x1024.png',
42
- click_action: settings.notification.clickAction
43
- || settings.notification.click_action
44
- || 'https://itwcreativeworks.com',
45
- };
46
-
47
- // Add cache buster to click_action URL
48
- try {
49
- const url = new URL(notification.click_action);
50
- url.searchParams.set('cb', new Date().getTime());
51
- notification.click_action = url.toString();
52
- } catch (e) {
53
- return assistant.respond(`Failed to add cb to URL: ${e}`, { code: 400 });
54
- }
55
-
56
- assistant.log('Resolved notification payload', notification);
57
-
58
- // Build filter options
59
- const filterOptions = {
60
- tags: settings.filters.tags || false,
61
- owner: settings.filters.owner || null,
62
- token: settings.filters.token || null,
63
- limit: settings.filters.limit || null,
64
- };
65
-
66
- // Process tokens and send notifications
67
- await processTokens(assistant, notification, filterOptions, response);
68
-
69
- // Track analytics
70
- analytics.event('admin/notification', { sent: response.sent });
71
-
72
- return assistant.respond(response);
73
- };
74
-
75
- // Helper: Process tokens and send notifications
76
- async function processTokens(assistant, notification, options, response) {
77
- const Manager = assistant.Manager;
78
-
79
- // If a specific token is provided, send directly to it (useful for testing)
80
- if (options.token) {
81
- assistant.log(`Sending to specific token: ${options.token}`);
82
-
83
- try {
84
- await sendBatch(assistant, [options.token], 0, notification, response);
85
- assistant.log('Single token notification sent successfully.');
86
- } catch (e) {
87
- assistant.error('Error sending to specific token', e);
88
- }
89
-
90
- return;
91
- }
92
-
93
- // Build query conditions
94
- const queryConditions = [];
95
-
96
- // Filter by tags
97
- if (options.tags) {
98
- queryConditions.push({ field: 'tags', operator: 'array-contains-any', value: options.tags });
99
- }
100
-
101
- // Filter by owner UID
102
- if (options.owner) {
103
- queryConditions.push({ field: 'owner', operator: '==', value: options.owner });
104
- }
105
-
106
- // Calculate max batches based on limit
107
- const maxBatches = options.limit
108
- ? Math.ceil(options.limit / BATCH_SIZE)
109
- : Infinity;
110
-
111
- // Log filter options
112
- assistant.log('Processing tokens with filters:', {
113
- tags: options.tags,
114
- owner: options.owner,
115
- limit: options.limit,
116
- maxBatches: maxBatches,
117
- });
118
-
119
- // Track tokens processed for limit
120
- let tokensProcessed = 0;
121
-
122
- // Batch processing logic
123
- await Manager.Utilities().iterateCollection(
124
- async (batch, index) => {
125
- let batchTokens = [];
126
-
127
- // Collect tokens from the current batch
128
- for (const doc of batch.docs) {
129
- // Stop if we've hit the limit
130
- if (options.limit && tokensProcessed >= options.limit) {
131
- break;
132
- }
133
-
134
- const data = doc.data();
135
- batchTokens.push(data.token);
136
- tokensProcessed++;
137
- }
138
-
139
- // Skip if no tokens to send
140
- if (batchTokens.length === 0) {
141
- return;
142
- }
143
-
144
- // Send the batch
145
- try {
146
- assistant.log(`Sending batch ${index} with ${batchTokens.length} tokens.`);
147
- await sendBatch(assistant, batchTokens, index, notification, response);
148
- } catch (e) {
149
- assistant.error(`Error sending batch ${index}`, e);
150
- }
23
+ icon: settings.notification.icon,
24
+ clickAction: settings.notification.clickAction
25
+ || settings.notification.click_action,
26
+ filters: {
27
+ tags: settings.filters.tags || false,
28
+ owner: settings.filters.owner || null,
29
+ token: settings.filters.token || null,
30
+ limit: settings.filters.limit || null,
151
31
  },
152
- {
153
- collection: PATH_NOTIFICATIONS,
154
- where: queryConditions,
155
- batchSize: BATCH_SIZE,
156
- maxBatches: maxBatches,
157
- log: true,
158
- }
159
- )
160
- .then(() => {
161
- assistant.log('All batches processed successfully.');
162
- })
163
- .catch(e => {
164
- assistant.error(`Error during token processing: ${e}`);
165
- });
166
- }
167
-
168
- // Helper: Send batch of notifications
169
- async function sendBatch(assistant, batch, id, notification, response) {
170
- const { admin } = assistant.Manager.libraries;
171
- try {
172
- assistant.log(`Sending batch #${id}: tokens=${batch.length}...`, notification);
173
-
174
- // Prepare messages
175
- const messages = batch.map(token => ({
176
- token: token,
177
- notification: {
178
- title: notification.title,
179
- body: notification.body,
180
- imageUrl: notification.imageUrl,
181
- },
182
- webpush: {
183
- notification: {
184
- title: notification.title,
185
- body: notification.body,
186
- icon: notification.imageUrl,
187
- click_action: notification.click_action,
188
- },
189
- data: {
190
- click_action: notification.click_action,
191
- },
192
- fcm_options: {
193
- link: notification.click_action,
194
- }
195
- },
196
- data: {
197
- click_action: notification.click_action,
198
- },
199
- }));
200
-
201
- // Send the batch
202
- const result = await admin.messaging().sendEach(messages);
203
-
204
- assistant.log(`Sent batch #${id}: tokens=${batch.length}, success=${result.successCount}, failures=${result.failureCount}`, JSON.stringify(result));
205
-
206
- // Attach token to response
207
- result.responses = result.responses.map((item, index) => {
208
- item.token = batch[index];
209
- return item;
210
- });
211
-
212
- // Clean bad tokens
213
- if (result.failureCount > 0) {
214
- await cleanTokens(assistant, batch, result.responses, id, response);
215
- }
216
-
217
- // Update response
218
- response.sent += (batch.length - result.failureCount);
219
- } catch (e) {
220
- throw new Error(`Error sending batch ${id}: ${e}`);
221
- }
222
- }
223
-
224
- // Helper: Clean bad tokens
225
- async function cleanTokens(assistant, batch, results, id, response) {
226
- assistant.log(`Cleaning ${results.length} tokens of batch ID: ${id}`);
227
-
228
- const cleanPromises = results
229
- .map((item, index) => {
230
- const shouldClean = BAD_TOKEN_REASONS.includes(item?.error?.code);
231
-
232
- assistant.log(`Checking #${index}: success=${item.success}, error=${item?.error?.code || null}, clean=${shouldClean}`, item.error);
233
-
234
- if (!item.error || !shouldClean) {
235
- return null;
236
- }
237
-
238
- return deleteToken(assistant, item.token, item.error.code, response);
239
- })
240
- .filter(Boolean);
241
-
242
- try {
243
- await Promise.all(cleanPromises);
244
- assistant.log(`Completed cleaning tokens for batch ID: ${id}`);
245
- } catch (e) {
246
- assistant.error(`Error cleaning tokens for batch ID: ${id}`, e);
247
- }
248
- }
249
-
250
- // Helper: Delete bad token
251
- async function deleteToken(assistant, token, errorCode, response) {
252
- const { admin } = assistant.Manager.libraries;
253
- try {
254
- await admin.firestore().doc(`${PATH_NOTIFICATIONS}/${token}`).delete();
32
+ });
255
33
 
256
- assistant.log(`Deleted bad token: ${token} (Reason: ${errorCode})`);
34
+ analytics.event('admin/notification', { sent: result.sent });
257
35
 
258
- response.deleted++;
259
- } catch (error) {
260
- assistant.error(`Failed to delete bad token: ${token} (Reason: ${errorCode})`, error);
261
- }
262
- }
36
+ return assistant.respond(result);
37
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * DELETE /marketing/campaign - Delete a marketing campaign
3
+ * Admin-only. Can only delete pending campaigns.
4
+ */
5
+ module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
6
+
7
+ if (!user.authenticated) {
8
+ return assistant.respond('Authentication required', { code: 401 });
9
+ }
10
+ if (!user.roles.admin) {
11
+ return assistant.respond('Admin access required', { code: 403 });
12
+ }
13
+
14
+ const { admin } = Manager.libraries;
15
+ const campaignId = (settings.id || '').trim();
16
+
17
+ if (!campaignId) {
18
+ return assistant.respond('Campaign ID is required', { code: 400 });
19
+ }
20
+
21
+ const docRef = admin.firestore().doc(`marketing-campaigns/${campaignId}`);
22
+ const doc = await docRef.get();
23
+
24
+ if (!doc.exists) {
25
+ return assistant.respond('Campaign not found', { code: 404 });
26
+ }
27
+
28
+ const existing = doc.data();
29
+
30
+ // Can only delete pending campaigns (sent/failed are historical records)
31
+ if (existing.status !== 'pending') {
32
+ return assistant.respond(`Cannot delete campaign with status "${existing.status}"`, { code: 400 });
33
+ }
34
+
35
+ await docRef.delete();
36
+
37
+ assistant.log('marketing/campaign deleted:', { campaignId });
38
+
39
+ analytics.event('marketing/campaign', { action: 'delete' });
40
+
41
+ return assistant.respond({
42
+ success: true,
43
+ deleted: campaignId,
44
+ });
45
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * GET /marketing/campaign - List or get marketing campaigns
3
+ * Admin-only. Used by calendar frontend.
4
+ *
5
+ * Query params:
6
+ * id — Get a single campaign by ID
7
+ * start — Filter campaigns with sendAt >= start (unix timestamp)
8
+ * end — Filter campaigns with sendAt <= end (unix timestamp)
9
+ * status — Filter by status (pending, sent, failed)
10
+ * type — Filter by type (email, push)
11
+ * limit — Max results (default 100)
12
+ */
13
+ module.exports = async ({ assistant, user, Manager, settings }) => {
14
+
15
+ if (!user.authenticated) {
16
+ return assistant.respond('Authentication required', { code: 401 });
17
+ }
18
+ if (!user.roles.admin) {
19
+ return assistant.respond('Admin access required', { code: 403 });
20
+ }
21
+
22
+ const { admin } = Manager.libraries;
23
+
24
+ // Single campaign by ID
25
+ if (settings.id) {
26
+ const doc = await admin.firestore().doc(`marketing-campaigns/${settings.id}`).get();
27
+
28
+ if (!doc.exists) {
29
+ return assistant.respond('Campaign not found', { code: 404 });
30
+ }
31
+
32
+ return assistant.respond({
33
+ success: true,
34
+ campaign: { id: doc.id, ...doc.data() },
35
+ });
36
+ }
37
+
38
+ // List campaigns with filters
39
+ let query = admin.firestore().collection('marketing-campaigns');
40
+
41
+ if (settings.status) {
42
+ query = query.where('status', '==', settings.status);
43
+ }
44
+ if (settings.type) {
45
+ query = query.where('type', '==', settings.type);
46
+ }
47
+ if (settings.start) {
48
+ query = query.where('sendAt', '>=', parseInt(settings.start, 10));
49
+ }
50
+ if (settings.end) {
51
+ query = query.where('sendAt', '<=', parseInt(settings.end, 10));
52
+ }
53
+
54
+ query = query.orderBy('sendAt', 'asc');
55
+ query = query.limit(parseInt(settings.limit, 10) || 100);
56
+
57
+ const snapshot = await query.get();
58
+
59
+ const campaigns = snapshot.docs.map(doc => ({
60
+ id: doc.id,
61
+ ...doc.data(),
62
+ }));
63
+
64
+ return assistant.respond({
65
+ success: true,
66
+ campaigns,
67
+ count: campaigns.length,
68
+ });
69
+ };
@@ -0,0 +1,161 @@
1
+ /**
2
+ * POST /marketing/campaign - Create a marketing campaign
3
+ * Admin-only. Saves to marketing-campaigns collection.
4
+ *
5
+ * - sendAt defaults to 'now' (immediate send)
6
+ * - Future sendAt → saved as 'pending' for cron pickup
7
+ * - Past/now sendAt → fires immediately, saved as 'sent'/'failed'
8
+ * - Supports type: 'email' (default) or 'push' (future)
9
+ *
10
+ * Content is markdown — converted to HTML at send time per provider.
11
+ */
12
+ const _ = require('lodash');
13
+ const moment = require('moment');
14
+ const pushid = require('pushid');
15
+
16
+ module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
17
+
18
+ if (!user.authenticated) {
19
+ return assistant.respond('Authentication required', { code: 401 });
20
+ }
21
+ if (!user.roles.admin) {
22
+ return assistant.respond('Admin access required', { code: 403 });
23
+ }
24
+
25
+ const { admin } = Manager.libraries;
26
+ const campaignId = settings.id || pushid();
27
+ const now = moment();
28
+
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';
69
+
70
+ const doc = {
71
+ settings: settingsCloned,
72
+ sendAt,
73
+ status: 'pending',
74
+ type,
75
+ ...(settings.recurrence ? { recurrence: settings.recurrence } : {}),
76
+ metadata: {
77
+ created: {
78
+ timestamp: now.toISOString(),
79
+ timestampUNIX: now.unix(),
80
+ },
81
+ updated: {
82
+ timestamp: now.toISOString(),
83
+ timestampUNIX: now.unix(),
84
+ },
85
+ },
86
+ };
87
+
88
+ // Save to Firestore
89
+ await admin.firestore().doc(`marketing-campaigns/${campaignId}`).set(doc);
90
+
91
+ assistant.log('marketing/campaign created:', { campaignId, sendAt, isFuture, type });
92
+
93
+ // If sendAt is now/past, fire immediately
94
+ let results = null;
95
+
96
+ if (!isFuture && type === 'email') {
97
+ const mailer = Manager.Email(assistant);
98
+ results = await mailer.sendCampaign({ ...campaignSettings, sendAt: 'now' });
99
+
100
+ // Update status
101
+ const status = Object.values(results).some(r => r.success) ? 'sent' : 'failed';
102
+
103
+ await admin.firestore().doc(`marketing-campaigns/${campaignId}`).set({
104
+ status,
105
+ results,
106
+ metadata: {
107
+ updated: {
108
+ timestamp: new Date().toISOString(),
109
+ timestampUNIX: Math.round(Date.now() / 1000),
110
+ },
111
+ },
112
+ }, { merge: true });
113
+
114
+ assistant.log('marketing/campaign sent:', { campaignId, status, results });
115
+ }
116
+
117
+ // Analytics
118
+ analytics.event('marketing/campaign', {
119
+ action: isFuture ? 'schedule' : 'send',
120
+ type,
121
+ });
122
+
123
+ return assistant.respond({
124
+ success: true,
125
+ id: campaignId,
126
+ status: isFuture ? 'pending' : (results ? 'sent' : 'pending'),
127
+ sendAt,
128
+ providers: results,
129
+ });
130
+ };
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
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * PUT /marketing/campaign - Update a marketing campaign
3
+ * Admin-only. Used by calendar frontend for edits and rescheduling.
4
+ *
5
+ * Accepts any field from the POST schema. Only provided fields are updated.
6
+ * Changing sendAt reschedules the campaign (if still pending).
7
+ */
8
+ const _ = require('lodash');
9
+ const moment = require('moment');
10
+
11
+ module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
12
+
13
+ if (!user.authenticated) {
14
+ return assistant.respond('Authentication required', { code: 401 });
15
+ }
16
+ if (!user.roles.admin) {
17
+ return assistant.respond('Admin access required', { code: 403 });
18
+ }
19
+
20
+ const { admin } = Manager.libraries;
21
+ const campaignId = (settings.id || '').trim();
22
+
23
+ if (!campaignId) {
24
+ return assistant.respond('Campaign ID is required', { code: 400 });
25
+ }
26
+
27
+ // Fetch existing
28
+ const docRef = admin.firestore().doc(`marketing-campaigns/${campaignId}`);
29
+ const doc = await docRef.get();
30
+
31
+ if (!doc.exists) {
32
+ return assistant.respond('Campaign not found', { code: 404 });
33
+ }
34
+
35
+ const existing = doc.data();
36
+
37
+ // Can only edit pending campaigns
38
+ if (existing.status !== 'pending') {
39
+ return assistant.respond(`Cannot edit campaign with status "${existing.status}"`, { code: 400 });
40
+ }
41
+
42
+ // Build update — merge provided fields into existing settings
43
+ const update = {
44
+ metadata: {
45
+ updated: {
46
+ timestamp: new Date().toISOString(),
47
+ timestampUNIX: Math.round(Date.now() / 1000),
48
+ },
49
+ },
50
+ };
51
+
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 };
90
+ }
91
+
92
+ await docRef.set(update, { merge: true });
93
+
94
+ assistant.log('marketing/campaign updated:', { campaignId, update });
95
+
96
+ analytics.event('marketing/campaign', { action: 'update' });
97
+
98
+ // Fetch updated doc
99
+ const updated = await docRef.get();
100
+
101
+ return assistant.respond({
102
+ success: true,
103
+ campaign: { id: campaignId, ...updated.data() },
104
+ });
105
+ };
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
+ }
@@ -48,6 +48,8 @@ function sendCancellationEmail(assistant, user, requestId) {
48
48
  const Manager = assistant.Manager;
49
49
  const mailer = Manager.Email(assistant);
50
50
  const uid = user.auth.uid;
51
+ const firstName = user.personal?.name?.first;
52
+ const greeting = firstName ? `Hey ${firstName}, your` : 'Your';
51
53
 
52
54
  mailer.send({
53
55
  to: user,
@@ -62,7 +64,7 @@ function sendCancellationEmail(assistant, user, requestId) {
62
64
  },
63
65
  body: {
64
66
  title: 'Data Request Cancelled',
65
- message: `Your data export request has been cancelled as requested.
67
+ message: `${greeting} data export request has been cancelled as requested.
66
68
 
67
69
  - **Request reference:** #${requestId}
68
70
  - **Account UID:** ${uid}