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
@@ -51,6 +51,41 @@ async function resolveFieldIds() {
51
51
  }
52
52
  }
53
53
 
54
+ // Cached segment name → SendGrid segment ID map
55
+ let _segmentIdCache = null;
56
+
57
+ /**
58
+ * Fetch segment definitions from SendGrid and build a name → id map.
59
+ * Segments are created by OMEGA with names matching the SSOT keys in constants.js.
60
+ * Cached in memory for the lifetime of the process.
61
+ *
62
+ * @returns {object} Map of segment name → SendGrid segment ID
63
+ */
64
+ async function resolveSegmentIds() {
65
+ if (_segmentIdCache) {
66
+ return _segmentIdCache;
67
+ }
68
+
69
+ try {
70
+ const data = await fetch(`${BASE_URL}/marketing/segments/2.0`, {
71
+ response: 'json',
72
+ headers: headers(),
73
+ timeout: 10000,
74
+ });
75
+
76
+ _segmentIdCache = {};
77
+
78
+ for (const segment of (data.results || [])) {
79
+ _segmentIdCache[segment.name] = segment.id;
80
+ }
81
+
82
+ return _segmentIdCache;
83
+ } catch (e) {
84
+ console.error('SendGrid resolveSegmentIds error:', e);
85
+ return {};
86
+ }
87
+ }
88
+
54
89
  // --- Contact Management ---
55
90
 
56
91
  /**
@@ -211,7 +246,7 @@ async function getListId() {
211
246
  * @param {object} [options.dynamicTemplateData] - Template variables
212
247
  * @returns {{ success: boolean, id?: string, error?: string }}
213
248
  */
214
- async function createSingleSend({ name, subject, templateId, from, sendTo, asmGroupId, categories, dynamicTemplateData }) {
249
+ async function createSingleSend({ name, subject, preheader, templateId, from, sendTo, excludeSegments, asmGroupId, categories, dynamicTemplateData }) {
215
250
  try {
216
251
  const body = {
217
252
  name,
@@ -224,6 +259,10 @@ async function createSingleSend({ name, subject, templateId, from, sendTo, asmGr
224
259
  },
225
260
  };
226
261
 
262
+ if (preheader) {
263
+ body.email_config.html_content = `<span style="display:none">${preheader}</span>`;
264
+ }
265
+
227
266
  // Use design_editor with template
228
267
  if (templateId) {
229
268
  body.email_config.editor = 'design';
@@ -245,6 +284,17 @@ async function createSingleSend({ name, subject, templateId, from, sendTo, asmGr
245
284
  body.email_config.categories = categories;
246
285
  }
247
286
 
287
+ // Exclude segments from targeting
288
+ if (excludeSegments && excludeSegments.length) {
289
+ body.send_to = body.send_to || {};
290
+ body.send_to.exclude_segment_ids = excludeSegments;
291
+ }
292
+
293
+ // Dynamic template variables
294
+ if (dynamicTemplateData && Object.keys(dynamicTemplateData).length) {
295
+ body.email_config.dynamic_template_data = dynamicTemplateData;
296
+ }
297
+
248
298
  const data = await fetch(`${BASE_URL}/marketing/singlesends`, {
249
299
  method: 'post',
250
300
  response: 'json',
@@ -423,10 +473,138 @@ async function buildFields(userDoc) {
423
473
  return fields;
424
474
  }
425
475
 
476
+ /**
477
+ * Get all contact emails in a segment (handles async export + download).
478
+ *
479
+ * @param {string} segmentId - SendGrid segment ID
480
+ * @param {number} [maxWaitMs=60000] - Max time to wait for export
481
+ * @returns {{ success: boolean, contacts?: Array<{email: string, id: string}>, error?: string }}
482
+ */
483
+ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
484
+ try {
485
+ // Start export job
486
+ const exportData = await fetch(`${BASE_URL}/marketing/contacts/exports`, {
487
+ method: 'post',
488
+ response: 'json',
489
+ headers: headers(),
490
+ timeout: 15000,
491
+ body: { segment_ids: [segmentId] },
492
+ });
493
+
494
+ if (!exportData.id) {
495
+ return { success: false, error: 'Failed to start export' };
496
+ }
497
+
498
+ console.log(`SendGrid getSegmentContacts: Export started (job: ${exportData.id})`);
499
+
500
+ // Poll for completion
501
+ const startTime = Date.now();
502
+ let pollCount = 0;
503
+
504
+ while (Date.now() - startTime < maxWaitMs) {
505
+ await new Promise(r => setTimeout(r, 3000));
506
+ pollCount++;
507
+
508
+ const statusData = await fetch(`${BASE_URL}/marketing/contacts/exports/${exportData.id}`, {
509
+ response: 'json',
510
+ headers: headers(),
511
+ timeout: 10000,
512
+ });
513
+
514
+ console.log(`SendGrid getSegmentContacts: Poll #${pollCount} — ${statusData.status} (${Date.now() - startTime}ms)`);
515
+
516
+ if (statusData.status === 'ready' && statusData.urls?.length) {
517
+ // Download CSV — disable cacheBreaker to preserve presigned S3 URL signature
518
+ const csvText = await fetch(statusData.urls[0], {
519
+ response: 'text',
520
+ timeout: 30000,
521
+ cacheBreaker: false,
522
+ });
523
+
524
+ // Parse CSV — first line is headers, find email and id columns
525
+ const lines = csvText.trim().split('\n');
526
+
527
+ if (lines.length < 2) {
528
+ return { success: true, contacts: [] };
529
+ }
530
+
531
+ const headerCols = lines[0].split(',').map(h => h.replace(/"/g, '').trim().toLowerCase());
532
+ const emailIdx = headerCols.indexOf('email');
533
+ const idIdx = headerCols.indexOf('contact_id');
534
+
535
+ const contacts = [];
536
+
537
+ for (let i = 1; i < lines.length; i++) {
538
+ const cols = lines[i].split(',');
539
+ const email = cols[emailIdx]?.replace(/"/g, '').trim();
540
+ const id = cols[idIdx]?.replace(/"/g, '').trim();
541
+
542
+ if (email) {
543
+ contacts.push({ email, id });
544
+ }
545
+ }
546
+
547
+ console.log(`SendGrid getSegmentContacts: Downloaded ${contacts.length} contacts (${Date.now() - startTime}ms)`);
548
+
549
+ return { success: true, contacts };
550
+ }
551
+
552
+ if (statusData.status === 'failure') {
553
+ console.error('SendGrid getSegmentContacts: Export failed');
554
+ return { success: false, error: 'Export failed' };
555
+ }
556
+ }
557
+
558
+ console.error(`SendGrid getSegmentContacts: Timed out after ${maxWaitMs}ms (${pollCount} polls)`);
559
+ return { success: false, error: 'Export timed out' };
560
+ } catch (e) {
561
+ console.error('SendGrid getSegmentContacts error:', e);
562
+ return { success: false, error: e.message };
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Bulk delete contacts by ID.
568
+ *
569
+ * @param {Array<string>} contactIds - SendGrid contact IDs
570
+ * @returns {{ success: boolean, jobId?: string, error?: string }}
571
+ */
572
+ async function bulkDeleteContacts(contactIds) {
573
+ if (!contactIds.length) {
574
+ return { success: true, jobId: null };
575
+ }
576
+
577
+ try {
578
+ // SendGrid accepts up to 100 IDs per request
579
+ const ids = contactIds.slice(0, 100).join(',');
580
+ const data = await fetch(`${BASE_URL}/marketing/contacts?ids=${ids}`, {
581
+ method: 'delete',
582
+ response: 'json',
583
+ headers: headers(),
584
+ timeout: 15000,
585
+ });
586
+
587
+ if (data.job_id) {
588
+ return { success: true, jobId: data.job_id };
589
+ }
590
+
591
+ return { success: false, error: data.errors?.[0]?.message || 'Delete failed' };
592
+ } catch (e) {
593
+ console.error('SendGrid bulkDeleteContacts error:', e);
594
+ return { success: false, error: e.message };
595
+ }
596
+ }
597
+
426
598
  module.exports = {
599
+ // Resolution
600
+ resolveFieldIds,
601
+ resolveSegmentIds,
602
+
427
603
  // Contacts
428
604
  addContact,
429
605
  removeContact,
606
+ getSegmentContacts,
607
+ bulkDeleteContacts,
430
608
  buildFields,
431
609
 
432
610
  // Campaigns (Single Sends)
@@ -30,6 +30,7 @@ const {
30
30
  encode,
31
31
  errorWithCode,
32
32
  } = require('../constants.js');
33
+ const { tagLinks } = require('../utm.js');
33
34
 
34
35
  function Transactional(assistant) {
35
36
  const self = this;
@@ -195,6 +196,22 @@ Transactional.prototype.build = async function (settings) {
195
196
  settings.data.email.body = md.render(settings.data.email.body);
196
197
  }
197
198
 
199
+ // Tag links with UTM params for attribution
200
+ const utmOptions = {
201
+ brandUrl: brand?.url,
202
+ brandId: brand?.id,
203
+ campaign: settings.sender || templateId,
204
+ type: 'transactional',
205
+ utm: settings.utm,
206
+ };
207
+
208
+ if (settings?.data?.body?.message) {
209
+ settings.data.body.message = tagLinks(settings.data.body.message, utmOptions);
210
+ }
211
+ if (settings?.data?.email?.body) {
212
+ settings.data.email.body = tagLinks(settings.data.email.body, utmOptions);
213
+ }
214
+
198
215
  // Build dynamic template data
199
216
  const dynamicTemplateData = {
200
217
  email: {
@@ -0,0 +1,116 @@
1
+ /**
2
+ * UTM link tagging for email HTML content
3
+ *
4
+ * Scans HTML for <a href> tags pointing to the brand's domain
5
+ * and appends UTM parameters for attribution tracking.
6
+ *
7
+ * Used by: marketing/index.js (campaigns), transactional/index.js (emails)
8
+ */
9
+
10
+ const DEFAULT_UTM = {
11
+ utm_source: null, // Defaults to brand.id at runtime
12
+ utm_medium: 'email',
13
+ utm_campaign: null, // Defaults to campaign name or email template
14
+ };
15
+
16
+ /**
17
+ * Append UTM parameters to all links matching the brand's domain(s).
18
+ *
19
+ * @param {string} html - HTML content with <a href="..."> links
20
+ * @param {object} options
21
+ * @param {string} options.brandUrl - Brand URL (e.g., 'https://somiibo.com')
22
+ * @param {string} options.brandId - Brand ID (e.g., 'somiibo') — used as default utm_source
23
+ * @param {string} [options.campaign] - Campaign/template name — used as default utm_campaign
24
+ * @param {string} [options.type] - 'marketing' or 'transactional' — used as utm_content
25
+ * @param {object} [options.utm] - Override/additional UTM params (e.g., { utm_term: 'spring' })
26
+ * @returns {string} HTML with UTM params appended to matching links
27
+ */
28
+ function tagLinks(html, options) {
29
+ if (!html || !options.brandUrl) {
30
+ return html;
31
+ }
32
+
33
+ // Extract brand hostname(s) to match against
34
+ const brandHostnames = extractHostnames(options.brandUrl);
35
+
36
+ if (!brandHostnames.length) {
37
+ return html;
38
+ }
39
+
40
+ // Build UTM params
41
+ const utm = {
42
+ ...DEFAULT_UTM,
43
+ utm_source: options.brandId || DEFAULT_UTM.utm_source,
44
+ utm_campaign: options.campaign || DEFAULT_UTM.utm_campaign,
45
+ utm_content: options.type || undefined,
46
+ ...options.utm,
47
+ };
48
+
49
+ // Remove null/undefined values
50
+ const utmParams = {};
51
+
52
+ for (const [key, value] of Object.entries(utm)) {
53
+ if (value != null && value !== '') {
54
+ utmParams[key] = String(value);
55
+ }
56
+ }
57
+
58
+ if (!Object.keys(utmParams).length) {
59
+ return html;
60
+ }
61
+
62
+ // Replace href values in <a> tags
63
+ return html.replace(/<a\s([^>]*?)href=["']([^"']+)["']/gi, (match, before, href) => {
64
+ try {
65
+ const url = new URL(href);
66
+
67
+ // Only tag links to the brand's domain
68
+ if (!brandHostnames.includes(url.hostname.toLowerCase())) {
69
+ return match;
70
+ }
71
+
72
+ // Append UTM params (don't override existing ones)
73
+ for (const [key, value] of Object.entries(utmParams)) {
74
+ if (!url.searchParams.has(key)) {
75
+ url.searchParams.set(key, value);
76
+ }
77
+ }
78
+
79
+ return `<a ${before}href="${url.toString()}"`;
80
+ } catch (e) {
81
+ // Not a valid URL (relative path, mailto:, etc.) — skip
82
+ return match;
83
+ }
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Extract hostnames from a brand URL.
89
+ * Returns the base domain + www variant.
90
+ *
91
+ * @param {string} brandUrl
92
+ * @returns {string[]}
93
+ */
94
+ function extractHostnames(brandUrl) {
95
+ try {
96
+ const url = new URL(brandUrl);
97
+ const hostname = url.hostname.toLowerCase();
98
+ const hostnames = [hostname];
99
+
100
+ // Add www variant
101
+ if (hostname.startsWith('www.')) {
102
+ hostnames.push(hostname.slice(4));
103
+ } else {
104
+ hostnames.push(`www.${hostname}`);
105
+ }
106
+
107
+ return hostnames;
108
+ } catch (e) {
109
+ return [];
110
+ }
111
+ }
112
+
113
+ module.exports = {
114
+ tagLinks,
115
+ DEFAULT_UTM,
116
+ };
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Push notification library — send FCM notifications to subscribers
3
+ *
4
+ * Usage:
5
+ * const notification = require('./libraries/notification.js');
6
+ * await notification.send(assistant, { title, body, icon, clickAction, filters });
7
+ *
8
+ * Used by:
9
+ * - POST /admin/notification route
10
+ * - marketing-campaigns cron job (type: 'push')
11
+ */
12
+ const PATH_NOTIFICATIONS = 'notifications';
13
+ const BAD_TOKEN_REASONS = [
14
+ 'messaging/invalid-registration-token',
15
+ 'messaging/registration-token-not-registered',
16
+ ];
17
+ const BATCH_SIZE = 500;
18
+
19
+ /**
20
+ * Send push notification to FCM subscribers.
21
+ *
22
+ * @param {object} assistant - BEM assistant instance
23
+ * @param {object} options
24
+ * @param {string} options.title - Notification title
25
+ * @param {string} options.body - Notification body
26
+ * @param {string} [options.icon] - Notification icon URL
27
+ * @param {string} [options.clickAction] - URL to open on click
28
+ * @param {object} [options.filters] - Targeting filters
29
+ * @param {Array<string>} [options.filters.tags] - Filter by tags
30
+ * @param {string} [options.filters.owner] - Filter by owner UID
31
+ * @param {string} [options.filters.token] - Send to specific token
32
+ * @param {number} [options.filters.limit] - Max tokens to send to
33
+ * @returns {{ subscribers: number, batches: number, sent: number, deleted: number }}
34
+ */
35
+ async function send(assistant, options) {
36
+ const { title, body, icon, clickAction, filters } = options;
37
+
38
+ if (!title || !body) {
39
+ throw new Error('Notification title and body are required');
40
+ }
41
+
42
+ // Build notification payload
43
+ const notification = {
44
+ title,
45
+ body,
46
+ imageUrl: icon
47
+ || 'https://cdn.itwcreativeworks.com/assets/itw-creative-works/images/socials/itw-creative-works-brandmark-square-black-1024x1024.png',
48
+ click_action: clickAction || 'https://itwcreativeworks.com',
49
+ };
50
+
51
+ // Add cache buster to click_action URL
52
+ try {
53
+ const url = new URL(notification.click_action);
54
+ url.searchParams.set('cb', new Date().getTime());
55
+ notification.click_action = url.toString();
56
+ } catch (e) {
57
+ throw new Error(`Invalid click_action URL: ${e.message}`);
58
+ }
59
+
60
+ assistant.log('notification.send():', notification);
61
+
62
+ const response = { subscribers: 0, batches: 0, sent: 0, deleted: 0 };
63
+ const filterOptions = {
64
+ tags: filters?.tags || false,
65
+ owner: filters?.owner || null,
66
+ token: filters?.token || null,
67
+ limit: filters?.limit || null,
68
+ };
69
+
70
+ await processTokens(assistant, notification, filterOptions, response);
71
+
72
+ return response;
73
+ }
74
+
75
+ async function processTokens(assistant, notification, options, response) {
76
+ const Manager = assistant.Manager;
77
+
78
+ // Specific token — send directly
79
+ if (options.token) {
80
+ assistant.log(`Sending to specific token: ${options.token}`);
81
+
82
+ try {
83
+ await sendBatch(assistant, [options.token], 0, notification, response);
84
+ } catch (e) {
85
+ assistant.error('Error sending to specific token', e);
86
+ }
87
+
88
+ return;
89
+ }
90
+
91
+ // Build query conditions
92
+ const queryConditions = [];
93
+
94
+ if (options.tags) {
95
+ queryConditions.push({ field: 'tags', operator: 'array-contains-any', value: options.tags });
96
+ }
97
+ if (options.owner) {
98
+ queryConditions.push({ field: 'owner', operator: '==', value: options.owner });
99
+ }
100
+
101
+ const maxBatches = options.limit
102
+ ? Math.ceil(options.limit / BATCH_SIZE)
103
+ : Infinity;
104
+
105
+ assistant.log('Processing tokens with filters:', {
106
+ tags: options.tags,
107
+ owner: options.owner,
108
+ limit: options.limit,
109
+ maxBatches,
110
+ });
111
+
112
+ let tokensProcessed = 0;
113
+
114
+ await Manager.Utilities().iterateCollection(
115
+ async (batch, index) => {
116
+ let batchTokens = [];
117
+
118
+ for (const doc of batch.docs) {
119
+ if (options.limit && tokensProcessed >= options.limit) {
120
+ break;
121
+ }
122
+
123
+ const data = doc.data();
124
+ batchTokens.push(data.token);
125
+ tokensProcessed++;
126
+ }
127
+
128
+ if (batchTokens.length === 0) {
129
+ return;
130
+ }
131
+
132
+ try {
133
+ assistant.log(`Sending batch ${index} with ${batchTokens.length} tokens.`);
134
+ await sendBatch(assistant, batchTokens, index, notification, response);
135
+ } catch (e) {
136
+ assistant.error(`Error sending batch ${index}`, e);
137
+ }
138
+ },
139
+ {
140
+ collection: PATH_NOTIFICATIONS,
141
+ where: queryConditions,
142
+ batchSize: BATCH_SIZE,
143
+ maxBatches,
144
+ log: true,
145
+ }
146
+ ).catch(e => {
147
+ assistant.error(`Error during token processing: ${e}`);
148
+ });
149
+ }
150
+
151
+ async function sendBatch(assistant, batch, id, notification, response) {
152
+ const { admin } = assistant.Manager.libraries;
153
+
154
+ assistant.log(`Sending batch #${id}: tokens=${batch.length}...`);
155
+
156
+ const messages = batch.map(token => ({
157
+ token,
158
+ notification: {
159
+ title: notification.title,
160
+ body: notification.body,
161
+ imageUrl: notification.imageUrl,
162
+ },
163
+ webpush: {
164
+ notification: {
165
+ title: notification.title,
166
+ body: notification.body,
167
+ icon: notification.imageUrl,
168
+ click_action: notification.click_action,
169
+ },
170
+ data: {
171
+ click_action: notification.click_action,
172
+ },
173
+ fcm_options: {
174
+ link: notification.click_action,
175
+ },
176
+ },
177
+ data: {
178
+ click_action: notification.click_action,
179
+ },
180
+ }));
181
+
182
+ const result = await admin.messaging().sendEach(messages);
183
+
184
+ assistant.log(`Sent batch #${id}: success=${result.successCount}, failures=${result.failureCount}`);
185
+
186
+ result.responses = result.responses.map((item, index) => {
187
+ item.token = batch[index];
188
+ return item;
189
+ });
190
+
191
+ // Clean bad tokens
192
+ if (result.failureCount > 0) {
193
+ await cleanTokens(assistant, batch, result.responses, id, response);
194
+ }
195
+
196
+ response.sent += (batch.length - result.failureCount);
197
+ response.batches++;
198
+ }
199
+
200
+ async function cleanTokens(assistant, batch, results, id, response) {
201
+ const { admin } = assistant.Manager.libraries;
202
+
203
+ const cleanPromises = results
204
+ .map((item) => {
205
+ if (!item.error || !BAD_TOKEN_REASONS.includes(item?.error?.code)) {
206
+ return null;
207
+ }
208
+
209
+ return admin.firestore().doc(`${PATH_NOTIFICATIONS}/${item.token}`).delete()
210
+ .then(() => {
211
+ assistant.log(`Deleted bad token: ${item.token} (${item.error.code})`);
212
+ response.deleted++;
213
+ })
214
+ .catch((e) => {
215
+ assistant.error(`Failed to delete bad token: ${item.token}`, e);
216
+ });
217
+ })
218
+ .filter(Boolean);
219
+
220
+ await Promise.all(cleanPromises);
221
+ }
222
+
223
+ module.exports = { send };