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.
- package/CHANGELOG.md +41 -0
- package/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/src/cli/commands/setup-tests/firestore-indexes-required.js +1 -1
- package/src/cli/commands/setup-tests/functions-package.js +3 -1
- package/src/cli/commands/setup-tests/{required-indexes.js → helpers/required-indexes.js} +22 -0
- package/src/cli/commands/setup-tests/helpers/seed-campaigns.js +132 -0
- package/src/cli/commands/setup-tests/index.js +2 -0
- package/src/cli/commands/setup-tests/marketing-campaigns-seeded.js +109 -0
- package/src/manager/cron/daily/marketing-prune.js +140 -0
- package/src/manager/cron/frequent/marketing-campaigns.js +158 -0
- package/src/manager/events/auth/on-create.js +36 -1
- package/src/manager/libraries/email/constants.js +46 -2
- package/src/manager/libraries/email/index.js +13 -3
- package/src/manager/libraries/email/marketing/index.js +205 -33
- package/src/manager/libraries/email/providers/beehiiv.js +90 -0
- package/src/manager/libraries/email/providers/sendgrid.js +179 -1
- package/src/manager/libraries/email/transactional/index.js +17 -0
- package/src/manager/libraries/email/utm.js +116 -0
- package/src/manager/libraries/notification.js +223 -0
- package/src/manager/routes/admin/notification/post.js +16 -241
- package/src/manager/routes/marketing/campaign/delete.js +45 -0
- package/src/manager/routes/marketing/campaign/get.js +69 -0
- package/src/manager/routes/marketing/campaign/post.js +161 -0
- package/src/manager/routes/marketing/campaign/put.js +122 -0
- package/src/manager/routes/user/data-request/delete.js +3 -1
- package/src/manager/routes/user/data-request/get.js +3 -1
- package/src/manager/routes/user/data-request/post.js +3 -1
- package/src/manager/routes/user/delete.js +4 -3
- package/src/manager/routes/user/signup/post.js +10 -8
- package/src/manager/schemas/marketing/campaign/delete.js +6 -0
- package/src/manager/schemas/marketing/campaign/get.js +11 -0
- package/src/manager/schemas/marketing/campaign/post.js +35 -0
- package/src/manager/schemas/marketing/campaign/put.js +35 -0
- 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 };
|