backend-manager 5.0.148 → 5.0.149
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 +50 -0
- package/CLAUDE.md +26 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +14 -4
- package/src/cli/commands/test.js +4 -10
- package/src/manager/cron/daily/ghostii-auto-publisher.js +25 -25
- package/src/manager/cron/frequent/abandoned-carts.js +7 -5
- package/src/manager/cron/frequent/email-queue.js +56 -0
- package/src/manager/events/auth/before-signin.js +3 -0
- package/src/manager/events/auth/on-delete.js +8 -0
- package/src/manager/events/firestore/payments-disputes/on-write.js +2 -1
- package/src/manager/events/firestore/payments-webhooks/on-write.js +9 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +7 -21
- package/src/manager/functions/core/actions/api/admin/get-stats.js +2 -2
- package/src/manager/functions/core/actions/api/admin/send-email.js +14 -14
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +22 -318
- package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +1 -1
- package/src/manager/functions/core/actions/api/general/remove-marketing-contact.js +2 -185
- package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
- package/src/manager/functions/core/actions/api/special/setup-electron-manager-client.js +2 -2
- package/src/manager/functions/core/actions/api/test/health.js +1 -0
- package/src/manager/helpers/api-manager.js +2 -2
- package/src/manager/helpers/user.js +3 -1
- package/src/manager/index.js +15 -10
- package/src/manager/libraries/email/constants.js +243 -0
- package/src/manager/libraries/email/index.js +145 -0
- package/src/manager/libraries/email/marketing/index.js +377 -0
- package/src/manager/libraries/email/providers/beehiiv.js +258 -0
- package/src/manager/libraries/email/providers/sendgrid.js +429 -0
- package/src/manager/libraries/{email.js → email/transactional/index.js} +91 -99
- package/src/manager/libraries/email/validation.js +168 -0
- package/src/manager/routes/admin/cron/post.js +3 -3
- package/src/manager/routes/admin/email/post.js +1 -1
- package/src/manager/routes/admin/stats/get.js +2 -2
- package/src/manager/routes/{app → brand}/get.js +1 -1
- package/src/manager/routes/general/email/templates/download-app-link.js +1 -1
- package/src/manager/routes/marketing/contact/delete.js +2 -164
- package/src/manager/routes/marketing/contact/post.js +45 -298
- package/src/manager/routes/marketing/contact/put.js +39 -0
- package/src/manager/routes/payments/cancel/post.js +11 -0
- package/src/manager/routes/special/electron-client/post.js +3 -3
- package/src/manager/routes/test/health/get.js +1 -0
- package/src/manager/routes/user/data-request/delete.js +2 -2
- package/src/manager/routes/user/data-request/get.js +2 -2
- package/src/manager/routes/user/data-request/post.js +2 -2
- package/src/manager/routes/user/delete.js +1 -1
- package/src/manager/routes/user/feedback/post.js +12 -8
- package/src/manager/routes/user/signup/post.js +48 -37
- package/src/manager/schemas/admin/email/post.js +4 -4
- package/src/manager/schemas/marketing/contact/delete.js +3 -1
- package/src/manager/schemas/marketing/contact/post.js +3 -1
- package/src/manager/schemas/marketing/contact/put.js +6 -0
- package/src/manager/schemas/special/electron-client/post.js +2 -2
- package/src/manager/schemas/user/feedback/post.js +2 -2
- package/src/test/run-tests.js +1 -1
- package/src/test/runner.js +22 -10
- package/src/test/test-accounts.js +9 -0
- package/src/test/utils/extended-mode-warning.js +11 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +11 -0
- package/test/events/payments/journey-payments-trial-cancel.js +11 -0
- package/test/functions/admin/edit-post.js +2 -2
- package/test/functions/admin/write-repo-content.js +2 -2
- package/test/functions/general/add-marketing-contact.js +21 -23
- package/test/helpers/email-validation.js +420 -0
- package/test/helpers/email.js +119 -6
- package/test/helpers/marketing-lifecycle.js +121 -0
- package/test/helpers/user.js +2 -2
- package/test/routes/admin/create-post.js +2 -2
- package/test/routes/admin/post.js +2 -2
- package/test/routes/admin/repo-content.js +2 -2
- package/test/routes/marketing/contact.js +21 -24
- package/test/routes/payments/cancel.js +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,56 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
+
# [5.0.149] - 2026-03-14
|
|
18
|
+
### Added
|
|
19
|
+
- Modular email library (`libraries/email/`) — replaces monolithic `libraries/email.js` with provider-based architecture
|
|
20
|
+
- Marketing contact providers: SendGrid (`providers/sendgrid.js`) and Beehiiv (`providers/beehiiv.js`) with add/remove/sync operations
|
|
21
|
+
- Email validation library (`libraries/email/validation.js`) — format, local-part, and disposable domain checks with configurable check selection
|
|
22
|
+
- Runtime SendGrid custom field ID resolution — fetches field definitions from API and caches name→ID mapping (no hardcoded IDs)
|
|
23
|
+
- 15 marketing custom fields synced to SendGrid/Beehiiv: brand, auth, subscription, payment, and attribution data
|
|
24
|
+
- `PUT /marketing/contact` admin route for triggering contact sync by UID
|
|
25
|
+
- `POST /marketing/contact` now syncs full custom field data on signup
|
|
26
|
+
- Marketing contact sync in payment webhook pipeline — subscription changes automatically update SendGrid/Beehiiv custom fields
|
|
27
|
+
- `mailer.sync(uid)` method for full contact re-sync from Firestore user doc
|
|
28
|
+
- `resolveFieldValues()` in `constants.js` — SSOT for building custom field payloads from user docs
|
|
29
|
+
- `User.resolveSubscription()` now includes `everPaid` field for marketing segmentation
|
|
30
|
+
- `TEST_EXTENDED_MODE` propagation from emulator to Firebase function workers
|
|
31
|
+
- `TEST_EXTENDED_MODE` mismatch detection between test runner and emulator via health check
|
|
32
|
+
- Email queue cron processor (`cron/frequent/email-queue.js`) — processes deferred emails every 10 minutes via the full `email.send()` pipeline
|
|
33
|
+
- Feedback route review URL builder with full site URLs
|
|
34
|
+
- 28 email validation unit tests, 7 marketing contact route tests, 5 marketing lifecycle integration tests
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
- Refactored `libraries/email.js` into modular `libraries/email/` directory (index, constants, validation, providers)
|
|
38
|
+
- `POST /marketing/contact` validation now uses configurable check selection instead of boolean `skipValidation`
|
|
39
|
+
- `DELETE /marketing/contact` uses new provider-based removal
|
|
40
|
+
- Marketing contact schemas updated to match new validation options
|
|
41
|
+
- `on-delete` auth event now uses new email library for contact removal
|
|
42
|
+
- `saveToEmailQueue` now stores raw settings instead of pre-built SendGrid email, so queued emails re-enter the full build pipeline
|
|
43
|
+
- Renamed `email-queue` collection to `emails-queue`
|
|
44
|
+
- Feedback schema: renamed `like`/`dislike` fields to `positive`/`negative`
|
|
45
|
+
- Feedback review prompt logic now checks total positive feedback length (50+ chars)
|
|
46
|
+
- Renamed `GET /app` route to `GET /brand` (completes app→brand migration)
|
|
47
|
+
|
|
48
|
+
### Removed
|
|
49
|
+
- Monolithic `libraries/email.js` — replaced by modular `libraries/email/` directory
|
|
50
|
+
|
|
51
|
+
# [5.0.148] - 2026-03-14
|
|
52
|
+
### Added
|
|
53
|
+
- Semantic email sender system — pass `sender: 'orders'` to `Email.send()` to auto-resolve from address, display name, and SendGrid ASM group
|
|
54
|
+
- 7 sender categories: `orders`, `hello`, `account`, `marketing`, `security`, `newsletter`, `internal`
|
|
55
|
+
- 7 dedicated SendGrid ASM groups for granular unsubscribe control
|
|
56
|
+
- 4 new email tests for sender resolution, override precedence, and fallback behavior
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
- Migrated all email call sites from `group:` to `sender:` parameter
|
|
60
|
+
- `sendOrderEmail()` now accepts optional `sender` parameter (defaults to `'orders'`)
|
|
61
|
+
- `replyTo` now defaults to the resolved from address instead of brand default
|
|
62
|
+
|
|
63
|
+
# [5.0.147] - 2026-03-14
|
|
64
|
+
### Added
|
|
65
|
+
- 24-hour cancellation guard on `POST /payments/cancel` — blocks cancellations for subscriptions younger than 24 hours
|
|
66
|
+
|
|
17
67
|
# [5.0.146] - 2026-03-13
|
|
18
68
|
### Added
|
|
19
69
|
- Promo discount support in payment analytics — `resolveActualValue()` computes effective price accounting for trials ($0) and percentage discounts
|
package/CLAUDE.md
CHANGED
|
@@ -1058,6 +1058,32 @@ Key rules:
|
|
|
1058
1058
|
|
|
1059
1059
|
The `test` processor generates Stripe-shaped data and auto-fires webhooks to the local server. Only available in non-production environments. Use `processor: 'test'` in intent requests during testing. The test webhook processor delegates to Stripe's parser since it generates Stripe-shaped payloads.
|
|
1060
1060
|
|
|
1061
|
+
## Marketing Custom Fields
|
|
1062
|
+
|
|
1063
|
+
BEM syncs user data to marketing providers (SendGrid, Beehiiv) as custom fields. Field definitions live in a single dictionary; OMEGA provisions them in each provider.
|
|
1064
|
+
|
|
1065
|
+
### Adding a New Field
|
|
1066
|
+
|
|
1067
|
+
1. Add the field to `FIELDS` in `src/manager/libraries/email/constants.js` — the key IS the field name in both providers. Set `source`, `path`, `type`.
|
|
1068
|
+
2. Add matching entry in OMEGA's `src/lib/bem-fields.js` with `name`, `display`, `type`. If Beehiiv has it built-in (e.g., country, utm_source), set `beehiivBuiltIn: true`.
|
|
1069
|
+
3. Run OMEGA: `npm start -- --service=sendgrid,beehiiv --brand=X`
|
|
1070
|
+
4. BEM resolves field IDs at runtime — no provider code changes needed.
|
|
1071
|
+
|
|
1072
|
+
### How It Works
|
|
1073
|
+
|
|
1074
|
+
- **SendGrid**: `resolveFieldIds()` fetches field definitions from the SendGrid API, builds a name-to-ID cache, and maps values to SendGrid's auto-generated IDs (e.g., `brand_id` maps to `e35_T`).
|
|
1075
|
+
- **Beehiiv**: BEM uses the key directly as the custom field name — no ID resolution needed.
|
|
1076
|
+
- **OMEGA**: The `ensure/custom-fields.js` handlers are idempotent — they fetch existing fields and only create what is missing.
|
|
1077
|
+
|
|
1078
|
+
### Key Files
|
|
1079
|
+
|
|
1080
|
+
| Purpose | File |
|
|
1081
|
+
|---------|------|
|
|
1082
|
+
| Field dictionary (BEM SSOT) | `src/manager/libraries/email/constants.js` |
|
|
1083
|
+
| Field provisioning list (OMEGA SSOT) | `omega-manager/src/lib/bem-fields.js` |
|
|
1084
|
+
| SendGrid provisioning | `omega-manager/src/services/sendgrid/ensure/custom-fields.js` |
|
|
1085
|
+
| Beehiiv provisioning | `omega-manager/src/services/beehiiv/ensure/custom-fields.js` |
|
|
1086
|
+
|
|
1061
1087
|
## Common Mistakes to Avoid
|
|
1062
1088
|
|
|
1063
1089
|
1. **Don't modify Manager internals directly** - Use factory methods and public APIs
|
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@ const JSON5 = require('json5');
|
|
|
7
7
|
const powertools = require('node-powertools');
|
|
8
8
|
const WatchCommand = require('./watch');
|
|
9
9
|
const { DEFAULT_EMULATOR_PORTS } = require('./setup-tests/emulator-config');
|
|
10
|
+
const { EXTENDED_MODE_WARNING } = require('../../test/utils/extended-mode-warning');
|
|
10
11
|
|
|
11
12
|
class EmulatorCommand extends BaseCommand {
|
|
12
13
|
async execute() {
|
|
@@ -15,9 +16,9 @@ class EmulatorCommand extends BaseCommand {
|
|
|
15
16
|
|
|
16
17
|
// Warn if TEST_EXTENDED_MODE is enabled
|
|
17
18
|
if (process.env.TEST_EXTENDED_MODE) {
|
|
18
|
-
this.log(chalk.yellow.bold(
|
|
19
|
-
this.log(chalk.yellow(
|
|
20
|
-
this.log(
|
|
19
|
+
this.log(chalk.yellow.bold(`\n ${EXTENDED_MODE_WARNING[0]}`));
|
|
20
|
+
EXTENDED_MODE_WARNING.slice(1).forEach((line) => this.log(chalk.yellow(` ${line}`)));
|
|
21
|
+
this.log('');
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
// Start BEM watcher in background
|
|
@@ -59,13 +60,22 @@ class EmulatorCommand extends BaseCommand {
|
|
|
59
60
|
// hosting is included so localhost:5002 rewrites work (e.g., /backend-manager -> bm_api)
|
|
60
61
|
// pubsub is included so scheduled functions (bm_cronDaily) can be triggered in tests
|
|
61
62
|
// Use double quotes for command wrapper since the command may contain single quotes (JSON strings)
|
|
62
|
-
const
|
|
63
|
+
const envPrefix = process.env.TEST_EXTENDED_MODE
|
|
64
|
+
? 'BEM_TESTING=true TEST_EXTENDED_MODE=true'
|
|
65
|
+
: 'BEM_TESTING=true';
|
|
66
|
+
const emulatorCommand = `${envPrefix} firebase emulators:exec --only functions,firestore,auth,database,hosting,pubsub --ui "${command}"`;
|
|
63
67
|
|
|
64
68
|
// Set up log file in the project directory
|
|
65
69
|
const logPath = path.join(projectDir, 'functions', 'emulator.log');
|
|
66
70
|
const logStream = fs.createWriteStream(logPath, { flags: 'w' });
|
|
67
71
|
const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
|
|
68
72
|
|
|
73
|
+
// Write pre-emulator info to log file
|
|
74
|
+
if (process.env.TEST_EXTENDED_MODE) {
|
|
75
|
+
EXTENDED_MODE_WARNING.forEach((line) => logStream.write(`${line}\n`));
|
|
76
|
+
logStream.write('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
|
|
70
80
|
|
|
71
81
|
await powertools.execute(emulatorCommand, {
|
package/src/cli/commands/test.js
CHANGED
|
@@ -103,15 +103,13 @@ class TestCommand extends BaseCommand {
|
|
|
103
103
|
return null;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
// Derive
|
|
107
|
-
const projectId = config.firebaseConfig?.projectId;
|
|
106
|
+
// Derive computed values (not in config file)
|
|
108
107
|
const backendManagerKey = argv.key || process.env.BACKEND_MANAGER_KEY;
|
|
109
|
-
const appId = config.brand?.id;
|
|
110
108
|
const contactEmail = config.brand?.contact?.email || '';
|
|
111
109
|
const domain = contactEmail.includes('@') ? contactEmail.split('@')[1] : '';
|
|
112
110
|
|
|
113
111
|
// Validate required configuration
|
|
114
|
-
if (!projectId) {
|
|
112
|
+
if (!config.firebaseConfig?.projectId) {
|
|
115
113
|
this.logError('Error: Missing firebaseConfig.projectId in backend-manager-config.json');
|
|
116
114
|
return null;
|
|
117
115
|
}
|
|
@@ -122,7 +120,7 @@ class TestCommand extends BaseCommand {
|
|
|
122
120
|
return null;
|
|
123
121
|
}
|
|
124
122
|
|
|
125
|
-
if (!
|
|
123
|
+
if (!config.brand?.id) {
|
|
126
124
|
this.logError('Error: Missing brand.id in backend-manager-config.json');
|
|
127
125
|
return null;
|
|
128
126
|
}
|
|
@@ -132,15 +130,11 @@ class TestCommand extends BaseCommand {
|
|
|
132
130
|
return null;
|
|
133
131
|
}
|
|
134
132
|
|
|
135
|
-
// Pass entire config +
|
|
133
|
+
// Pass entire config + computed values not in config file
|
|
136
134
|
return {
|
|
137
135
|
...config,
|
|
138
|
-
appId,
|
|
139
|
-
projectId,
|
|
140
136
|
backendManagerKey,
|
|
141
137
|
domain,
|
|
142
|
-
brandName: config.brand?.name,
|
|
143
|
-
githubRepoWebsite: config.github?.repo_website,
|
|
144
138
|
};
|
|
145
139
|
}
|
|
146
140
|
|
|
@@ -4,7 +4,7 @@ const moment = require('moment');
|
|
|
4
4
|
const JSON5 = require('json5');
|
|
5
5
|
|
|
6
6
|
const PROMPT = `
|
|
7
|
-
Company: {
|
|
7
|
+
Company: {brand.brand.name}: {brand.brand.description}
|
|
8
8
|
Date: {date}
|
|
9
9
|
Instructions: {prompt}
|
|
10
10
|
|
|
@@ -29,11 +29,11 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
|
29
29
|
// Set post ID
|
|
30
30
|
postId = moment().unix();
|
|
31
31
|
|
|
32
|
-
// Build
|
|
33
|
-
const
|
|
32
|
+
// Build brand config from local config
|
|
33
|
+
const brandConfig = buildBrandConfig(Manager.config);
|
|
34
34
|
|
|
35
35
|
// Log
|
|
36
|
-
assistant.log('
|
|
36
|
+
assistant.log('Brand config', brandConfig);
|
|
37
37
|
|
|
38
38
|
// Get settings
|
|
39
39
|
const settingsArray = powertools.arrayify(Manager.config.ghostii);
|
|
@@ -48,22 +48,22 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
|
48
48
|
settings.chance = settings.chance || 1.0;
|
|
49
49
|
settings.author = settings.author || undefined;
|
|
50
50
|
|
|
51
|
-
// Resolve
|
|
52
|
-
if (settings.
|
|
53
|
-
// Cross-
|
|
54
|
-
settings.
|
|
51
|
+
// Resolve brand data for this ghostii item
|
|
52
|
+
if (settings.brand && settings.brandUrl) {
|
|
53
|
+
// Cross-brand: fetch from the other project's /brand endpoint
|
|
54
|
+
settings.brand = await fetchRemoteBrand(settings.brandUrl).catch((e) => e);
|
|
55
55
|
|
|
56
|
-
if (settings.
|
|
57
|
-
assistant.error('Error fetching remote
|
|
56
|
+
if (settings.brand instanceof Error) {
|
|
57
|
+
assistant.error('Error fetching remote brand data', settings.brand);
|
|
58
58
|
continue;
|
|
59
59
|
}
|
|
60
60
|
} else {
|
|
61
|
-
// Same-
|
|
62
|
-
settings.
|
|
61
|
+
// Same-brand: use local config
|
|
62
|
+
settings.brand = brandConfig;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
// Log
|
|
66
|
-
assistant.log(`Settings (
|
|
66
|
+
assistant.log(`Settings (brand=${settings.brand.brand.id})`, settings);
|
|
67
67
|
|
|
68
68
|
// Quit if articles are disabled
|
|
69
69
|
if (!settings.articles || !settings.sources.length) {
|
|
@@ -90,19 +90,19 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
|
90
90
|
};
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* Build
|
|
93
|
+
* Build brand config from Manager.config (same shape as /brand endpoint response)
|
|
94
94
|
*/
|
|
95
|
-
function
|
|
96
|
-
const { buildPublicConfig } = require(require('path').join(__dirname, '..', '..', 'routes', '
|
|
95
|
+
function buildBrandConfig(config) {
|
|
96
|
+
const { buildPublicConfig } = require(require('path').join(__dirname, '..', '..', 'routes', 'brand', 'get.js'));
|
|
97
97
|
|
|
98
98
|
return buildPublicConfig(config);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
* Fetch
|
|
102
|
+
* Fetch brand data from a remote BEM project's /brand endpoint
|
|
103
103
|
*/
|
|
104
|
-
function
|
|
105
|
-
return fetch(`${
|
|
104
|
+
function fetchRemoteBrand(brandUrl) {
|
|
105
|
+
return fetch(`${brandUrl}/backend-manager/brand`, {
|
|
106
106
|
timeout: 30000,
|
|
107
107
|
tries: 3,
|
|
108
108
|
response: 'json',
|
|
@@ -113,7 +113,7 @@ async function harvest(assistant, settings) {
|
|
|
113
113
|
const date = moment().format('MMMM YYYY');
|
|
114
114
|
|
|
115
115
|
// Log
|
|
116
|
-
assistant.log(`harvest(): Starting ${settings.
|
|
116
|
+
assistant.log(`harvest(): Starting ${settings.brand.brand.id}...`);
|
|
117
117
|
|
|
118
118
|
// Process the number of sources in the settings
|
|
119
119
|
for (let index = 0; index < settings.articles; index++) {
|
|
@@ -209,16 +209,16 @@ function requestGhostii(settings, content) {
|
|
|
209
209
|
description: content,
|
|
210
210
|
insertLinks: true,
|
|
211
211
|
headerImageUrl: 'unsplash',
|
|
212
|
-
url: settings.
|
|
212
|
+
url: settings.brand.brand.url,
|
|
213
213
|
sectionQuantity: powertools.random(3, 6, { mode: 'gaussian' }),
|
|
214
|
-
feedUrl: `${settings.
|
|
214
|
+
feedUrl: `${settings.brand.brand.url}/feeds/posts.json`,
|
|
215
215
|
links: settings.links,
|
|
216
216
|
},
|
|
217
217
|
});
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
function uploadPost(assistant, settings, article) {
|
|
221
|
-
const apiUrl = `https://api.${(settings.
|
|
221
|
+
const apiUrl = `https://api.${(settings.brand.brand.url || '').replace(/^https?:\/\//, '')}`;
|
|
222
222
|
return fetch(`${apiUrl}/backend-manager/admin/post`, {
|
|
223
223
|
method: 'POST',
|
|
224
224
|
timeout: 90000,
|
|
@@ -236,8 +236,8 @@ function uploadPost(assistant, settings, article) {
|
|
|
236
236
|
categories: article.categories,
|
|
237
237
|
tags: article.keywords,
|
|
238
238
|
path: 'ghostii',
|
|
239
|
-
githubUser: settings.
|
|
240
|
-
githubRepo: settings.
|
|
239
|
+
githubUser: settings.brand.github.user,
|
|
240
|
+
githubRepo: settings.brand.github.repo,
|
|
241
241
|
},
|
|
242
242
|
});
|
|
243
243
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
const powertools = require('node-powertools');
|
|
2
2
|
const { REMINDER_DELAYS, COLLECTION } = require('../../libraries/abandoned-cart-config.js');
|
|
3
|
-
const { sendOrderEmail } = require('../../events/firestore/payments-webhooks/transitions/send-email.js');
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* Abandoned cart reminder cron job
|
|
@@ -26,6 +25,7 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
|
26
25
|
|
|
27
26
|
assistant.log(`Processing ${snapshot.size} abandoned cart reminder(s)...`);
|
|
28
27
|
|
|
28
|
+
const email = Manager.Email(assistant);
|
|
29
29
|
let sent = 0;
|
|
30
30
|
let completed = 0;
|
|
31
31
|
let skipped = 0;
|
|
@@ -68,12 +68,12 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
|
68
68
|
// Send reminder email
|
|
69
69
|
assistant.log(`Sending abandoned cart reminder #${reminderIndex + 1} to uid=${uid}, product=${data.productId}`);
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
email.send({
|
|
72
|
+
sender: 'marketing',
|
|
73
|
+
to: userDoc,
|
|
72
74
|
template: 'main/order/abandoned-cart',
|
|
73
75
|
subject: `Complete your ${brandName} ${productName} checkout`,
|
|
74
76
|
categories: ['order/abandoned-cart', `order/abandoned-cart/reminder-${reminderIndex + 1}`],
|
|
75
|
-
userDoc,
|
|
76
|
-
assistant,
|
|
77
77
|
copy: false,
|
|
78
78
|
data: {
|
|
79
79
|
abandonedCart: {
|
|
@@ -87,7 +87,9 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
|
87
87
|
checkoutUrl: checkoutUrl,
|
|
88
88
|
},
|
|
89
89
|
},
|
|
90
|
-
})
|
|
90
|
+
})
|
|
91
|
+
.then(() => assistant.log(`Abandoned cart email sent for uid=${uid}`))
|
|
92
|
+
.catch((e) => assistant.error(`Abandoned cart email failed for uid=${uid}: ${e.message}`));
|
|
91
93
|
|
|
92
94
|
sent++;
|
|
93
95
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const moment = require('moment');
|
|
2
|
+
|
|
3
|
+
// Must match the SEND_AT_LIMIT in email.js
|
|
4
|
+
const SEND_AT_LIMIT = 71;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Email queue processor cron job
|
|
8
|
+
*
|
|
9
|
+
* Picks up emails from the `emails-queue` collection that are now within
|
|
10
|
+
* SendGrid's 71-hour scheduling window and sends them back through the
|
|
11
|
+
* full email.send() pipeline (build, resolve recipients, send, audit trail).
|
|
12
|
+
*
|
|
13
|
+
* Emails land in this queue when their `sendAt` exceeds the 71-hour limit
|
|
14
|
+
* at the time of the original send() call (see email.js → saveToEmailQueue).
|
|
15
|
+
*/
|
|
16
|
+
module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
17
|
+
const { admin } = libraries;
|
|
18
|
+
const cutoff = moment().add(SEND_AT_LIMIT, 'hours').unix();
|
|
19
|
+
|
|
20
|
+
// Query emails that are now within the SendGrid scheduling window
|
|
21
|
+
const snapshot = await admin.firestore()
|
|
22
|
+
.collection('emails-queue')
|
|
23
|
+
.where('sendAt', '<=', cutoff)
|
|
24
|
+
.limit(100)
|
|
25
|
+
.get();
|
|
26
|
+
|
|
27
|
+
if (snapshot.empty) {
|
|
28
|
+
assistant.log('No queued emails ready to send');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
assistant.log(`Processing ${snapshot.size} queued email(s)...`);
|
|
33
|
+
|
|
34
|
+
const email = Manager.Email(assistant);
|
|
35
|
+
|
|
36
|
+
const results = await Promise.allSettled(snapshot.docs.map(async (doc) => {
|
|
37
|
+
const { settings } = doc.data();
|
|
38
|
+
const emailId = doc.id;
|
|
39
|
+
|
|
40
|
+
const result = await email.send(settings);
|
|
41
|
+
assistant.log(`Queued email ${emailId} ${result.status}`);
|
|
42
|
+
|
|
43
|
+
await doc.ref.delete();
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const sent = results.filter(r => r.status === 'fulfilled').length;
|
|
47
|
+
const failed = results.filter(r => r.status === 'rejected').length;
|
|
48
|
+
|
|
49
|
+
for (const r of results) {
|
|
50
|
+
if (r.status === 'rejected') {
|
|
51
|
+
assistant.error(`Failed to send queued email: ${r.reason?.message}`, r.reason);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
assistant.log(`Completed! (${sent} sent, ${failed} failed)`);
|
|
56
|
+
};
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This function fires on every sign-in (including right after account creation).
|
|
5
5
|
* It updates last activity and sends sign-in analytics.
|
|
6
|
+
*
|
|
7
|
+
* TODO: Add mailer.sync(uid) here with 1x/day rate limit to keep marketing
|
|
8
|
+
* contact data (name, country, subscription fields) fresh between sessions.
|
|
6
9
|
*/
|
|
7
10
|
module.exports = async ({ Manager, assistant, user, context, libraries }) => {
|
|
8
11
|
const startTime = Date.now();
|
|
@@ -58,6 +58,14 @@ module.exports = async ({ Manager, assistant, user, context, libraries }) => {
|
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// Remove marketing contact from all providers (non-blocking)
|
|
62
|
+
if (user.email) {
|
|
63
|
+
const email = Manager.Email(assistant);
|
|
64
|
+
email.remove(user.email)
|
|
65
|
+
.then((r) => assistant.log('onDelete: Marketing remove:', r))
|
|
66
|
+
.catch((e) => assistant.error('onDelete: Marketing remove failed:', e));
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
// Send delete analytics (server-side only event)
|
|
62
70
|
Manager.Analytics({
|
|
63
71
|
assistant: assistant,
|
|
@@ -334,7 +334,8 @@ function sendDisputeEmail({ alert, match, result, alertId, assistant }) {
|
|
|
334
334
|
}
|
|
335
335
|
|
|
336
336
|
email.send({
|
|
337
|
-
|
|
337
|
+
sender: 'internal',
|
|
338
|
+
to: brandEmail,
|
|
338
339
|
subject: subject,
|
|
339
340
|
template: 'main/basic/card',
|
|
340
341
|
categories: ['order/dispute-alert'],
|
|
@@ -288,6 +288,15 @@ async function processPaymentEvent({ category, library, resource, resourceType,
|
|
|
288
288
|
if (isSubscription) {
|
|
289
289
|
await admin.firestore().doc(`users/${uid}`).set({ subscription: unified }, { merge: true });
|
|
290
290
|
assistant.log(`Updated users/${uid}.subscription: status=${unified.status}, product=${unified.product.id}`);
|
|
291
|
+
|
|
292
|
+
// Sync marketing contact with updated subscription data (non-blocking)
|
|
293
|
+
if (shouldRunHandlers) {
|
|
294
|
+
const email = Manager.Email(assistant);
|
|
295
|
+
const updatedUserDoc = { ...userData, subscription: unified };
|
|
296
|
+
email.sync(updatedUserDoc)
|
|
297
|
+
.then((r) => assistant.log('Marketing sync after payment:', r))
|
|
298
|
+
.catch((e) => assistant.error('Marketing sync after payment failed:', e));
|
|
299
|
+
}
|
|
291
300
|
}
|
|
292
301
|
|
|
293
302
|
// Write to payments-orders/{orderId}
|
|
@@ -12,41 +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 {object} options.userDoc - User document data (
|
|
15
|
+
* @param {object} options.userDoc - User document data (passed as `to` — email.js extracts email/name and user template data)
|
|
16
16
|
* @param {object} options.assistant - Assistant instance
|
|
17
17
|
*/
|
|
18
|
-
function sendOrderEmail({ template, subject, categories, data, userDoc, assistant, copy }) {
|
|
18
|
+
function sendOrderEmail({ template, subject, categories, data, userDoc, assistant, copy, sender = 'orders' }) {
|
|
19
19
|
const email = assistant.Manager.Email(assistant);
|
|
20
|
-
|
|
21
|
-
const userEmail = userDoc?.auth?.email;
|
|
22
|
-
const userName = userDoc?.personal?.name?.first;
|
|
23
20
|
const uid = userDoc?.auth?.uid;
|
|
24
21
|
|
|
25
|
-
if (!
|
|
22
|
+
if (!userDoc?.auth?.email) {
|
|
26
23
|
assistant.error(`sendOrderEmail(): No email found for uid=${uid}, skipping`);
|
|
27
24
|
return;
|
|
28
25
|
}
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
delete safeUser.oauth2;
|
|
34
|
-
delete safeUser.activity;
|
|
35
|
-
delete safeUser.affiliate;
|
|
36
|
-
delete safeUser.attribution;
|
|
37
|
-
delete safeUser.flags;
|
|
38
|
-
|
|
39
|
-
const settings = {
|
|
40
|
-
to: { email: userEmail, ...(userName && { name: userName }) },
|
|
27
|
+
email.send({
|
|
28
|
+
sender,
|
|
29
|
+
to: userDoc,
|
|
41
30
|
subject,
|
|
42
31
|
template,
|
|
43
32
|
categories,
|
|
44
33
|
copy: copy !== false,
|
|
45
|
-
user: safeUser,
|
|
46
34
|
data,
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
email.send(settings)
|
|
35
|
+
})
|
|
50
36
|
.then((result) => {
|
|
51
37
|
assistant.log(`sendOrderEmail(): Success template=${template}, uid=${uid}, status=${result.status}`);
|
|
52
38
|
})
|
|
@@ -38,7 +38,7 @@ Module.prototype.main = function () {
|
|
|
38
38
|
if (!doc.exists) {
|
|
39
39
|
await stats.set({
|
|
40
40
|
users: { total: 0 },
|
|
41
|
-
|
|
41
|
+
brand: Manager.config?.brand?.id || null,
|
|
42
42
|
});
|
|
43
43
|
data = { users: { total: 0 } };
|
|
44
44
|
}
|
|
@@ -102,7 +102,7 @@ Module.prototype.updateStats = function (existingData, update) {
|
|
|
102
102
|
// Set defaults
|
|
103
103
|
let error = null;
|
|
104
104
|
let newData = {
|
|
105
|
-
|
|
105
|
+
brand: self.Manager.config?.brand?.id || null,
|
|
106
106
|
};
|
|
107
107
|
|
|
108
108
|
// Log
|
|
@@ -131,7 +131,7 @@ Module.prototype.defaultize = function () {
|
|
|
131
131
|
email: {},
|
|
132
132
|
personalization: {},
|
|
133
133
|
signoff: {},
|
|
134
|
-
|
|
134
|
+
brand: {},
|
|
135
135
|
user: {},
|
|
136
136
|
data: {},
|
|
137
137
|
},
|
|
@@ -181,14 +181,14 @@ Module.prototype.defaultize = function () {
|
|
|
181
181
|
|
|
182
182
|
email.dynamicTemplateData.user = Manager.User(options.user).properties;
|
|
183
183
|
|
|
184
|
-
// Get
|
|
184
|
+
// Get brand configuration from Manager.config.brand (backend-manager-config.json)
|
|
185
185
|
const brand = Manager.config?.brand;
|
|
186
186
|
if (!brand) {
|
|
187
187
|
return reject(new Error('Missing brand configuration in backend-manager-config.json'));
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
// Build
|
|
191
|
-
const
|
|
190
|
+
// Build brand object for email template data
|
|
191
|
+
const brandData = {
|
|
192
192
|
id: brand.id,
|
|
193
193
|
name: brand.name,
|
|
194
194
|
url: brand.url,
|
|
@@ -196,11 +196,11 @@ Module.prototype.defaultize = function () {
|
|
|
196
196
|
images: brand.images || {},
|
|
197
197
|
};
|
|
198
198
|
|
|
199
|
-
if (!
|
|
199
|
+
if (!brandData.email) {
|
|
200
200
|
return reject(new Error('Missing brand.contact.email in backend-manager-config.json'));
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
email.dynamicTemplateData.
|
|
203
|
+
email.dynamicTemplateData.brand = brandData;
|
|
204
204
|
|
|
205
205
|
// Add user to recipients
|
|
206
206
|
email.to.push({
|
|
@@ -211,8 +211,8 @@ Module.prototype.defaultize = function () {
|
|
|
211
211
|
// Add carbon copy recipients
|
|
212
212
|
if (options.copy) {
|
|
213
213
|
email.cc.push({
|
|
214
|
-
email: email.dynamicTemplateData.
|
|
215
|
-
name: email.dynamicTemplateData.
|
|
214
|
+
email: email.dynamicTemplateData.brand.email,
|
|
215
|
+
name: email.dynamicTemplateData.brand.name,
|
|
216
216
|
});
|
|
217
217
|
email.bcc.push(
|
|
218
218
|
{
|
|
@@ -227,17 +227,17 @@ Module.prototype.defaultize = function () {
|
|
|
227
227
|
}
|
|
228
228
|
|
|
229
229
|
// Set email properties
|
|
230
|
-
email.replyTo = email.replyTo || email.dynamicTemplateData.
|
|
230
|
+
email.replyTo = email.replyTo || email.dynamicTemplateData.brand.email;
|
|
231
231
|
email.subject = email.subject || email.dynamicTemplateData.email.subject;
|
|
232
232
|
email.dynamicTemplateData.email.subject = email.dynamicTemplateData.email.subject || email.subject;
|
|
233
233
|
email.from = options.from || {
|
|
234
|
-
email: email.dynamicTemplateData.
|
|
235
|
-
name: email.dynamicTemplateData.
|
|
234
|
+
email: email.dynamicTemplateData.brand.email,
|
|
235
|
+
name: email.dynamicTemplateData.brand.name,
|
|
236
236
|
};
|
|
237
237
|
email.sendAt = options.sendAt;
|
|
238
238
|
|
|
239
239
|
// Set categories
|
|
240
|
-
email.categories = ['transactional', email.dynamicTemplateData.
|
|
240
|
+
email.categories = ['transactional', email.dynamicTemplateData.brand.id, ...options.categories];
|
|
241
241
|
|
|
242
242
|
// Remove duplicates from email lists
|
|
243
243
|
email.to = filter(email.to);
|
|
@@ -283,7 +283,7 @@ Module.prototype.defaultize = function () {
|
|
|
283
283
|
};
|
|
284
284
|
|
|
285
285
|
// Build unsubscribe URL
|
|
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.
|
|
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.brand.name}&appUrl=${email.dynamicTemplateData.brand.url}`;
|
|
287
287
|
email.dynamicTemplateData.email.categories = email.categories;
|
|
288
288
|
email.dynamicTemplateData.email.carbonCopy = options.copy;
|
|
289
289
|
|
|
@@ -306,7 +306,7 @@ Module.prototype.defaultize = function () {
|
|
|
306
306
|
|
|
307
307
|
// Clone and clean data for stringified version
|
|
308
308
|
const emailClonedData = _.cloneDeep(email.dynamicTemplateData);
|
|
309
|
-
emailClonedData.
|
|
309
|
+
emailClonedData.brand.sponsorships = {};
|
|
310
310
|
email.dynamicTemplateData._stringified = JSON.stringify(emailClonedData, null, 2);
|
|
311
311
|
|
|
312
312
|
return resolve(email);
|