backend-manager 5.0.92 → 5.0.94

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.94",
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,14 @@
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
- uid,
14
+ userDoc,
15
15
  assistant,
16
16
  data: {
17
17
  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,18 +12,27 @@ 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 {object} options.userDoc - User document data (already fetched by on-write.js)
16
16
  * @param {object} options.assistant - Assistant instance
17
17
  */
18
- function sendOrderEmail({ template, subject, categories, data, uid, assistant }) {
18
+ function sendOrderEmail({ template, subject, categories, data, userDoc, assistant }) {
19
19
  const email = assistant.Manager.Email(assistant);
20
20
 
21
+ const userEmail = userDoc?.auth?.email;
22
+ const userName = userDoc?.personal?.name?.first;
23
+ const uid = userDoc?.auth?.uid;
24
+
25
+ if (!userEmail) {
26
+ assistant.error(`sendOrderEmail(): No email found for uid=${uid}, skipping`);
27
+ return;
28
+ }
29
+
21
30
  const settings = {
22
- to: `uid:${uid}`,
31
+ to: { email: userEmail, ...(userName && { name: userName }) },
23
32
  subject,
24
33
  template,
25
34
  categories,
26
- copy: false,
35
+ copy: true,
27
36
  data,
28
37
  };
29
38
 
@@ -4,14 +4,14 @@
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
- uid,
14
+ userDoc,
15
15
  assistant,
16
16
  data: {
17
17
  order: {
@@ -5,16 +5,16 @@
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
- uid,
17
+ userDoc,
18
18
  assistant,
19
19
  data: {
20
20
  order: {
@@ -4,14 +4,14 @@
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
- uid,
14
+ userDoc,
15
15
  assistant,
16
16
  data: {
17
17
  order: {
@@ -4,14 +4,14 @@
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
- uid,
14
+ userDoc,
15
15
  assistant,
16
16
  data: {
17
17
  order: {
@@ -4,15 +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
  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
- uid,
15
+ userDoc,
16
16
  assistant,
17
17
  data: {
18
18
  order: {
@@ -4,17 +4,17 @@
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
- uid,
17
+ userDoc,
18
18
  assistant,
19
19
  data: {
20
20
  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,15 +96,17 @@ 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) {
75
103
  throw errorWithCode('Missing brand.contact.email in backend-manager-config.json', 400);
76
104
  }
77
105
 
106
+ const copy = settings.copy ?? true;
107
+
78
108
  // Add carbon copy recipients
79
- if (settings.copy) {
109
+ if (copy) {
80
110
  cc.push({
81
111
  email: app.email,
82
112
  name: app.name,
@@ -116,17 +146,8 @@ Email.prototype.build = async function (settings) {
116
146
  throw errorWithCode('Parameter subject is required', 400);
117
147
  }
118
148
 
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
- }
149
+ const templateId = TEMPLATES[settings.template] || settings.template || TEMPLATES['default'];
150
+ const groupId = GROUPS[settings.group] || settings.group || GROUPS['default'];
130
151
 
131
152
  // Build categories
132
153
  const categories = _.uniq([
@@ -139,7 +160,7 @@ Email.prototype.build = async function (settings) {
139
160
  const sendAt = normalizeSendAt(settings.sendAt);
140
161
 
141
162
  // 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}`;
163
+ 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
164
 
144
165
  // Build signoff
145
166
  const signoff = settings?.data?.signoff || {};
@@ -157,7 +178,7 @@ Email.prototype.build = async function (settings) {
157
178
  const dynamicTemplateData = {
158
179
  email: {
159
180
  id: Manager.require('uuid').v4(),
160
- subject: settings?.data?.email?.subject || null,
181
+ subject: settings?.data?.email?.subject || subject,
161
182
  preview: settings?.data?.email?.preview || null,
162
183
  body: settings?.data?.email?.body || null,
163
184
  unsubscribeUrl,
@@ -165,7 +186,7 @@ Email.prototype.build = async function (settings) {
165
186
  footer: {
166
187
  text: settings?.data?.email?.footer?.text || null,
167
188
  },
168
- carbonCopy: settings.copy,
189
+ carbonCopy: copy,
169
190
  },
170
191
  personalization: {
171
192
  email: to[0].email,
@@ -229,6 +250,8 @@ Email.prototype.send = async function (settings) {
229
250
  const admin = self.admin;
230
251
  const assistant = self.assistant;
231
252
 
253
+ assistant.log(`Email.send(): to=${JSON.stringify(settings.to)}, subject=${settings.subject}, template=${settings.template}`);
254
+
232
255
  // Build email from settings (throws with code: 400 on validation failure)
233
256
  const email = await self.build(settings);
234
257
 
@@ -473,6 +496,24 @@ function errorWithCode(message, code) {
473
496
  return err;
474
497
  }
475
498
 
499
+ /**
500
+ * Convert SVG image URLs to PNG equivalents — email clients don't render SVGs.
501
+ * CDN naming convention: `-x.svg` → `-1024.png`
502
+ */
503
+ function sanitizeImagesForEmail(images) {
504
+ const result = {};
505
+
506
+ for (const [key, value] of Object.entries(images)) {
507
+ if (typeof value === 'string' && value.endsWith('.svg')) {
508
+ result[key] = value.replace(/-x\.svg$/, '-1024.png');
509
+ } else {
510
+ result[key] = value;
511
+ }
512
+ }
513
+
514
+ return result;
515
+ }
516
+
476
517
  /**
477
518
  * URL-encode a value as base64
478
519
  */
@@ -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',