backend-manager 5.0.92 → 5.0.93

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.92",
3
+ "version": "5.0.93",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -140,6 +140,8 @@ async function processPaymentEvent({ category, library, resource, resourceType,
140
140
  const userData = userDoc.exists ? userDoc.data() : {};
141
141
  const before = isSubscription ? (userData.subscription || null) : null;
142
142
 
143
+ assistant.log(`User doc for ${uid}: exists=${userDoc.exists}, email=${userData?.auth?.email || 'null'}, name=${userData?.personal?.name?.first || 'null'}, subscription=${userData?.subscription?.product?.id || 'null'}`);
144
+
143
145
  // Auto-fill user name from payment processor if not already set
144
146
  if (!userData?.personal?.name?.first) {
145
147
  const customerName = extractCustomerName(resource, resourceType);
@@ -194,7 +196,7 @@ async function processPaymentEvent({ category, library, resource, resourceType,
194
196
 
195
197
  if (shouldRunHandlers) {
196
198
  transitions.dispatch(transitionName, category, {
197
- before, after: unified, order, uid, assistant,
199
+ before, after: unified, order, uid, userDoc: userData, assistant,
198
200
  });
199
201
  } else {
200
202
  assistant.log(`Transition handler skipped (testing mode): ${category}/${transitionName}`);
@@ -4,14 +4,15 @@
4
4
  */
5
5
  const { sendOrderEmail, formatDate } = require('../send-email.js');
6
6
 
7
- module.exports = async function ({ before, after, order, uid, assistant }) {
7
+ module.exports = async function ({ before, after, order, uid, userDoc, assistant }) {
8
8
  assistant.log(`Transition [one-time/purchase-completed]: uid=${uid}, resourceId=${after.payment.resourceId}`);
9
9
 
10
10
  sendOrderEmail({
11
- template: 'd-5371ac2b4e3b490bbce51bfc2922ece8',
11
+ template: 'main/order/confirmation',
12
12
  subject: 'Your order is confirmed!',
13
13
  categories: ['order/confirmation'],
14
14
  uid,
15
+ userDoc,
15
16
  assistant,
16
17
  data: {
17
18
  order: {
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * NOTE: No email template exists for this transition yet. Keeping as stub.
6
6
  */
7
- module.exports = async function ({ before, after, order, uid, assistant }) {
7
+ module.exports = async function ({ before, after, order, uid, userDoc, assistant }) {
8
8
  assistant.log(`Transition [one-time/purchase-failed]: uid=${uid}, orderId=${order?.id}`);
9
9
 
10
10
  // TODO: Send payment failure email once template is created
@@ -12,14 +12,24 @@ const moment = require('moment');
12
12
  * @param {string} options.subject - Email subject line
13
13
  * @param {string[]} options.categories - SendGrid categories for filtering
14
14
  * @param {object} options.data - Template data (passed as-is to the email)
15
- * @param {string} options.uid - User UID (resolved from Firestore for email + name)
15
+ * @param {string} options.uid - User UID
16
+ * @param {object} options.userDoc - User document data (used to get email directly, avoids race conditions)
16
17
  * @param {object} options.assistant - Assistant instance
17
18
  */
18
- function sendOrderEmail({ template, subject, categories, data, uid, assistant }) {
19
+ function sendOrderEmail({ template, subject, categories, data, uid, userDoc, assistant }) {
19
20
  const email = assistant.Manager.Email(assistant);
20
21
 
22
+ // Use email directly from userDoc (already fetched by on-write.js, avoids redundant Firestore lookup)
23
+ const userEmail = userDoc?.auth?.email;
24
+ const userName = userDoc?.personal?.name?.first;
25
+
26
+ if (!userEmail) {
27
+ assistant.error(`sendOrderEmail(): No email found for uid=${uid}, skipping`);
28
+ return;
29
+ }
30
+
21
31
  const settings = {
22
- to: `uid:${uid}`,
32
+ to: { email: userEmail, ...(userName && { name: userName }) },
23
33
  subject,
24
34
  template,
25
35
  categories,
@@ -4,14 +4,15 @@
4
4
  */
5
5
  const { sendOrderEmail, formatDate } = require('../send-email.js');
6
6
 
7
- module.exports = async function ({ before, after, order, uid, assistant }) {
7
+ module.exports = async function ({ before, after, order, uid, userDoc, assistant }) {
8
8
  assistant.log(`Transition [subscription/cancellation-requested]: uid=${uid}, product=${after.product.id}, cancelDate=${after.cancellation.date?.timestamp}`);
9
9
 
10
10
  sendOrderEmail({
11
- template: 'd-78074f3e8c844146bf263b86fc8d5ecf',
11
+ template: 'main/order/cancellation-requested',
12
12
  subject: 'Your subscription cancellation is confirmed',
13
13
  categories: ['order/cancellation-requested'],
14
14
  uid,
15
+ userDoc,
15
16
  assistant,
16
17
  data: {
17
18
  order: {
@@ -5,16 +5,17 @@
5
5
  */
6
6
  const { sendOrderEmail, formatDate } = require('../send-email.js');
7
7
 
8
- module.exports = async function ({ before, after, order, uid, assistant }) {
8
+ module.exports = async function ({ before, after, order, uid, userDoc, assistant }) {
9
9
  const isTrial = after.trial?.claimed === true;
10
10
 
11
11
  assistant.log(`Transition [subscription/new-subscription]: uid=${uid}, product=${after.product.id}, frequency=${after.payment.frequency}, trial=${isTrial}`);
12
12
 
13
13
  sendOrderEmail({
14
- template: 'd-5371ac2b4e3b490bbce51bfc2922ece8',
14
+ template: 'main/order/confirmation',
15
15
  subject: isTrial ? 'Your free trial has started!' : 'Your subscription is confirmed!',
16
16
  categories: ['order/confirmation'],
17
17
  uid,
18
+ userDoc,
18
19
  assistant,
19
20
  data: {
20
21
  order: {
@@ -4,14 +4,15 @@
4
4
  */
5
5
  const { sendOrderEmail, formatDate } = require('../send-email.js');
6
6
 
7
- module.exports = async function ({ before, after, order, uid, assistant }) {
7
+ module.exports = async function ({ before, after, order, uid, userDoc, assistant }) {
8
8
  assistant.log(`Transition [subscription/payment-failed]: uid=${uid}, product=${after.product.id}, previousStatus=${before?.status}`);
9
9
 
10
10
  sendOrderEmail({
11
- template: 'd-e56af0ac62364bfb9e50af02854e2cd3',
11
+ template: 'main/order/payment-failed',
12
12
  subject: 'Your payment failed',
13
13
  categories: ['order/payment-failed'],
14
14
  uid,
15
+ userDoc,
15
16
  assistant,
16
17
  data: {
17
18
  order: {
@@ -4,14 +4,15 @@
4
4
  */
5
5
  const { sendOrderEmail, formatDate } = require('../send-email.js');
6
6
 
7
- module.exports = async function ({ before, after, order, uid, assistant }) {
7
+ module.exports = async function ({ before, after, order, uid, userDoc, assistant }) {
8
8
  assistant.log(`Transition [subscription/payment-recovered]: uid=${uid}, product=${after.product.id}`);
9
9
 
10
10
  sendOrderEmail({
11
- template: 'd-d6dbd17a260a4755b34a852ba09c2454',
11
+ template: 'main/order/payment-recovered',
12
12
  subject: 'Your payment was successful',
13
13
  categories: ['order/payment-recovered'],
14
14
  uid,
15
+ userDoc,
15
16
  assistant,
16
17
  data: {
17
18
  order: {
@@ -4,15 +4,16 @@
4
4
  */
5
5
  const { sendOrderEmail, formatDate } = require('../send-email.js');
6
6
 
7
- module.exports = async function ({ before, after, order, uid, assistant }) {
7
+ module.exports = async function ({ before, after, order, uid, userDoc, assistant }) {
8
8
  const direction = after.product.id > before.product.id ? 'upgrade' : 'downgrade';
9
9
  assistant.log(`Transition [subscription/plan-changed]: uid=${uid}, ${before.product.id} → ${after.product.id} (${direction})`);
10
10
 
11
11
  sendOrderEmail({
12
- template: 'd-399086311bbb48b4b77bc90b20fb9d0a',
12
+ template: 'main/order/plan-changed',
13
13
  subject: 'Your subscription plan has been updated',
14
14
  categories: ['order/plan-changed'],
15
15
  uid,
16
+ userDoc,
16
17
  assistant,
17
18
  data: {
18
19
  order: {
@@ -4,17 +4,18 @@
4
4
  */
5
5
  const { sendOrderEmail, formatDate } = require('../send-email.js');
6
6
 
7
- module.exports = async function ({ before, after, order, uid, assistant }) {
7
+ module.exports = async function ({ before, after, order, uid, userDoc, assistant }) {
8
8
  assistant.log(`Transition [subscription/subscription-cancelled]: uid=${uid}, previousProduct=${before?.product?.id}, previousStatus=${before?.status}`);
9
9
 
10
10
  // Check if subscription has a future expiry (e.g., cancelled at period end)
11
11
  const hasFutureExpiry = after.expires?.timestamp && new Date(after.expires.timestamp) > new Date();
12
12
 
13
13
  sendOrderEmail({
14
- template: 'd-39041132e6b24e5ebf0e95bce2d94dba',
14
+ template: 'main/order/cancelled',
15
15
  subject: 'Your subscription has been cancelled',
16
16
  categories: ['order/cancelled'],
17
17
  uid,
18
+ userDoc,
18
19
  assistant,
19
20
  data: {
20
21
  order: {
@@ -12,8 +12,8 @@ module.exports = function (payload, config) {
12
12
  },
13
13
  categories: ['download'],
14
14
  subject: `Free ${config.brand.name} download link for ${payload.name || 'you'}!`,
15
- template: 'd-1d730ac8cc544b7cbccc8fa4a4b3f9ce',
16
- group: 25927,
15
+ template: 'main/misc/app-download-link',
16
+ group: 'marketing',
17
17
  copy: false,
18
18
  data: {},
19
19
  }
@@ -17,6 +17,34 @@ const moment = require('moment');
17
17
  // SendGrid limit for scheduled emails (72 hours, but use 71 for buffer)
18
18
  const SEND_AT_LIMIT = 71;
19
19
 
20
+ // Template shortcut map — callers use readable paths instead of SendGrid IDs
21
+ // Paths mirror the email website structure: {category}/{subcategory}/{name}
22
+ const TEMPLATES = {
23
+ // v1 templates
24
+ 'main/basic/card': 'd-b7f8da3c98ad49a2ad1e187f3a67b546',
25
+ 'main/engagement/feedback': 'd-c1522214c67b47058669acc5a81ed663',
26
+ 'main/misc/app-download-link': 'd-1d730ac8cc544b7cbccc8fa4a4b3f9ce',
27
+
28
+ // v2 templates
29
+ 'main/order/confirmation': 'd-5371ac2b4e3b490bbce51bfc2922ece8',
30
+ 'main/order/payment-failed': 'd-e56af0ac62364bfb9e50af02854e2cd3',
31
+ 'main/order/payment-recovered': 'd-d6dbd17a260a4755b34a852ba09c2454',
32
+ 'main/order/cancellation-requested': 'd-78074f3e8c844146bf263b86fc8d5ecf',
33
+ 'main/order/cancelled': 'd-39041132e6b24e5ebf0e95bce2d94dba',
34
+ 'main/order/plan-changed': 'd-399086311bbb48b4b77bc90b20fb9d0a',
35
+ 'main/order/trial-ending': 'd-af8ab499cbfb4d56918b4118f44343b0',
36
+ };
37
+
38
+ // "default" resolves to the basic card template
39
+ TEMPLATES['default'] = TEMPLATES['main/basic/card'];
40
+
41
+ // Group shortcut map — SendGrid ASM group IDs
42
+ const GROUPS = {
43
+ 'default': 24077,
44
+ 'marketing': 25927,
45
+ 'account': 25928,
46
+ };
47
+
20
48
  function Email(assistant) {
21
49
  const self = this;
22
50
 
@@ -68,7 +96,7 @@ Email.prototype.build = async function (settings) {
68
96
  name: brand.name,
69
97
  url: brand.url,
70
98
  email: brand.contact?.email,
71
- images: brand.images || {},
99
+ images: sanitizeImagesForEmail(brand.images || {}),
72
100
  };
73
101
 
74
102
  if (!app.email) {
@@ -76,7 +104,7 @@ Email.prototype.build = async function (settings) {
76
104
  }
77
105
 
78
106
  // Add carbon copy recipients
79
- if (settings.copy) {
107
+ if (copy) {
80
108
  cc.push({
81
109
  email: app.email,
82
110
  name: app.name,
@@ -116,17 +144,9 @@ Email.prototype.build = async function (settings) {
116
144
  throw errorWithCode('Parameter subject is required', 400);
117
145
  }
118
146
 
119
- const templateId = settings.template;
120
-
121
- if (!templateId && !settings.html) {
122
- throw errorWithCode('Parameter template is required', 400);
123
- }
124
-
125
- const groupId = settings.group;
126
-
127
- if (!groupId) {
128
- throw errorWithCode('Parameter group is required', 400);
129
- }
147
+ const templateId = TEMPLATES[settings.template] || settings.template || TEMPLATES['default'];
148
+ const groupId = GROUPS[settings.group] || settings.group || GROUPS['default'];
149
+ const copy = settings.copy ?? true;
130
150
 
131
151
  // Build categories
132
152
  const categories = _.uniq([
@@ -139,7 +159,7 @@ Email.prototype.build = async function (settings) {
139
159
  const sendAt = normalizeSendAt(settings.sendAt);
140
160
 
141
161
  // Build unsubscribe URL
142
- const unsubscribeUrl = `https://itwcreativeworks.com/portal/account/email-preferences?email=${encode(to[0].email)}&asmId=${encode(groupId)}&templateId=${encode(templateId)}&appName=${app.name}&appUrl=${app.url}`;
162
+ const unsubscribeUrl = `${Manager.project.websiteUrl}/portal/account/email-preferences?email=${encode(to[0].email)}&asmId=${encode(groupId)}&templateId=${encode(templateId)}&appName=${app.name}&appUrl=${app.url}`;
143
163
 
144
164
  // Build signoff
145
165
  const signoff = settings?.data?.signoff || {};
@@ -157,7 +177,7 @@ Email.prototype.build = async function (settings) {
157
177
  const dynamicTemplateData = {
158
178
  email: {
159
179
  id: Manager.require('uuid').v4(),
160
- subject: settings?.data?.email?.subject || null,
180
+ subject: settings?.data?.email?.subject || subject,
161
181
  preview: settings?.data?.email?.preview || null,
162
182
  body: settings?.data?.email?.body || null,
163
183
  unsubscribeUrl,
@@ -165,7 +185,7 @@ Email.prototype.build = async function (settings) {
165
185
  footer: {
166
186
  text: settings?.data?.email?.footer?.text || null,
167
187
  },
168
- carbonCopy: settings.copy,
188
+ carbonCopy: copy,
169
189
  },
170
190
  personalization: {
171
191
  email: to[0].email,
@@ -229,6 +249,8 @@ Email.prototype.send = async function (settings) {
229
249
  const admin = self.admin;
230
250
  const assistant = self.assistant;
231
251
 
252
+ assistant.log(`Email.send(): to=${JSON.stringify(settings.to)}, subject=${settings.subject}, template=${settings.template}`);
253
+
232
254
  // Build email from settings (throws with code: 400 on validation failure)
233
255
  const email = await self.build(settings);
234
256
 
@@ -473,6 +495,24 @@ function errorWithCode(message, code) {
473
495
  return err;
474
496
  }
475
497
 
498
+ /**
499
+ * Convert SVG image URLs to PNG equivalents — email clients don't render SVGs.
500
+ * CDN naming convention: `-x.svg` → `-1024.png`
501
+ */
502
+ function sanitizeImagesForEmail(images) {
503
+ const result = {};
504
+
505
+ for (const [key, value] of Object.entries(images)) {
506
+ if (typeof value === 'string' && value.endsWith('.svg')) {
507
+ result[key] = value.replace(/-x\.svg$/, '-1024.png');
508
+ } else {
509
+ result[key] = value;
510
+ }
511
+ }
512
+
513
+ return result;
514
+ }
515
+
476
516
  /**
477
517
  * URL-encode a value as base64
478
518
  */
@@ -16,8 +16,8 @@ module.exports = function (payload, config) {
16
16
  },
17
17
  categories: ['download'],
18
18
  subject: `Free ${config.brand.name} download link for ${payload.name || 'you'}!`,
19
- template: 'd-1d730ac8cc544b7cbccc8fa4a4b3f9ce',
20
- group: 25927,
19
+ template: 'main/misc/app-download-link',
20
+ group: 'marketing',
21
21
  copy: false,
22
22
  data: {},
23
23
  }
@@ -280,8 +280,8 @@ function sendWelcomeEmail(assistant, email) {
280
280
  to: email,
281
281
  categories: ['account/welcome'],
282
282
  subject: `Welcome to ${Manager.config.brand.name}!`,
283
- template: 'd-b7f8da3c98ad49a2ad1e187f3a67b546',
284
- group: 25928,
283
+ template: 'default',
284
+ group: 'account',
285
285
  copy: false,
286
286
  data: {
287
287
  email: {
@@ -326,8 +326,8 @@ function sendCheckupEmail(assistant, email) {
326
326
  to: email,
327
327
  categories: ['account/checkup'],
328
328
  subject: `How's your experience with ${Manager.config.brand.name}?`,
329
- template: 'd-b7f8da3c98ad49a2ad1e187f3a67b546',
330
- group: 25928,
329
+ template: 'default',
330
+ group: 'account',
331
331
  copy: false,
332
332
  sendAt: moment().add(7, 'days').unix(),
333
333
  data: {
@@ -373,8 +373,8 @@ function sendFeedbackEmail(assistant, email) {
373
373
  to: email,
374
374
  categories: ['engagement/feedback'],
375
375
  subject: `Want to share your feedback about ${Manager.config.brand.name}?`,
376
- template: 'd-c1522214c67b47058669acc5a81ed663',
377
- group: 25928,
376
+ template: 'main/engagement/feedback',
377
+ group: 'account',
378
378
  copy: false,
379
379
  sendAt: moment().add(14, 'days').unix(),
380
380
  })
@@ -14,12 +14,12 @@ module.exports = () => ({
14
14
  from: { types: ['object'], default: undefined },
15
15
  replyTo: { types: ['string'], default: undefined },
16
16
  subject: { types: ['string'], default: undefined },
17
- template: { types: ['string'], default: 'd-b7f8da3c98ad49a2ad1e187f3a67b546' },
18
- group: { types: ['number'], default: 24077 },
17
+ template: { types: ['string'], default: undefined },
18
+ group: { types: ['number'], default: undefined },
19
19
  sendAt: { types: ['number', 'string'], default: undefined },
20
20
  user: { types: ['object'], default: {} },
21
21
  data: { types: ['object'], default: {} },
22
22
  categories: { types: ['array'], default: [] },
23
- copy: { types: ['boolean'], default: true },
23
+ copy: { types: ['boolean'], default: undefined },
24
24
  html: { types: ['string'], default: undefined },
25
25
  });
@@ -45,19 +45,26 @@ module.exports = {
45
45
  },
46
46
 
47
47
  {
48
- name: 'missing-template-and-html-rejected',
48
+ name: 'default-template-used-when-omitted',
49
49
  auth: 'admin',
50
- timeout: 15000,
50
+ timeout: 30000,
51
51
 
52
52
  async run({ http, assert, config }) {
53
53
  const response = await http.post('admin/email', {
54
- subject: 'BEM Test Email - No Template',
54
+ subject: 'BEM Test Email - Default Template',
55
55
  to: [{ email: `_test-receiver@${config.domain}` }],
56
- template: '',
57
56
  copy: false,
57
+ data: {
58
+ email: {
59
+ subject: 'BEM Test Email - Default Template',
60
+ body: 'Testing that default template is used when not specified.',
61
+ },
62
+ },
58
63
  });
59
64
 
60
- assert.isError(response, 400, 'Missing template and html should return 400');
65
+ assert.isSuccess(response, 'Should succeed with default template');
66
+ assert.equal(response.data.status, 'sent', 'Status should be sent');
67
+ assert.equal(response.data.options.templateId, 'd-b7f8da3c98ad49a2ad1e187f3a67b546', 'Should use default template');
61
68
  },
62
69
  },
63
70
 
@@ -350,6 +357,39 @@ module.exports = {
350
357
  },
351
358
  },
352
359
 
360
+ {
361
+ name: 'svg-images-converted-to-png',
362
+ auth: 'admin',
363
+ timeout: 30000,
364
+
365
+ async run({ http, assert, config }) {
366
+ const response = await http.post('admin/email', {
367
+ subject: 'BEM Test Email - SVG to PNG',
368
+ to: `_test-receiver@${config.domain}`,
369
+ copy: false,
370
+ data: {
371
+ email: {
372
+ subject: 'BEM Test Email - SVG to PNG',
373
+ body: 'Testing that SVG images are converted to PNG for email.',
374
+ },
375
+ },
376
+ });
377
+
378
+ assert.isSuccess(response, 'Should send email');
379
+ assert.equal(response.data.status, 'sent', 'Status should be sent');
380
+
381
+ const appImages = response.data.options.dynamicTemplateData.app.images;
382
+
383
+ // Any image that was an SVG should now be a PNG (-x.svg → -1024.png)
384
+ for (const [key, value] of Object.entries(appImages)) {
385
+ assert.ok(
386
+ !String(value || '').endsWith('.svg'),
387
+ `app.images.${key} should not be an SVG (got: ${value})`,
388
+ );
389
+ }
390
+ },
391
+ },
392
+
353
393
  {
354
394
  name: 'sendat-iso-string-accepted',
355
395
  auth: 'admin',