backend-manager 5.0.91 → 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/CHANGELOG.md +2 -2
- package/CLAUDE.md +14 -6
- package/README.md +6 -6
- package/TODO-MARKETING.md +3 -0
- package/TODO-PAYMENT-v2.md +71 -0
- package/TODO.md +7 -0
- package/package.json +3 -3
- package/src/cli/commands/{emulators.js → emulator.js} +15 -15
- package/src/cli/commands/index.js +1 -1
- package/src/cli/commands/setup-tests/{emulators-config.js → emulator-config.js} +4 -4
- package/src/cli/commands/setup-tests/index.js +2 -2
- package/src/cli/commands/setup-tests/project-id-consistency.js +1 -1
- package/src/cli/commands/test.js +16 -16
- package/src/cli/index.js +4 -4
- package/src/manager/events/auth/on-create.js +5 -158
- package/src/manager/events/firestore/payments-webhooks/analytics.js +4 -3
- package/src/manager/events/firestore/payments-webhooks/on-write.js +56 -6
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +1 -1
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +32 -28
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +3 -3
- package/src/manager/functions/core/actions/api/admin/send-email.js +0 -131
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +2 -137
- package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +2 -2
- package/src/manager/functions/core/actions/api/user/sign-up.js +1 -1
- package/src/manager/index.js +12 -0
- package/src/manager/libraries/email.js +523 -0
- package/src/manager/libraries/infer-contact.js +140 -0
- package/src/manager/libraries/prompts/infer-contact.md +43 -0
- package/src/manager/routes/admin/backup/post.js +4 -3
- package/src/manager/routes/admin/email/post.js +11 -428
- package/src/manager/routes/admin/hook/post.js +3 -2
- package/src/manager/routes/admin/notification/post.js +14 -12
- package/src/manager/routes/admin/post/post.js +5 -6
- package/src/manager/routes/admin/post/put.js +3 -2
- package/src/manager/routes/admin/stats/get.js +19 -10
- package/src/manager/routes/general/email/post.js +8 -21
- package/src/manager/routes/general/email/templates/download-app-link.js +2 -2
- package/src/manager/routes/marketing/contact/post.js +2 -100
- package/src/manager/routes/payments/intent/post.js +0 -2
- package/src/manager/routes/payments/intent/processors/test.js +9 -10
- package/src/manager/routes/user/oauth2/_helpers.js +3 -2
- package/src/manager/routes/user/oauth2/delete.js +3 -3
- package/src/manager/routes/user/oauth2/get.js +2 -2
- package/src/manager/routes/user/oauth2/post.js +9 -9
- package/src/manager/routes/user/sessions/delete.js +4 -3
- package/src/manager/routes/user/signup/post.js +254 -54
- package/src/manager/schemas/admin/email/post.js +13 -8
- package/src/test/run-tests.js +1 -1
- package/test/functions/admin/send-email.js +1 -88
- package/test/helpers/email.js +421 -0
- package/test/helpers/infer-contact.js +299 -0
- package/test/routes/admin/email.js +41 -90
- package/REFACTOR-BEM-API.md +0 -76
- package/REFACTOR-MIDDLEWARE.md +0 -62
- package/REFACTOR-PAYMENT.md +0 -66
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
* @param {object} options.unified - Unified subscription or one-time object
|
|
19
19
|
* @param {string} options.uid - User ID
|
|
20
20
|
* @param {string} options.processor - Payment processor (e.g., 'stripe', 'paypal')
|
|
21
|
-
* @param {object} options.assistant - Assistant instance
|
|
22
|
-
* @param {object} options.Manager - Manager instance
|
|
21
|
+
* @param {object} options.assistant - Assistant instance (Manager derived via assistant.Manager)
|
|
23
22
|
*/
|
|
24
|
-
function trackPayment({ category, transitionName, unified, uid, processor, assistant
|
|
23
|
+
function trackPayment({ category, transitionName, unified, uid, processor, assistant }) {
|
|
24
|
+
const Manager = assistant.Manager;
|
|
25
|
+
|
|
25
26
|
try {
|
|
26
27
|
// Resolve the analytics event to fire based on transition
|
|
27
28
|
const event = resolvePaymentEvent(category, transitionName, unified, Manager.config);
|
|
@@ -14,8 +14,9 @@ const { trackPayment } = require('./analytics.js');
|
|
|
14
14
|
* 4. Detects state transitions and dispatches handler files (non-blocking)
|
|
15
15
|
* 5. Marks the webhook as completed
|
|
16
16
|
*/
|
|
17
|
-
module.exports = async ({
|
|
18
|
-
const
|
|
17
|
+
module.exports = async ({ assistant, change, context }) => {
|
|
18
|
+
const Manager = assistant.Manager;
|
|
19
|
+
const admin = Manager.libraries.admin;
|
|
19
20
|
|
|
20
21
|
const dataAfter = change.after.data();
|
|
21
22
|
|
|
@@ -79,7 +80,7 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
|
|
|
79
80
|
throw new Error(`Unknown event category: ${category}`);
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
const transitionName = await processPaymentEvent({ category, library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX,
|
|
83
|
+
const transitionName = await processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant });
|
|
83
84
|
|
|
84
85
|
// Mark webhook as completed (include transition name for auditing/testing)
|
|
85
86
|
await webhookRef.set({
|
|
@@ -117,7 +118,9 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
|
|
|
117
118
|
* 6. Track analytics (non-blocking)
|
|
118
119
|
* 7. Write to Firestore (user doc for subscriptions + payments-orders)
|
|
119
120
|
*/
|
|
120
|
-
async function processPaymentEvent({ category, library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX,
|
|
121
|
+
async function processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant }) {
|
|
122
|
+
const Manager = assistant.Manager;
|
|
123
|
+
const admin = Manager.libraries.admin;
|
|
121
124
|
const isSubscription = category === 'subscription';
|
|
122
125
|
|
|
123
126
|
// Staleness check: skip if a newer webhook already wrote to this order
|
|
@@ -137,6 +140,19 @@ async function processPaymentEvent({ category, library, resource, uid, processor
|
|
|
137
140
|
const userData = userDoc.exists ? userDoc.data() : {};
|
|
138
141
|
const before = isSubscription ? (userData.subscription || null) : null;
|
|
139
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
|
+
|
|
145
|
+
// Auto-fill user name from payment processor if not already set
|
|
146
|
+
if (!userData?.personal?.name?.first) {
|
|
147
|
+
const customerName = extractCustomerName(resource, resourceType);
|
|
148
|
+
if (customerName?.first) {
|
|
149
|
+
await admin.firestore().doc(`users/${uid}`).set({
|
|
150
|
+
personal: { name: customerName },
|
|
151
|
+
}, { merge: true });
|
|
152
|
+
assistant.log(`Auto-filled user name from ${resourceType}: ${customerName.first} ${customerName.last || ''}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
140
156
|
// Transform raw resource → unified object
|
|
141
157
|
const transformOptions = { config: Manager.config, eventName: eventType, eventId: eventId };
|
|
142
158
|
const unified = isSubscription
|
|
@@ -180,7 +196,7 @@ async function processPaymentEvent({ category, library, resource, uid, processor
|
|
|
180
196
|
|
|
181
197
|
if (shouldRunHandlers) {
|
|
182
198
|
transitions.dispatch(transitionName, category, {
|
|
183
|
-
before, after: unified, order, uid, userDoc: userData,
|
|
199
|
+
before, after: unified, order, uid, userDoc: userData, assistant,
|
|
184
200
|
});
|
|
185
201
|
} else {
|
|
186
202
|
assistant.log(`Transition handler skipped (testing mode): ${category}/${transitionName}`);
|
|
@@ -189,7 +205,7 @@ async function processPaymentEvent({ category, library, resource, uid, processor
|
|
|
189
205
|
|
|
190
206
|
// Track payment analytics (non-blocking)
|
|
191
207
|
if (transitionName && shouldRunHandlers) {
|
|
192
|
-
trackPayment({ category, transitionName, unified, uid, processor, assistant
|
|
208
|
+
trackPayment({ category, transitionName, unified, uid, processor, assistant });
|
|
193
209
|
}
|
|
194
210
|
|
|
195
211
|
// Write unified subscription to user doc (subscriptions only)
|
|
@@ -206,3 +222,37 @@ async function processPaymentEvent({ category, library, resource, uid, processor
|
|
|
206
222
|
|
|
207
223
|
return transitionName;
|
|
208
224
|
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Extract customer name from a raw payment processor resource
|
|
228
|
+
*
|
|
229
|
+
* @param {object} resource - Raw processor resource (Stripe subscription, session, invoice)
|
|
230
|
+
* @param {string} resourceType - 'subscription' | 'session' | 'invoice'
|
|
231
|
+
* @returns {{ first: string, last: string }|null}
|
|
232
|
+
*/
|
|
233
|
+
function extractCustomerName(resource, resourceType) {
|
|
234
|
+
let fullName = null;
|
|
235
|
+
|
|
236
|
+
// Checkout sessions have customer_details.name
|
|
237
|
+
if (resourceType === 'session') {
|
|
238
|
+
fullName = resource.customer_details?.name;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Invoices have customer_name
|
|
242
|
+
if (resourceType === 'invoice') {
|
|
243
|
+
fullName = resource.customer_name;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Subscriptions only have customer ID, no name
|
|
247
|
+
|
|
248
|
+
if (!fullName) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const { capitalize } = require('../../../libraries/infer-contact.js');
|
|
253
|
+
const parts = fullName.trim().split(/\s+/);
|
|
254
|
+
return {
|
|
255
|
+
first: capitalize(parts[0]) || null,
|
|
256
|
+
last: capitalize(parts.slice(1).join(' ')) || null,
|
|
257
|
+
};
|
|
258
|
+
}
|
package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js
CHANGED
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
*/
|
|
5
5
|
const { sendOrderEmail, formatDate } = require('../send-email.js');
|
|
6
6
|
|
|
7
|
-
module.exports = async function ({ before, after, order, uid,
|
|
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: '
|
|
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
|
-
Manager,
|
|
17
17
|
data: {
|
|
18
18
|
order: {
|
|
19
19
|
...order,
|
package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js
CHANGED
|
@@ -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,
|
|
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
|
|
@@ -1,45 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared email helper for payment transition handlers
|
|
3
|
-
* Sends transactional order emails via
|
|
3
|
+
* Sends transactional order emails directly via the shared email library (no HTTP round-trip)
|
|
4
4
|
*/
|
|
5
5
|
const moment = require('moment');
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Send an order email
|
|
8
|
+
* Send an order email directly using the shared email library (fire-and-forget)
|
|
9
9
|
*
|
|
10
10
|
* @param {object} options
|
|
11
11
|
* @param {string} options.template - SendGrid dynamic template ID
|
|
12
12
|
* @param {string} options.subject - Email subject line
|
|
13
13
|
* @param {string[]} options.categories - SendGrid categories for filtering
|
|
14
|
-
* @param {object} options.data - Template data (passed as-is to the email
|
|
15
|
-
* @param {string} options.uid - User UID
|
|
14
|
+
* @param {object} options.data - Template data (passed as-is to the email)
|
|
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
|
-
* @param {object} options.Manager - Manager instance
|
|
18
18
|
*/
|
|
19
|
-
function sendOrderEmail({ template, subject, categories, data, uid,
|
|
20
|
-
const
|
|
19
|
+
function sendOrderEmail({ template, subject, categories, data, uid, userDoc, assistant }) {
|
|
20
|
+
const email = assistant.Manager.Email(assistant);
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
|
|
31
|
+
const settings = {
|
|
32
|
+
to: { email: userEmail, ...(userName && { name: userName }) },
|
|
33
|
+
subject,
|
|
34
|
+
template,
|
|
35
|
+
categories,
|
|
36
|
+
copy: false,
|
|
37
|
+
data,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
email.send(settings)
|
|
41
|
+
.then((result) => {
|
|
42
|
+
assistant.log(`sendOrderEmail(): Success template=${template}, uid=${uid}, status=${result.status}`);
|
|
43
|
+
})
|
|
44
|
+
.catch((e) => {
|
|
45
|
+
assistant.error(`sendOrderEmail(): Failed template=${template}, uid=${uid}: ${e.message}`);
|
|
46
|
+
});
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
/**
|
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
*/
|
|
5
5
|
const { sendOrderEmail, formatDate } = require('../send-email.js');
|
|
6
6
|
|
|
7
|
-
module.exports = async function ({ before, after, order, uid,
|
|
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: '
|
|
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
|
-
Manager,
|
|
17
17
|
data: {
|
|
18
18
|
order: {
|
|
19
19
|
...order,
|
package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js
CHANGED
|
@@ -5,18 +5,18 @@
|
|
|
5
5
|
*/
|
|
6
6
|
const { sendOrderEmail, formatDate } = require('../send-email.js');
|
|
7
7
|
|
|
8
|
-
module.exports = async function ({ before, after, order, uid,
|
|
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: '
|
|
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
|
-
Manager,
|
|
20
20
|
data: {
|
|
21
21
|
order: {
|
|
22
22
|
...order,
|
package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js
CHANGED
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
*/
|
|
5
5
|
const { sendOrderEmail, formatDate } = require('../send-email.js');
|
|
6
6
|
|
|
7
|
-
module.exports = async function ({ before, after, order, uid,
|
|
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: '
|
|
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
|
-
Manager,
|
|
17
17
|
data: {
|
|
18
18
|
order: {
|
|
19
19
|
...order,
|
package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js
CHANGED
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
*/
|
|
5
5
|
const { sendOrderEmail, formatDate } = require('../send-email.js');
|
|
6
6
|
|
|
7
|
-
module.exports = async function ({ before, after, order, uid,
|
|
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: '
|
|
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
|
-
Manager,
|
|
17
17
|
data: {
|
|
18
18
|
order: {
|
|
19
19
|
...order,
|
package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js
CHANGED
|
@@ -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,
|
|
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: '
|
|
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
|
-
Manager,
|
|
18
18
|
data: {
|
|
19
19
|
order: {
|
|
20
20
|
...order,
|
|
@@ -4,19 +4,19 @@
|
|
|
4
4
|
*/
|
|
5
5
|
const { sendOrderEmail, formatDate } = require('../send-email.js');
|
|
6
6
|
|
|
7
|
-
module.exports = async function ({ before, after, order, uid,
|
|
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: '
|
|
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
|
-
Manager,
|
|
20
20
|
data: {
|
|
21
21
|
order: {
|
|
22
22
|
...order,
|
|
@@ -51,18 +51,6 @@ Module.prototype.main = function () {
|
|
|
51
51
|
return reject(assistant.errorify(email.message, { code: 400 }));
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
// Check for duplicate emails being sent
|
|
55
|
-
const uniqueResult = await self.ensureFirstInstance(email);
|
|
56
|
-
|
|
57
|
-
// If not unique, return early
|
|
58
|
-
if (!uniqueResult) {
|
|
59
|
-
return resolve({
|
|
60
|
-
data: {
|
|
61
|
-
status: 'non-unique',
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
|
|
66
54
|
// If scheduled beyond SendGrid's limit, queue it
|
|
67
55
|
if (email.sendAt && email.sendAt >= moment().add(SEND_AT_LIMIT, 'hours').unix()) {
|
|
68
56
|
await self.saveToEmailQueue(email).catch(e => e);
|
|
@@ -151,7 +139,6 @@ Module.prototype.defaultize = function () {
|
|
|
151
139
|
|
|
152
140
|
// Set defaults
|
|
153
141
|
options.copy = typeof options.copy === 'undefined' ? true : options.copy;
|
|
154
|
-
options.ensureUnique = typeof options.ensureUnique === 'undefined' ? true : options.ensureUnique;
|
|
155
142
|
options.categories = powertools.arrayify(options.categories || []);
|
|
156
143
|
|
|
157
144
|
email.to = powertools.arrayify(options.to || []);
|
|
@@ -262,38 +249,6 @@ Module.prototype.defaultize = function () {
|
|
|
262
249
|
email.cc = email.cc.filter(obj => !email.to.some(obj2 => obj.email === obj2.email));
|
|
263
250
|
email.bcc = email.bcc.filter(obj => !email.to.some(obj2 => obj.email === obj2.email));
|
|
264
251
|
|
|
265
|
-
// Try to get contact name from SendGrid
|
|
266
|
-
await fetch(`https://api.sendgrid.com/v3/marketing/contacts/search/emails`, {
|
|
267
|
-
method: 'post',
|
|
268
|
-
response: 'json',
|
|
269
|
-
timeout: 60000,
|
|
270
|
-
headers: {
|
|
271
|
-
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
|
|
272
|
-
'Content-Type': 'application/json',
|
|
273
|
-
},
|
|
274
|
-
body: {
|
|
275
|
-
emails: email.to.map(obj => obj.email),
|
|
276
|
-
},
|
|
277
|
-
})
|
|
278
|
-
.then((json) => {
|
|
279
|
-
assistant.log('Got contact names', json);
|
|
280
|
-
|
|
281
|
-
// Update names from contacts
|
|
282
|
-
email.to.forEach((to) => {
|
|
283
|
-
const match = json.result[to.email];
|
|
284
|
-
if (match) {
|
|
285
|
-
email.to[0].name = match.contact.first_name || email.dynamicTemplateData.user.personal.name.first;
|
|
286
|
-
}
|
|
287
|
-
});
|
|
288
|
-
})
|
|
289
|
-
.catch((e) => {
|
|
290
|
-
if (e.status === 404) {
|
|
291
|
-
assistant.log('Contact does not exist in database');
|
|
292
|
-
} else {
|
|
293
|
-
assistant.error('Failed to get contact names', e);
|
|
294
|
-
}
|
|
295
|
-
});
|
|
296
|
-
|
|
297
252
|
// Log resolved email
|
|
298
253
|
assistant.log('Resolved email.to', email.to);
|
|
299
254
|
|
|
@@ -331,7 +286,6 @@ Module.prototype.defaultize = function () {
|
|
|
331
286
|
email.dynamicTemplateData.email.unsubscribeUrl = `https://itwcreativeworks.com/portal/account/email-preferences?email=${encode(email.to[0].email)}&asmId=${encode(email.asm.groupId)}&templateId=${encode(email.templateId)}&appName=${email.dynamicTemplateData.app.name}&appUrl=${email.dynamicTemplateData.app.url}`;
|
|
332
287
|
email.dynamicTemplateData.email.categories = email.categories;
|
|
333
288
|
email.dynamicTemplateData.email.carbonCopy = options.copy;
|
|
334
|
-
email.dynamicTemplateData.email.ensureUnique = options.ensureUnique;
|
|
335
289
|
|
|
336
290
|
// Handle raw HTML content (overrides template)
|
|
337
291
|
if (options.html) {
|
|
@@ -350,16 +304,6 @@ Module.prototype.defaultize = function () {
|
|
|
350
304
|
'List-Unsubscribe': `<${email.dynamicTemplateData.email.unsubscribeUrl}>`,
|
|
351
305
|
};
|
|
352
306
|
|
|
353
|
-
// Generate email hash for deduplication
|
|
354
|
-
email.hash = crypto.createHash('sha256');
|
|
355
|
-
email.hash.update(
|
|
356
|
-
email.to.map(obj => obj.email).join(',')
|
|
357
|
-
+ email.from.email
|
|
358
|
-
+ email.subject
|
|
359
|
-
+ options.categories.join(',')
|
|
360
|
-
);
|
|
361
|
-
email.hash = email.hash.digest('hex');
|
|
362
|
-
|
|
363
307
|
// Clone and clean data for stringified version
|
|
364
308
|
const emailClonedData = _.cloneDeep(email.dynamicTemplateData);
|
|
365
309
|
emailClonedData.app.sponsorships = {};
|
|
@@ -400,81 +344,6 @@ Module.prototype.saveToEmailQueue = function (email) {
|
|
|
400
344
|
});
|
|
401
345
|
};
|
|
402
346
|
|
|
403
|
-
Module.prototype.ensureFirstInstance = function (email) {
|
|
404
|
-
const self = this;
|
|
405
|
-
const Manager = self.Manager;
|
|
406
|
-
const assistant = self.assistant;
|
|
407
|
-
const payload = self.payload;
|
|
408
|
-
|
|
409
|
-
return new Promise(async function(resolve, reject) {
|
|
410
|
-
const timeout = assistant.isDevelopment() ? 3000 : 45000;
|
|
411
|
-
const { admin } = self.libraries;
|
|
412
|
-
|
|
413
|
-
const hash = email.hash;
|
|
414
|
-
const id = email.dynamicTemplateData.email.id;
|
|
415
|
-
const options = payload.data.payload;
|
|
416
|
-
|
|
417
|
-
assistant.log(`ensureFirstInstance(): Checking for unique email hash=${hash}, id=${id}`);
|
|
418
|
-
|
|
419
|
-
// Skip uniqueness check if disabled
|
|
420
|
-
if (!options.ensureUnique) {
|
|
421
|
-
assistant.log(`ensureFirstInstance(): Skipping unique email check`);
|
|
422
|
-
return resolve(true);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// Save email to temporary storage
|
|
426
|
-
await admin.firestore().doc(`temporary/email-queue`).set({
|
|
427
|
-
[hash]: {
|
|
428
|
-
[id]: assistant.meta.startTime.timestampUNIX,
|
|
429
|
-
},
|
|
430
|
-
}, { merge: true })
|
|
431
|
-
.then((doc) => {
|
|
432
|
-
assistant.log(`ensureFirstInstance(): Saved email to temporary storage`, hash);
|
|
433
|
-
})
|
|
434
|
-
.catch((e) => {
|
|
435
|
-
assistant.error(`ensureFirstInstance(): Failed to save email to temporary storage`, hash, e);
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
// Wait for timeout to allow duplicates to register
|
|
439
|
-
assistant.log(`ensureFirstInstance(): Waiting for ${timeout / 1000} sec`);
|
|
440
|
-
await powertools.poll(async (index) => {
|
|
441
|
-
return false;
|
|
442
|
-
}, { interval: 1000, timeout: timeout })
|
|
443
|
-
.catch((e) => {
|
|
444
|
-
assistant.log(`ensureFirstInstance(): Timeout reached`);
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
// Check if this is the first instance
|
|
448
|
-
const result = await admin.firestore().doc(`temporary/email-queue`).get()
|
|
449
|
-
.then((doc) => doc.data()?.[hash] || {})
|
|
450
|
-
.catch((e) => ({}));
|
|
451
|
-
|
|
452
|
-
const length = Object.keys(result).length;
|
|
453
|
-
const isFirstInstance = length === 1 || result[id] === Math.min(...Object.values(result));
|
|
454
|
-
|
|
455
|
-
assistant.log(`ensureFirstInstance(): Result`, result);
|
|
456
|
-
assistant.log(`ensureFirstInstance(): Result isFirstInstance`, length, isFirstInstance);
|
|
457
|
-
|
|
458
|
-
if (isFirstInstance) {
|
|
459
|
-
// Delete email from temporary storage
|
|
460
|
-
await admin.firestore().doc(`temporary/email-queue`).set({
|
|
461
|
-
[hash]: FieldValue.delete(),
|
|
462
|
-
}, { merge: true })
|
|
463
|
-
.then((doc) => {
|
|
464
|
-
assistant.log(`ensureFirstInstance(): Deleted email from temporary storage`, hash);
|
|
465
|
-
})
|
|
466
|
-
.catch((e) => {
|
|
467
|
-
assistant.error(`ensureFirstInstance(): Failed to delete email from temporary storage`, hash, e);
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
return resolve(true);
|
|
471
|
-
} else {
|
|
472
|
-
assistant.warn(`ensureFirstInstance(): Email is not unique`, hash, length, result);
|
|
473
|
-
return resolve(false);
|
|
474
|
-
}
|
|
475
|
-
});
|
|
476
|
-
};
|
|
477
|
-
|
|
478
347
|
// Helper to URL-encode base64
|
|
479
348
|
function encode(s) {
|
|
480
349
|
return encodeURIComponent(Buffer.from(String(s)).toString('base64'));
|