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
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
const fetch = require('wonderful-fetch');
|
|
2
|
+
const moment = require('moment');
|
|
3
|
+
const { inferContact } = require('../../../libraries/infer-contact.js');
|
|
4
|
+
|
|
1
5
|
const MAX_POLL_TIME_MS = 30000;
|
|
2
6
|
const POLL_INTERVAL_MS = 500;
|
|
3
7
|
const MAX_ACCOUNT_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
|
@@ -7,11 +11,13 @@ const MAX_ACCOUNT_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
7
11
|
*
|
|
8
12
|
* Called by client after account creation to:
|
|
9
13
|
* 1. Poll for user doc to exist (waits for onCreate to complete)
|
|
10
|
-
* 2.
|
|
11
|
-
* 3.
|
|
12
|
-
* 4.
|
|
14
|
+
* 2. Validate (already processed, account age)
|
|
15
|
+
* 3. Gather all data (client details, inferred contact)
|
|
16
|
+
* 4. Write everything to user doc in one merge
|
|
17
|
+
* 5. Process affiliate referral (writes to referrer's doc)
|
|
18
|
+
* 6. Send welcome emails + add to marketing lists (non-blocking)
|
|
13
19
|
*/
|
|
14
|
-
module.exports = async ({ assistant,
|
|
20
|
+
module.exports = async ({ assistant, user, settings, libraries }) => {
|
|
15
21
|
const { admin } = libraries;
|
|
16
22
|
|
|
17
23
|
// Require authentication
|
|
@@ -30,7 +36,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
30
36
|
assistant.log(`signup(): Starting for ${uid}`, settings);
|
|
31
37
|
|
|
32
38
|
// 1. Poll for user doc to exist (wait for onCreate to complete)
|
|
33
|
-
const userDoc = await pollForUserDoc(
|
|
39
|
+
const userDoc = await pollForUserDoc(assistant, uid);
|
|
34
40
|
|
|
35
41
|
if (!userDoc) {
|
|
36
42
|
return assistant.respond('User document not found after waiting. Please try again.', { code: 500 });
|
|
@@ -56,52 +62,24 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
56
62
|
return assistant.respond('Account is too old to process signup', { code: 400 });
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
// 4.
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (settings.affiliateCode && !attribution.affiliate?.code) {
|
|
64
|
-
attribution.affiliate = { code: settings.affiliateCode };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const affiliateCode = attribution.affiliate?.code || null;
|
|
68
|
-
|
|
69
|
-
// 5. Build update record with client details
|
|
70
|
-
const userRecord = {
|
|
71
|
-
flags: {
|
|
72
|
-
signupProcessed: true,
|
|
73
|
-
},
|
|
74
|
-
activity: {
|
|
75
|
-
...settings.context,
|
|
76
|
-
geolocation: {
|
|
77
|
-
...(settings.context?.geolocation || {}),
|
|
78
|
-
...assistant.request.geolocation,
|
|
79
|
-
},
|
|
80
|
-
client: {
|
|
81
|
-
...(settings.context?.client || {}),
|
|
82
|
-
...assistant.request.client,
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
attribution: attribution || {},
|
|
86
|
-
metadata: Manager.Metadata().set({ tag: 'user/signup' }),
|
|
87
|
-
};
|
|
65
|
+
// 4. Gather all data, then write once
|
|
66
|
+
const email = user.auth.email;
|
|
67
|
+
const inferred = await inferUserContact(assistant, email);
|
|
68
|
+
const userRecord = buildUserRecord(assistant, settings, inferred);
|
|
88
69
|
|
|
89
|
-
assistant.log(`signup():
|
|
70
|
+
assistant.log(`signup(): Writing user record for ${uid}`, userRecord);
|
|
90
71
|
|
|
91
|
-
// Update user doc with client details
|
|
92
72
|
await admin.firestore().doc(`users/${uid}`)
|
|
93
73
|
.set(userRecord, { merge: true })
|
|
94
74
|
.catch((e) => {
|
|
95
75
|
assistant.error(`signup(): Failed to update user record:`, e);
|
|
96
76
|
});
|
|
97
77
|
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
});
|
|
104
|
-
}
|
|
78
|
+
// 5. Process affiliate referral (writes to referrer's doc, not this user's)
|
|
79
|
+
await processAffiliate(assistant, uid, settings);
|
|
80
|
+
|
|
81
|
+
// 6. Send emails + marketing (non-blocking, fire-and-forget)
|
|
82
|
+
sendEmailsAndMarketing(assistant, uid, email, inferred);
|
|
105
83
|
|
|
106
84
|
return assistant.respond({ signedUp: true });
|
|
107
85
|
};
|
|
@@ -109,7 +87,8 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
109
87
|
/**
|
|
110
88
|
* Poll for user doc to exist (wait for onCreate to complete)
|
|
111
89
|
*/
|
|
112
|
-
async function pollForUserDoc(
|
|
90
|
+
async function pollForUserDoc(assistant, uid) {
|
|
91
|
+
const { admin } = assistant.Manager.libraries;
|
|
113
92
|
const startTime = Date.now();
|
|
114
93
|
|
|
115
94
|
while (Date.now() - startTime < MAX_POLL_TIME_MS) {
|
|
@@ -133,25 +112,99 @@ async function pollForUserDoc(admin, assistant, uid) {
|
|
|
133
112
|
}
|
|
134
113
|
|
|
135
114
|
/**
|
|
136
|
-
*
|
|
115
|
+
* Build the full user record: client details, attribution, and inferred contact
|
|
116
|
+
*/
|
|
117
|
+
function buildUserRecord(assistant, settings, inferred) {
|
|
118
|
+
const Manager = assistant.Manager;
|
|
119
|
+
const attribution = settings.attribution;
|
|
120
|
+
|
|
121
|
+
// Legacy support: if affiliateCode exists, normalize to new format
|
|
122
|
+
if (settings.affiliateCode && !attribution.affiliate?.code) {
|
|
123
|
+
attribution.affiliate = { code: settings.affiliateCode };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const record = {
|
|
127
|
+
flags: {
|
|
128
|
+
signupProcessed: true,
|
|
129
|
+
},
|
|
130
|
+
activity: {
|
|
131
|
+
...settings.context,
|
|
132
|
+
geolocation: {
|
|
133
|
+
...(settings.context?.geolocation || {}),
|
|
134
|
+
...assistant.request.geolocation,
|
|
135
|
+
},
|
|
136
|
+
client: {
|
|
137
|
+
...(settings.context?.client || {}),
|
|
138
|
+
...assistant.request.client,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
attribution: attribution || {},
|
|
142
|
+
metadata: Manager.Metadata().set({ tag: 'user/signup' }),
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Add inferred name/company if available
|
|
146
|
+
if (inferred) {
|
|
147
|
+
record.personal = {
|
|
148
|
+
...(inferred.firstName || inferred.lastName ? {
|
|
149
|
+
name: {
|
|
150
|
+
...(inferred.firstName ? { first: inferred.firstName } : {}),
|
|
151
|
+
...(inferred.lastName ? { last: inferred.lastName } : {}),
|
|
152
|
+
},
|
|
153
|
+
} : {}),
|
|
154
|
+
...(inferred.company ? { company: { name: inferred.company } } : {}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return record;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Infer name/company from email using AI (or regex fallback)
|
|
163
|
+
* Returns the inferred contact info, or null on failure
|
|
164
|
+
*/
|
|
165
|
+
async function inferUserContact(assistant, email) {
|
|
166
|
+
try {
|
|
167
|
+
const inferred = await inferContact(email, assistant);
|
|
168
|
+
|
|
169
|
+
if (!inferred?.firstName && !inferred?.lastName && !inferred?.company) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
assistant.log(`signup(): Inferred contact: ${inferred.firstName || ''} ${inferred.lastName || ''}, company=${inferred.company || ''} (method=${inferred.method})`);
|
|
174
|
+
|
|
175
|
+
return inferred;
|
|
176
|
+
} catch (e) {
|
|
177
|
+
assistant.error('signup(): Name inference failed:', e);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Process affiliate referral if affiliate code provided
|
|
184
|
+
* Writes to the referrer's doc (not the current user's)
|
|
137
185
|
*/
|
|
138
|
-
async function
|
|
186
|
+
async function processAffiliate(assistant, uid, settings) {
|
|
187
|
+
const { admin } = assistant.Manager.libraries;
|
|
188
|
+
const affiliateCode = settings.attribution?.affiliate?.code
|
|
189
|
+
|| settings.affiliateCode
|
|
190
|
+
|| null;
|
|
191
|
+
|
|
139
192
|
if (!affiliateCode) {
|
|
140
193
|
return;
|
|
141
194
|
}
|
|
142
195
|
|
|
143
|
-
assistant.log(`
|
|
196
|
+
assistant.log(`processAffiliate(): Looking for referrer with code ${affiliateCode}`);
|
|
144
197
|
|
|
145
198
|
const snapshot = await admin.firestore().collection('users')
|
|
146
199
|
.where('affiliate.code', '==', affiliateCode)
|
|
147
200
|
.get()
|
|
148
201
|
.catch((e) => {
|
|
149
|
-
assistant.error(`
|
|
202
|
+
assistant.error(`processAffiliate(): Failed to find referrer:`, e);
|
|
150
203
|
throw e;
|
|
151
204
|
});
|
|
152
205
|
|
|
153
206
|
if (snapshot.empty) {
|
|
154
|
-
assistant.log(`
|
|
207
|
+
assistant.log(`processAffiliate(): No referrer found with code ${affiliateCode}`);
|
|
155
208
|
return;
|
|
156
209
|
}
|
|
157
210
|
|
|
@@ -162,13 +215,12 @@ async function updateReferral(admin, assistant, newUserUid, affiliateCode) {
|
|
|
162
215
|
let referrals = referrerData?.affiliate?.referrals || [];
|
|
163
216
|
referrals = Array.isArray(referrals) ? referrals : [];
|
|
164
217
|
|
|
165
|
-
// Add new referral
|
|
166
218
|
referrals.push({
|
|
167
|
-
uid:
|
|
219
|
+
uid: uid,
|
|
168
220
|
timestamp: assistant.meta.startTime.timestamp,
|
|
169
221
|
});
|
|
170
222
|
|
|
171
|
-
assistant.log(`
|
|
223
|
+
assistant.log(`processAffiliate(): Appending referral to ${referrerDoc.id}`, referrals);
|
|
172
224
|
|
|
173
225
|
await admin.firestore().doc(`users/${referrerDoc.id}`)
|
|
174
226
|
.set({
|
|
@@ -177,9 +229,157 @@ async function updateReferral(admin, assistant, newUserUid, affiliateCode) {
|
|
|
177
229
|
},
|
|
178
230
|
}, { merge: true })
|
|
179
231
|
.then(() => {
|
|
180
|
-
assistant.log(`
|
|
232
|
+
assistant.log(`processAffiliate(): Success`);
|
|
181
233
|
})
|
|
182
234
|
.catch((e) => {
|
|
183
|
-
assistant.error(`
|
|
235
|
+
assistant.error(`processAffiliate(): Failed to update referrer:`, e);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Send welcome emails and add to marketing lists (non-blocking, fire-and-forget)
|
|
241
|
+
*/
|
|
242
|
+
function sendEmailsAndMarketing(assistant, uid, email, inferred) {
|
|
243
|
+
const Manager = assistant.Manager;
|
|
244
|
+
const shouldSend = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
245
|
+
|
|
246
|
+
if (!shouldSend) {
|
|
247
|
+
assistant.log(`signup(): Skipping emails/marketing (BEM_TESTING=true, TEST_EXTENDED_MODE not set)`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
assistant.log(`signup(): Sending emails/adding to marketing for ${uid}`);
|
|
252
|
+
|
|
253
|
+
// Add to marketing lists (SendGrid + Beehiiv) via centralized endpoint
|
|
254
|
+
fetch(`${Manager.project.apiUrl}/backend-manager/marketing/contact`, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
response: 'json',
|
|
257
|
+
body: {
|
|
258
|
+
backendManagerKey: process.env.BACKEND_MANAGER_KEY,
|
|
259
|
+
email: email,
|
|
260
|
+
firstName: inferred?.firstName || '',
|
|
261
|
+
lastName: inferred?.lastName || '',
|
|
262
|
+
source: 'user:signup',
|
|
263
|
+
},
|
|
264
|
+
}).catch(e => assistant.error('signup(): marketing-contact failed:', e));
|
|
265
|
+
|
|
266
|
+
// Send welcome emails (non-blocking, don't fail on error)
|
|
267
|
+
sendWelcomeEmail(assistant, email).catch(e => assistant.error('signup(): sendWelcomeEmail failed:', e));
|
|
268
|
+
sendCheckupEmail(assistant, email).catch(e => assistant.error('signup(): sendCheckupEmail failed:', e));
|
|
269
|
+
sendFeedbackEmail(assistant, email).catch(e => assistant.error('signup(): sendFeedbackEmail failed:', e));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Send welcome email (immediate)
|
|
274
|
+
*/
|
|
275
|
+
function sendWelcomeEmail(assistant, email) {
|
|
276
|
+
const Manager = assistant.Manager;
|
|
277
|
+
const mailer = Manager.Email(assistant);
|
|
278
|
+
|
|
279
|
+
return mailer.send({
|
|
280
|
+
to: email,
|
|
281
|
+
categories: ['account/welcome'],
|
|
282
|
+
subject: `Welcome to ${Manager.config.brand.name}!`,
|
|
283
|
+
template: 'default',
|
|
284
|
+
group: 'account',
|
|
285
|
+
copy: false,
|
|
286
|
+
data: {
|
|
287
|
+
email: {
|
|
288
|
+
preview: `Welcome aboard! I'm Ian, the CEO and founder of ${Manager.config.brand.name}. I'm here to ensure your journey with us gets off to a great start.`,
|
|
289
|
+
},
|
|
290
|
+
body: {
|
|
291
|
+
title: `Welcome to ${Manager.config.brand.name}!`,
|
|
292
|
+
message: `
|
|
293
|
+
Welcome aboard!
|
|
294
|
+
<br><br>
|
|
295
|
+
I'm Ian, the founder and CEO of <strong>${Manager.config.brand.name}</strong>, and I'm thrilled to have you with us.
|
|
296
|
+
Your journey begins today, and we are committed to supporting you every step of the way.
|
|
297
|
+
<br><br>
|
|
298
|
+
We are dedicated to ensuring your experience is exceptional.
|
|
299
|
+
Feel free to reply directly to this email with any questions you may have.
|
|
300
|
+
<br><br>
|
|
301
|
+
Thank you for choosing <strong>${Manager.config.brand.name}</strong>. Here's to new beginnings!
|
|
302
|
+
`,
|
|
303
|
+
},
|
|
304
|
+
signoff: {
|
|
305
|
+
type: 'personal',
|
|
306
|
+
name: 'Ian Wiedenman, CEO',
|
|
307
|
+
url: `https://ianwiedenman.com?utm_source=welcome-email&utm_medium=email&utm_campaign=${Manager.config.app.id}`,
|
|
308
|
+
urlText: '@ianwieds',
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
})
|
|
312
|
+
.then((result) => {
|
|
313
|
+
assistant.log('sendWelcomeEmail(): Success', result.status);
|
|
314
|
+
return result;
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Send checkup email (7 days after signup)
|
|
320
|
+
*/
|
|
321
|
+
function sendCheckupEmail(assistant, email) {
|
|
322
|
+
const Manager = assistant.Manager;
|
|
323
|
+
const mailer = Manager.Email(assistant);
|
|
324
|
+
|
|
325
|
+
return mailer.send({
|
|
326
|
+
to: email,
|
|
327
|
+
categories: ['account/checkup'],
|
|
328
|
+
subject: `How's your experience with ${Manager.config.brand.name}?`,
|
|
329
|
+
template: 'default',
|
|
330
|
+
group: 'account',
|
|
331
|
+
copy: false,
|
|
332
|
+
sendAt: moment().add(7, 'days').unix(),
|
|
333
|
+
data: {
|
|
334
|
+
email: {
|
|
335
|
+
preview: `Checking in from ${Manager.config.brand.name} to see how things are going. Let us know if you have any questions or feedback!`,
|
|
336
|
+
},
|
|
337
|
+
body: {
|
|
338
|
+
title: `How's everything going?`,
|
|
339
|
+
message: `
|
|
340
|
+
Hi there,
|
|
341
|
+
<br><br>
|
|
342
|
+
It's Ian again from <strong>${Manager.config.brand.name}</strong>. Just checking in to see how things are going for you.
|
|
343
|
+
<br><br>
|
|
344
|
+
Have you had a chance to explore all our features? Any questions or feedback for us?
|
|
345
|
+
<br><br>
|
|
346
|
+
We're always here to help, so don't hesitate to reach out. Just reply to this email and we'll get back to you as soon as possible.
|
|
347
|
+
<br><br>
|
|
348
|
+
Thank you for choosing <strong>${Manager.config.brand.name}</strong>. Here's to new beginnings!
|
|
349
|
+
`,
|
|
350
|
+
},
|
|
351
|
+
signoff: {
|
|
352
|
+
type: 'personal',
|
|
353
|
+
name: 'Ian Wiedenman, CEO',
|
|
354
|
+
url: `https://ianwiedenman.com?utm_source=checkup-email&utm_medium=email&utm_campaign=${Manager.config.app.id}`,
|
|
355
|
+
urlText: '@ianwieds',
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
})
|
|
359
|
+
.then((result) => {
|
|
360
|
+
assistant.log('sendCheckupEmail(): Success', result.status);
|
|
361
|
+
return result;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Send feedback email (14 days after signup)
|
|
367
|
+
*/
|
|
368
|
+
function sendFeedbackEmail(assistant, email) {
|
|
369
|
+
const Manager = assistant.Manager;
|
|
370
|
+
const mailer = Manager.Email(assistant);
|
|
371
|
+
|
|
372
|
+
return mailer.send({
|
|
373
|
+
to: email,
|
|
374
|
+
categories: ['engagement/feedback'],
|
|
375
|
+
subject: `Want to share your feedback about ${Manager.config.brand.name}?`,
|
|
376
|
+
template: 'main/engagement/feedback',
|
|
377
|
+
group: 'account',
|
|
378
|
+
copy: false,
|
|
379
|
+
sendAt: moment().add(14, 'days').unix(),
|
|
380
|
+
})
|
|
381
|
+
.then((result) => {
|
|
382
|
+
assistant.log('sendFeedbackEmail(): Success', result.status);
|
|
383
|
+
return result;
|
|
184
384
|
});
|
|
185
385
|
}
|
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Schema for POST /admin/email
|
|
3
|
+
*
|
|
4
|
+
* Recipients (to, cc, bcc) accept flexible formats:
|
|
5
|
+
* - String email: "user@example.com"
|
|
6
|
+
* - UID string: "uid:abc123" (auto-resolves from Firestore)
|
|
7
|
+
* - Object: { email: "user@example.com", name: "John" }
|
|
8
|
+
* - Array of any of the above
|
|
3
9
|
*/
|
|
4
10
|
module.exports = () => ({
|
|
5
|
-
to: { types: ['array'], default: [] },
|
|
6
|
-
cc: { types: ['array'], default: [] },
|
|
7
|
-
bcc: { types: ['array'], default: [] },
|
|
11
|
+
to: { types: ['array', 'string', 'object'], default: [] },
|
|
12
|
+
cc: { types: ['array', 'string', 'object'], default: [] },
|
|
13
|
+
bcc: { types: ['array', 'string', 'object'], default: [] },
|
|
8
14
|
from: { types: ['object'], default: undefined },
|
|
9
15
|
replyTo: { types: ['string'], default: undefined },
|
|
10
16
|
subject: { types: ['string'], default: undefined },
|
|
11
|
-
template: { types: ['string'], default:
|
|
12
|
-
group: { types: ['number'], default:
|
|
13
|
-
sendAt: { types: ['number'], default: undefined },
|
|
17
|
+
template: { types: ['string'], default: undefined },
|
|
18
|
+
group: { types: ['number'], default: undefined },
|
|
19
|
+
sendAt: { types: ['number', 'string'], default: undefined },
|
|
14
20
|
user: { types: ['object'], default: {} },
|
|
15
21
|
data: { types: ['object'], default: {} },
|
|
16
22
|
categories: { types: ['array'], default: [] },
|
|
17
|
-
copy: { types: ['boolean'], default:
|
|
18
|
-
ensureUnique: { types: ['boolean'], default: true },
|
|
23
|
+
copy: { types: ['boolean'], default: undefined },
|
|
19
24
|
html: { types: ['string'], default: undefined },
|
|
20
25
|
});
|
package/src/test/run-tests.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* BEM Test Runner Entry Point
|
|
5
|
-
* This script is executed by the CLI test command inside
|
|
5
|
+
* This script is executed by the CLI test command inside the Firebase emulator
|
|
6
6
|
* It reads configuration from BEM_TEST_CONFIG environment variable and runs the test suite
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Possible status values:
|
|
10
10
|
* - 'sent': Email sent via SendGrid
|
|
11
|
-
* - 'non-unique': Duplicate email detected (when ensureUnique: true)
|
|
12
11
|
* - 'queued': Scheduled beyond 71 hours, saved to queue for later
|
|
13
12
|
*/
|
|
14
13
|
module.exports = {
|
|
@@ -26,7 +25,6 @@ module.exports = {
|
|
|
26
25
|
const response = await http.command('admin:send-email', {
|
|
27
26
|
to: [{ email: `_test-receiver@${config.domain}` }],
|
|
28
27
|
copy: false,
|
|
29
|
-
ensureUnique: false,
|
|
30
28
|
});
|
|
31
29
|
|
|
32
30
|
assert.isError(response, 400, 'Missing subject should return 400');
|
|
@@ -44,7 +42,6 @@ module.exports = {
|
|
|
44
42
|
subject: 'BEM Test Email - Status Sent',
|
|
45
43
|
to: [{ email: `_test-receiver@${config.domain}`, name: 'Test Receiver' }],
|
|
46
44
|
copy: false,
|
|
47
|
-
ensureUnique: false,
|
|
48
45
|
data: {
|
|
49
46
|
email: {
|
|
50
47
|
subject: 'BEM Test Email - Status Sent',
|
|
@@ -55,7 +52,7 @@ module.exports = {
|
|
|
55
52
|
|
|
56
53
|
assert.isSuccess(response, 'Admin should be able to send email');
|
|
57
54
|
assert.hasProperty(response, 'data.status', 'Response should have status');
|
|
58
|
-
assert.equal(response.data.status, 'sent', 'Status should be sent
|
|
55
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
59
56
|
},
|
|
60
57
|
},
|
|
61
58
|
|
|
@@ -73,7 +70,6 @@ module.exports = {
|
|
|
73
70
|
subject: 'BEM Test Email - Status Queued',
|
|
74
71
|
to: [{ email: `_test-receiver@${config.domain}`, name: 'Test Receiver' }],
|
|
75
72
|
copy: false,
|
|
76
|
-
ensureUnique: false,
|
|
77
73
|
sendAt: sendAt,
|
|
78
74
|
data: {
|
|
79
75
|
email: {
|
|
@@ -89,89 +85,6 @@ module.exports = {
|
|
|
89
85
|
},
|
|
90
86
|
},
|
|
91
87
|
|
|
92
|
-
// Test 6: Status 'non-unique' - Duplicate email detected
|
|
93
|
-
// This test sends two identical emails with ensureUnique: true
|
|
94
|
-
// The second one should return 'non-unique' status
|
|
95
|
-
{
|
|
96
|
-
name: 'status-non-unique',
|
|
97
|
-
auth: 'admin',
|
|
98
|
-
timeout: 120000, // Long timeout because ensureUnique waits ~45 seconds
|
|
99
|
-
|
|
100
|
-
async run({ http, assert, config }) {
|
|
101
|
-
const uniqueSubject = `BEM Test Email - Unique Check ${Date.now()}`;
|
|
102
|
-
|
|
103
|
-
// Send first email with ensureUnique: true
|
|
104
|
-
const response1Promise = http.command('admin:send-email', {
|
|
105
|
-
subject: uniqueSubject,
|
|
106
|
-
to: [{ email: `_test-receiver@${config.domain}` }],
|
|
107
|
-
copy: false,
|
|
108
|
-
ensureUnique: true,
|
|
109
|
-
categories: ['bem-test-unique'],
|
|
110
|
-
data: {
|
|
111
|
-
email: {
|
|
112
|
-
subject: uniqueSubject,
|
|
113
|
-
body: 'Testing ensureUnique feature.',
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
// Send second identical email immediately (same subject, to, categories = same hash)
|
|
119
|
-
const response2Promise = http.command('admin:send-email', {
|
|
120
|
-
subject: uniqueSubject,
|
|
121
|
-
to: [{ email: `_test-receiver@${config.domain}` }],
|
|
122
|
-
copy: false,
|
|
123
|
-
ensureUnique: true,
|
|
124
|
-
categories: ['bem-test-unique'],
|
|
125
|
-
data: {
|
|
126
|
-
email: {
|
|
127
|
-
subject: uniqueSubject,
|
|
128
|
-
body: 'Testing ensureUnique feature.',
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
// Wait for both
|
|
134
|
-
const [response1, response2] = await Promise.all([response1Promise, response2Promise]);
|
|
135
|
-
|
|
136
|
-
// Both should succeed
|
|
137
|
-
assert.isSuccess(response1, 'First email should succeed');
|
|
138
|
-
assert.isSuccess(response2, 'Second email should succeed');
|
|
139
|
-
|
|
140
|
-
// One should be 'sent', the other 'non-unique'
|
|
141
|
-
const statuses = [response1.data.status, response2.data.status].sort();
|
|
142
|
-
assert.equal(statuses[0], 'non-unique', 'One email should have status non-unique');
|
|
143
|
-
assert.equal(statuses[1], 'sent', 'One email should have status sent');
|
|
144
|
-
},
|
|
145
|
-
},
|
|
146
|
-
|
|
147
|
-
// Test 7: Unauthorized sender domain rejected by SendGrid
|
|
148
|
-
// TODO: SendGrid accepts emails from unauthorized domains at API level
|
|
149
|
-
// (they may fail at delivery). Consider adding BEM-level validation.
|
|
150
|
-
// {
|
|
151
|
-
// name: 'unauthorized-from-domain-rejected',
|
|
152
|
-
// auth: 'admin',
|
|
153
|
-
// timeout: 30000,
|
|
154
|
-
//
|
|
155
|
-
// async run({ http, assert, config }) {
|
|
156
|
-
// const response = await http.command('admin:send-email', {
|
|
157
|
-
// subject: 'BEM Test Email - Unauthorized Sender',
|
|
158
|
-
// to: [{ email: `_test-receiver@${config.domain}` }],
|
|
159
|
-
// from: { email: 'fake-sender@example.com', name: 'Fake Sender' },
|
|
160
|
-
// copy: false,
|
|
161
|
-
// ensureUnique: false,
|
|
162
|
-
// data: {
|
|
163
|
-
// email: {
|
|
164
|
-
// subject: 'BEM Test Email - Unauthorized Sender',
|
|
165
|
-
// body: 'This email should fail because the sender domain is not authorized.',
|
|
166
|
-
// },
|
|
167
|
-
// },
|
|
168
|
-
// });
|
|
169
|
-
//
|
|
170
|
-
// // SendGrid rejects emails from unauthorized sender domains with 403
|
|
171
|
-
// assert.isError(response, 500, 'Sending from unauthorized domain should fail');
|
|
172
|
-
// },
|
|
173
|
-
// },
|
|
174
|
-
|
|
175
88
|
// --- Auth rejection tests (at end per convention) ---
|
|
176
89
|
{
|
|
177
90
|
name: 'unauthenticated-rejected',
|