backend-manager 5.0.157 → 5.0.159
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 +32 -0
- package/CLAUDE.md +135 -0
- package/README.md +15 -0
- package/package.json +1 -1
- package/src/cli/commands/setup-tests/helpers/seed-campaigns.js +147 -24
- package/src/manager/cron/daily/marketing-newsletter-generate.js +118 -0
- package/src/manager/cron/frequent/marketing-campaigns.js +7 -1
- package/src/manager/libraries/email/constants.js +6 -1
- package/src/manager/libraries/email/generators/newsletter.js +184 -0
- package/src/manager/libraries/email/marketing/index.js +66 -14
- package/src/manager/libraries/email/utm.js +2 -2
- package/src/manager/libraries/payment/discount-codes.js +58 -6
- package/src/manager/routes/admin/cron/post.js +1 -1
- package/src/manager/routes/payments/discount/get.js +2 -2
- package/src/manager/routes/payments/intent/post.js +1 -1
- package/src/manager/schemas/marketing/campaign/post.js +2 -0
- package/templates/backend-manager-config.json +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,38 @@ 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.159] - 2026-03-18
|
|
18
|
+
### Added
|
|
19
|
+
- Audience-specific email discount codes: `UPGRADE15`, `COMEBACK20`, `MISSYOU25`, `TRYAGAIN10` with eligibility validation per user
|
|
20
|
+
- `{discount.code}` and `{discount.percent}` campaign template variables
|
|
21
|
+
- `test: true` flag on campaign route — sends real Single Send to `test_admin` segment only
|
|
22
|
+
- `test_admin` segment in SSOT (targets `hello@itwcreativeworks.com`)
|
|
23
|
+
- `trial_claimed` custom field (`user_subscription_trial_claimed`) for marketing sync
|
|
24
|
+
- `subscription_churned_paid` and `subscription_churned_trial` segments (replaces `subscription_churned`)
|
|
25
|
+
- 4 audience-specific recurring sale seed campaigns with tailored messaging + discount codes
|
|
26
|
+
- Full marketing campaign system documentation in CLAUDE.md, README.md, and BEM:patterns skill
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- Template variable resolution now recursive — walks all string values in settings (future-proof)
|
|
30
|
+
- UTM values resolved through template vars (`{holiday.name}_sale` → `black_friday_sale`)
|
|
31
|
+
- UTM sanitizer strips apostrophes before underscore conversion
|
|
32
|
+
- Payment intent + discount routes now pass user object for discount eligibility checking
|
|
33
|
+
- Discount code `validate()` accepts optional user param for eligibility checks (backwards compatible)
|
|
34
|
+
|
|
35
|
+
# [5.0.158] - 2026-03-17
|
|
36
|
+
### Added
|
|
37
|
+
- Newsletter generator system (`libraries/email/generators/newsletter.js`) — fetches sources from parent server, AI assembles branded content with subject/preheader
|
|
38
|
+
- Daily pre-generation cron (`cron/daily/marketing-newsletter-generate.js`) — generates newsletter content 24 hours before sendAt for calendar review
|
|
39
|
+
- `marketing.newsletter.enabled` and `marketing.newsletter.categories` config options
|
|
40
|
+
- `generator` field on campaign docs — tells cron to run content generation instead of sending directly
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
- Seed campaign IDs are now timing-agnostic: `_recurring-sale`, `_recurring-newsletter`
|
|
44
|
+
- Recurrence timing removed from enforced fields — consuming projects can freely change schedule
|
|
45
|
+
- Newsletter subject/preheader are now AI-generated (empty in seed template)
|
|
46
|
+
- Frequent cron skips generator campaigns (handled by daily pre-generation cron)
|
|
47
|
+
- Admin cron route now passes `libraries` to cron handlers
|
|
48
|
+
|
|
17
49
|
# [5.0.157] - 2026-03-17
|
|
18
50
|
### Added
|
|
19
51
|
- Campaign template variables via `powertools.template()` — `{brand.name}`, `{season.name}`, `{holiday.name}`, `{date.month}`, `{date.year}`, `{date.full}`
|
package/CLAUDE.md
CHANGED
|
@@ -1084,6 +1084,141 @@ BEM syncs user data to marketing providers (SendGrid, Beehiiv) as custom fields.
|
|
|
1084
1084
|
| SendGrid provisioning | `omega-manager/src/services/sendgrid/ensure/custom-fields.js` |
|
|
1085
1085
|
| Beehiiv provisioning | `omega-manager/src/services/beehiiv/ensure/custom-fields.js` |
|
|
1086
1086
|
|
|
1087
|
+
## Marketing Campaign System
|
|
1088
|
+
|
|
1089
|
+
### Campaign CRUD Routes (admin-only)
|
|
1090
|
+
|
|
1091
|
+
| Method | Endpoint | Purpose |
|
|
1092
|
+
|--------|----------|---------|
|
|
1093
|
+
| POST | `/marketing/campaign` | Create campaign (immediate or scheduled) |
|
|
1094
|
+
| GET | `/marketing/campaign` | List/filter campaigns by date range, status, type |
|
|
1095
|
+
| PUT | `/marketing/campaign` | Update pending campaigns (reschedule, edit) |
|
|
1096
|
+
| DELETE | `/marketing/campaign` | Delete pending campaigns |
|
|
1097
|
+
|
|
1098
|
+
### Firestore Collection: `marketing-campaigns/{id}`
|
|
1099
|
+
|
|
1100
|
+
```javascript
|
|
1101
|
+
{
|
|
1102
|
+
settings: { name, subject, preheader, content, template, sender, segments, excludeSegments, ... },
|
|
1103
|
+
sendAt: 1743465600, // Unix timestamp (any format accepted, normalized on create)
|
|
1104
|
+
status: 'pending', // pending | sent | failed
|
|
1105
|
+
type: 'email', // email | push
|
|
1106
|
+
recurrence: { pattern, hour, day }, // Optional — makes it recurring
|
|
1107
|
+
generator: 'newsletter', // Optional — runs content generator before sending
|
|
1108
|
+
recurringId: '_recurring-sale', // Present on history docs (links to parent template)
|
|
1109
|
+
generatedFrom: '_recurring-newsletter', // Present on generated docs
|
|
1110
|
+
results: { sendgrid: {...}, beehiiv: {...} },
|
|
1111
|
+
metadata: { created: {...}, updated: {...} },
|
|
1112
|
+
}
|
|
1113
|
+
```
|
|
1114
|
+
|
|
1115
|
+
### Campaign Types
|
|
1116
|
+
|
|
1117
|
+
- **Email**: dispatches to SendGrid (Single Send) + Beehiiv (Post) via `mailer.sendCampaign()`
|
|
1118
|
+
- **Push**: dispatches to FCM via `notification.send()` (shared library)
|
|
1119
|
+
- Content is **markdown** — converted to HTML at send time. Template variables resolved before conversion.
|
|
1120
|
+
|
|
1121
|
+
### Recurring Campaigns
|
|
1122
|
+
|
|
1123
|
+
Campaigns with a `recurrence` field repeat automatically:
|
|
1124
|
+
- Cron fires → creates a **history doc** (same collection, `recurringId` set) → advances `sendAt` to next occurrence
|
|
1125
|
+
- Status stays `pending` on the recurring template, history docs are `sent`/`failed`
|
|
1126
|
+
- `_` prefix on IDs groups them at top of Firestore console
|
|
1127
|
+
|
|
1128
|
+
Recurrence patterns: `daily`, `weekly`, `monthly`, `quarterly`, `yearly`
|
|
1129
|
+
|
|
1130
|
+
### Generator Campaigns
|
|
1131
|
+
|
|
1132
|
+
Campaigns with a `generator` field don't send directly. A daily cron pre-generates content 24 hours before `sendAt`:
|
|
1133
|
+
1. Daily cron finds generator campaigns due within 24 hours
|
|
1134
|
+
2. Runs the generator module (e.g., `generators/newsletter.js`)
|
|
1135
|
+
3. Creates a NEW standalone `pending` campaign with generated content
|
|
1136
|
+
4. Advances the recurring template's `sendAt`
|
|
1137
|
+
5. Generated campaign appears on calendar for review, sent by frequent cron when due
|
|
1138
|
+
|
|
1139
|
+
### Template Variables
|
|
1140
|
+
|
|
1141
|
+
Resolved at send time via `powertools.template()`. Single braces `{var}` for campaign-level, double `{{var}}` for SendGrid template-level.
|
|
1142
|
+
|
|
1143
|
+
| Variable | Example Output |
|
|
1144
|
+
|----------|---------------|
|
|
1145
|
+
| `{brand.name}` | Somiibo |
|
|
1146
|
+
| `{brand.id}` | somiibo |
|
|
1147
|
+
| `{brand.url}` | https://somiibo.com |
|
|
1148
|
+
| `{season.name}` | Winter, Spring, Summer, Fall |
|
|
1149
|
+
| `{holiday.name}` | Black Friday, Christmas, Valentine's Day, etc. |
|
|
1150
|
+
| `{date.month}` | November |
|
|
1151
|
+
| `{date.year}` | 2026 |
|
|
1152
|
+
| `{date.full}` | March 17, 2026 |
|
|
1153
|
+
|
|
1154
|
+
### UTM Auto-Tagging
|
|
1155
|
+
|
|
1156
|
+
`libraries/email/utm.js` scans HTML for `<a href>` matching the brand's domain and appends UTM params. Applied to both marketing campaigns and transactional emails.
|
|
1157
|
+
|
|
1158
|
+
Defaults: `utm_source=brand.id`, `utm_medium=email`, `utm_campaign=name`, `utm_content=type`. Override via `settings.utm` object.
|
|
1159
|
+
|
|
1160
|
+
### Segments SSOT
|
|
1161
|
+
|
|
1162
|
+
`SEGMENTS` dictionary in `constants.js` — 22 segment definitions. OMEGA creates them in SendGrid, BEM resolves keys to provider IDs at runtime via `resolveSegmentIds()` (cached).
|
|
1163
|
+
|
|
1164
|
+
| Category | Segments |
|
|
1165
|
+
|----------|----------|
|
|
1166
|
+
| Subscription (9) | `subscription_free`, `subscription_paid`, `subscription_trialing`, `subscription_cancelling`, `subscription_suspended`, `subscription_cancelled`, `subscription_churned`, `subscription_ever_paid`, `subscription_never_paid` |
|
|
1167
|
+
| Lifecycle (5) | `lifecycle_7d`, `lifecycle_30d`, `lifecycle_90d`, `lifecycle_6m`, `lifecycle_1y` |
|
|
1168
|
+
| Engagement (5) | `engagement_active_30d`, `engagement_active_90d`, `engagement_inactive_90d`, `engagement_inactive_5m`, `engagement_inactive_6m` |
|
|
1169
|
+
|
|
1170
|
+
Campaigns reference segments by SSOT key: `segments: ['subscription_free']`. Auto-translated to provider IDs.
|
|
1171
|
+
|
|
1172
|
+
### Contact Pruning
|
|
1173
|
+
|
|
1174
|
+
`cron/daily/marketing-prune.js` — runs 1st of each month. Two stages:
|
|
1175
|
+
1. **Re-engagement**: send email to `engagement_inactive_5m` (excluding `engagement_inactive_6m`)
|
|
1176
|
+
2. **Prune**: export `engagement_inactive_6m` contacts, bulk delete from SendGrid + Beehiiv. Never prunes paying customers.
|
|
1177
|
+
|
|
1178
|
+
### Newsletter Generator
|
|
1179
|
+
|
|
1180
|
+
`generators/newsletter.js` — pulls content from parent server, AI assembles branded newsletter.
|
|
1181
|
+
1. Fetch sources: `GET {parentUrl}/newsletter/sources?category=X&claimFor=brandId` (atomic claim)
|
|
1182
|
+
2. AI assembly: GPT-4o-mini generates subject, preheader, and markdown content
|
|
1183
|
+
3. Mark used: `PUT {parentUrl}/newsletter/sources` per source
|
|
1184
|
+
|
|
1185
|
+
### Seed Campaigns
|
|
1186
|
+
|
|
1187
|
+
Created by `npx bm setup` (idempotent, enforced fields checked every run):
|
|
1188
|
+
|
|
1189
|
+
| ID | Type | Description |
|
|
1190
|
+
|----|------|-------------|
|
|
1191
|
+
| `_recurring-sale` | email (sendgrid) | Seasonal sale targeting free + cancelled + churned users |
|
|
1192
|
+
| `_recurring-newsletter` | email (beehiiv) | AI-generated newsletter from parent server sources |
|
|
1193
|
+
|
|
1194
|
+
### Marketing Config
|
|
1195
|
+
|
|
1196
|
+
```javascript
|
|
1197
|
+
marketing: {
|
|
1198
|
+
sendgrid: { enabled: true },
|
|
1199
|
+
beehiiv: { enabled: false, publicationId: 'pub_xxxxx' },
|
|
1200
|
+
prune: { enabled: true },
|
|
1201
|
+
newsletter: { enabled: false, categories: ['social-media', 'marketing'] },
|
|
1202
|
+
}
|
|
1203
|
+
```
|
|
1204
|
+
|
|
1205
|
+
### Key Marketing Files
|
|
1206
|
+
|
|
1207
|
+
| Purpose | File |
|
|
1208
|
+
|---------|------|
|
|
1209
|
+
| Marketing library | `src/manager/libraries/email/marketing/index.js` |
|
|
1210
|
+
| Field + segment SSOT | `src/manager/libraries/email/constants.js` |
|
|
1211
|
+
| UTM tagging | `src/manager/libraries/email/utm.js` |
|
|
1212
|
+
| Newsletter generator | `src/manager/libraries/email/generators/newsletter.js` |
|
|
1213
|
+
| Notification library | `src/manager/libraries/notification.js` |
|
|
1214
|
+
| SendGrid provider | `src/manager/libraries/email/providers/sendgrid.js` |
|
|
1215
|
+
| Beehiiv provider | `src/manager/libraries/email/providers/beehiiv.js` |
|
|
1216
|
+
| Campaign routes | `src/manager/routes/marketing/campaign/{get,post,put,delete}.js` |
|
|
1217
|
+
| Campaign cron | `src/manager/cron/frequent/marketing-campaigns.js` |
|
|
1218
|
+
| Newsletter pre-gen cron | `src/manager/cron/daily/marketing-newsletter-generate.js` |
|
|
1219
|
+
| Pruning cron | `src/manager/cron/daily/marketing-prune.js` |
|
|
1220
|
+
| Seed campaigns | `src/cli/commands/setup-tests/helpers/seed-campaigns.js` |
|
|
1221
|
+
|
|
1087
1222
|
## Common Mistakes to Avoid
|
|
1088
1223
|
|
|
1089
1224
|
1. **Don't modify Manager internals directly** - Use factory methods and public APIs
|
package/README.md
CHANGED
|
@@ -404,6 +404,21 @@ Job.prototype.main = function () {
|
|
|
404
404
|
module.exports = Job;
|
|
405
405
|
```
|
|
406
406
|
|
|
407
|
+
## Marketing & Campaigns
|
|
408
|
+
|
|
409
|
+
Built-in marketing system with multi-provider support (SendGrid + Beehiiv + FCM push).
|
|
410
|
+
|
|
411
|
+
- **Contact management** — add, sync, remove contacts across providers with custom field syncing
|
|
412
|
+
- **Campaign CRUD** — `POST/GET/PUT/DELETE /marketing/campaign` with calendar-backed scheduling
|
|
413
|
+
- **Recurring campaigns** — seasonal sales, newsletters with automatic sendAt advancement
|
|
414
|
+
- **Newsletter generator** — AI-assembled newsletters from parent server content sources
|
|
415
|
+
- **Segment SSOT** — 22 segment definitions resolved to provider IDs at runtime
|
|
416
|
+
- **UTM auto-tagging** — brand domain links tagged automatically in marketing + transactional emails
|
|
417
|
+
- **Contact pruning** — monthly 2-stage re-engagement + deletion of inactive contacts
|
|
418
|
+
- **Template variables** — `{brand.name}`, `{holiday.name}`, `{season.name}`, `{date.*}` resolved at send time
|
|
419
|
+
|
|
420
|
+
Configure via `marketing` section in `backend-manager-config.json`. See CLAUDE.md for full documentation.
|
|
421
|
+
|
|
407
422
|
## Helper Classes
|
|
408
423
|
|
|
409
424
|
### Assistant
|
package/package.json
CHANGED
|
@@ -9,7 +9,10 @@ const moment = require('moment');
|
|
|
9
9
|
* - enforced: Fields that MUST match on every setup run (overwritten if changed)
|
|
10
10
|
*
|
|
11
11
|
* Fields NOT in `enforced` are only set on creation and never touched again,
|
|
12
|
-
* allowing runtime changes (sendAt advances,
|
|
12
|
+
* allowing runtime changes (sendAt advances, recurrence timing, content edits).
|
|
13
|
+
*
|
|
14
|
+
* IDs and names are timing-agnostic so consuming projects can change
|
|
15
|
+
* the recurrence pattern without breaking the ID.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
18
|
/**
|
|
@@ -20,7 +23,6 @@ const moment = require('moment');
|
|
|
20
23
|
function nextMonthDay(dayOfMonth, hour) {
|
|
21
24
|
const next = moment.utc().startOf('month').date(dayOfMonth).hour(hour);
|
|
22
25
|
|
|
23
|
-
// If this month's date has passed, go to next month
|
|
24
26
|
if (next.isBefore(moment.utc())) {
|
|
25
27
|
next.add(1, 'month');
|
|
26
28
|
}
|
|
@@ -49,34 +51,117 @@ function buildSeedCampaigns() {
|
|
|
49
51
|
const nowUNIX = now.unix();
|
|
50
52
|
|
|
51
53
|
return [
|
|
54
|
+
// --- Seasonal sale campaigns (one per audience segment) ---
|
|
52
55
|
{
|
|
53
|
-
id: '_recurring-
|
|
56
|
+
id: '_recurring-sale-free',
|
|
54
57
|
doc: {
|
|
55
58
|
settings: {
|
|
56
|
-
name: '{holiday.name} Sale',
|
|
57
|
-
subject: '{holiday.name} Sale —
|
|
59
|
+
name: '{holiday.name} Sale — Free Users',
|
|
60
|
+
subject: '{holiday.name} Sale — {discount.percent}% Off!',
|
|
58
61
|
preheader: 'Limited time offer from {brand.name}',
|
|
62
|
+
discountCode: 'UPGRADE15',
|
|
59
63
|
content: [
|
|
60
64
|
'# {holiday.name} Sale',
|
|
61
65
|
'',
|
|
62
|
-
'
|
|
66
|
+
'You\'ve been using **{brand.name}** on our free plan — and we think you\'ll love what\'s on the other side.',
|
|
67
|
+
'',
|
|
68
|
+
'For a limited time, upgrade to Premium and get **{discount.percent}% off** your first month.',
|
|
63
69
|
'',
|
|
64
|
-
'
|
|
70
|
+
'Use code **{discount.code}** at checkout.',
|
|
65
71
|
].join('\n'),
|
|
66
72
|
template: 'default',
|
|
67
73
|
sender: 'marketing',
|
|
68
74
|
providers: ['sendgrid'],
|
|
69
|
-
segments: ['subscription_free'
|
|
75
|
+
segments: ['subscription_free'],
|
|
70
76
|
excludeSegments: ['subscription_paid'],
|
|
77
|
+
utm: { utm_campaign: '{holiday.name}_sale', utm_content: 'free_users' },
|
|
71
78
|
},
|
|
72
79
|
sendAt: nextMonthDay(15, 14),
|
|
73
80
|
status: 'pending',
|
|
74
81
|
type: 'email',
|
|
75
|
-
recurrence: {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
82
|
+
recurrence: { pattern: 'monthly', day: 15, hour: 14 },
|
|
83
|
+
metadata: {
|
|
84
|
+
created: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
85
|
+
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
enforced: {
|
|
89
|
+
'type': 'email',
|
|
90
|
+
'settings.providers': ['sendgrid'],
|
|
91
|
+
'settings.sender': 'marketing',
|
|
92
|
+
'settings.segments': ['subscription_free'],
|
|
93
|
+
'settings.excludeSegments': ['subscription_paid'],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: '_recurring-sale-churned-trial',
|
|
98
|
+
doc: {
|
|
99
|
+
settings: {
|
|
100
|
+
name: '{holiday.name} Sale — Churned Trial',
|
|
101
|
+
subject: 'Your trial ended — come back for {discount.percent}% off!',
|
|
102
|
+
preheader: 'We saved your spot at {brand.name}',
|
|
103
|
+
discountCode: 'FLASH20',
|
|
104
|
+
content: [
|
|
105
|
+
'# Come Back to {brand.name}',
|
|
106
|
+
'',
|
|
107
|
+
'Your free trial may have ended, but we haven\'t forgotten about you.',
|
|
108
|
+
'',
|
|
109
|
+
'For our **{holiday.name}** offer, get **{discount.percent}% off** your first month of Premium.',
|
|
110
|
+
'',
|
|
111
|
+
'Use code **{discount.code}** at checkout.',
|
|
112
|
+
].join('\n'),
|
|
113
|
+
template: 'default',
|
|
114
|
+
sender: 'marketing',
|
|
115
|
+
providers: ['sendgrid'],
|
|
116
|
+
segments: ['subscription_churned_trial'],
|
|
117
|
+
excludeSegments: ['subscription_paid'],
|
|
118
|
+
utm: { utm_campaign: '{holiday.name}_sale', utm_content: 'churned_trial' },
|
|
119
|
+
},
|
|
120
|
+
sendAt: nextMonthDay(15, 14),
|
|
121
|
+
status: 'pending',
|
|
122
|
+
type: 'email',
|
|
123
|
+
recurrence: { pattern: 'monthly', day: 15, hour: 14 },
|
|
124
|
+
metadata: {
|
|
125
|
+
created: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
126
|
+
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
enforced: {
|
|
130
|
+
'type': 'email',
|
|
131
|
+
'settings.providers': ['sendgrid'],
|
|
132
|
+
'settings.sender': 'marketing',
|
|
133
|
+
'settings.segments': ['subscription_churned_trial'],
|
|
134
|
+
'settings.excludeSegments': ['subscription_paid'],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: '_recurring-sale-churned-paid',
|
|
139
|
+
doc: {
|
|
140
|
+
settings: {
|
|
141
|
+
name: '{holiday.name} Sale — Churned Paid',
|
|
142
|
+
subject: 'We miss you — here\'s {discount.percent}% off to come back',
|
|
143
|
+
preheader: '{holiday.name} offer from {brand.name}',
|
|
144
|
+
discountCode: 'MISSYOU25',
|
|
145
|
+
content: [
|
|
146
|
+
'# We Miss You at {brand.name}',
|
|
147
|
+
'',
|
|
148
|
+
'It\'s been a while since you cancelled, and a lot has changed.',
|
|
149
|
+
'',
|
|
150
|
+
'For our **{holiday.name}** offer, get **{discount.percent}% off** your next month.',
|
|
151
|
+
'',
|
|
152
|
+
'Use code **{discount.code}** at checkout.',
|
|
153
|
+
].join('\n'),
|
|
154
|
+
template: 'default',
|
|
155
|
+
sender: 'marketing',
|
|
156
|
+
providers: ['sendgrid'],
|
|
157
|
+
segments: ['subscription_churned_paid'],
|
|
158
|
+
excludeSegments: ['subscription_paid'],
|
|
159
|
+
utm: { utm_campaign: '{holiday.name}_sale', utm_content: 'churned_paid' },
|
|
79
160
|
},
|
|
161
|
+
sendAt: nextMonthDay(15, 14),
|
|
162
|
+
status: 'pending',
|
|
163
|
+
type: 'email',
|
|
164
|
+
recurrence: { pattern: 'monthly', day: 15, hour: 14 },
|
|
80
165
|
metadata: {
|
|
81
166
|
created: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
82
167
|
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
@@ -84,29 +169,69 @@ function buildSeedCampaigns() {
|
|
|
84
169
|
},
|
|
85
170
|
enforced: {
|
|
86
171
|
'type': 'email',
|
|
87
|
-
'recurrence.pattern': 'monthly',
|
|
88
|
-
'recurrence.day': 15,
|
|
89
|
-
'recurrence.hour': 14,
|
|
90
172
|
'settings.providers': ['sendgrid'],
|
|
91
173
|
'settings.sender': 'marketing',
|
|
92
|
-
'settings.segments': ['
|
|
174
|
+
'settings.segments': ['subscription_churned_paid'],
|
|
93
175
|
'settings.excludeSegments': ['subscription_paid'],
|
|
94
176
|
},
|
|
95
177
|
},
|
|
96
178
|
{
|
|
97
|
-
id: '_recurring-
|
|
179
|
+
id: '_recurring-sale-cancelled',
|
|
180
|
+
doc: {
|
|
181
|
+
settings: {
|
|
182
|
+
name: '{holiday.name} Sale — Cancelled',
|
|
183
|
+
subject: 'Ready to give {brand.name} another try?',
|
|
184
|
+
preheader: '{holiday.name} offer — {discount.percent}% off',
|
|
185
|
+
discountCode: 'TRYAGAIN10',
|
|
186
|
+
content: [
|
|
187
|
+
'# Give {brand.name} Another Try',
|
|
188
|
+
'',
|
|
189
|
+
'We know things didn\'t work out before, and that\'s okay.',
|
|
190
|
+
'',
|
|
191
|
+
'For our **{holiday.name}** offer, get **{discount.percent}% off** your first month back.',
|
|
192
|
+
'',
|
|
193
|
+
'Use code **{discount.code}** at checkout. No pressure — just an open door.',
|
|
194
|
+
].join('\n'),
|
|
195
|
+
template: 'default',
|
|
196
|
+
sender: 'marketing',
|
|
197
|
+
providers: ['sendgrid'],
|
|
198
|
+
segments: ['subscription_cancelled'],
|
|
199
|
+
excludeSegments: ['subscription_paid', 'subscription_churned_paid', 'subscription_churned_trial'],
|
|
200
|
+
utm: { utm_campaign: '{holiday.name}_sale', utm_content: 'cancelled' },
|
|
201
|
+
},
|
|
202
|
+
sendAt: nextMonthDay(15, 14),
|
|
203
|
+
status: 'pending',
|
|
204
|
+
type: 'email',
|
|
205
|
+
recurrence: { pattern: 'monthly', day: 15, hour: 14 },
|
|
206
|
+
metadata: {
|
|
207
|
+
created: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
208
|
+
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
enforced: {
|
|
212
|
+
'type': 'email',
|
|
213
|
+
'settings.providers': ['sendgrid'],
|
|
214
|
+
'settings.sender': 'marketing',
|
|
215
|
+
'settings.segments': ['subscription_cancelled'],
|
|
216
|
+
'settings.excludeSegments': ['subscription_paid', 'subscription_churned_paid', 'subscription_churned_trial'],
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: '_recurring-newsletter',
|
|
98
221
|
doc: {
|
|
99
222
|
settings: {
|
|
100
|
-
name: '
|
|
101
|
-
subject: '
|
|
102
|
-
preheader: '
|
|
103
|
-
content: '',
|
|
223
|
+
name: '{brand.name} Newsletter — {date.month} {date.year}',
|
|
224
|
+
subject: '', // Generated by AI
|
|
225
|
+
preheader: '', // Generated by AI
|
|
226
|
+
content: '', // Generated at send time by newsletter generator
|
|
104
227
|
sender: 'newsletter',
|
|
105
228
|
providers: ['beehiiv'],
|
|
229
|
+
utm: { utm_campaign: 'newsletter_{date.month}_{date.year}', utm_content: 'newsletter' },
|
|
106
230
|
},
|
|
107
231
|
sendAt: nextWeekday(1, 10),
|
|
108
232
|
status: 'pending',
|
|
109
233
|
type: 'email',
|
|
234
|
+
generator: 'newsletter',
|
|
110
235
|
recurrence: {
|
|
111
236
|
pattern: 'weekly',
|
|
112
237
|
hour: 10,
|
|
@@ -119,9 +244,7 @@ function buildSeedCampaigns() {
|
|
|
119
244
|
},
|
|
120
245
|
enforced: {
|
|
121
246
|
'type': 'email',
|
|
122
|
-
'
|
|
123
|
-
'recurrence.hour': 10,
|
|
124
|
-
'recurrence.day': 1,
|
|
247
|
+
'generator': 'newsletter',
|
|
125
248
|
'settings.providers': ['beehiiv'],
|
|
126
249
|
'settings.sender': 'newsletter',
|
|
127
250
|
},
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Newsletter pre-generation cron job
|
|
3
|
+
*
|
|
4
|
+
* Runs daily. Looks for generator campaigns (e.g., _recurring-newsletter)
|
|
5
|
+
* with sendAt within the next 24 hours. Generates content via AI and creates
|
|
6
|
+
* a NEW standalone pending campaign with the real content.
|
|
7
|
+
*
|
|
8
|
+
* The generated campaign appears on the calendar for review.
|
|
9
|
+
* The frequent cron picks it up and sends it when sendAt is due.
|
|
10
|
+
*
|
|
11
|
+
* After generating, advances the recurring doc's sendAt to the next occurrence.
|
|
12
|
+
*
|
|
13
|
+
* Runs on bm_cronDaily.
|
|
14
|
+
*/
|
|
15
|
+
const moment = require('moment');
|
|
16
|
+
const pushid = require('pushid');
|
|
17
|
+
|
|
18
|
+
// Generator modules — keyed by generator field value
|
|
19
|
+
const generators = {
|
|
20
|
+
newsletter: require('../../libraries/email/generators/newsletter.js'),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
module.exports = async ({ Manager, assistant, libraries }) => {
|
|
24
|
+
const { admin } = libraries;
|
|
25
|
+
|
|
26
|
+
const now = Math.round(Date.now() / 1000);
|
|
27
|
+
const oneDayFromNow = now + (24 * 60 * 60);
|
|
28
|
+
|
|
29
|
+
// Find generator campaigns with sendAt within the next 24 hours
|
|
30
|
+
const snapshot = await admin.firestore()
|
|
31
|
+
.collection('marketing-campaigns')
|
|
32
|
+
.where('status', '==', 'pending')
|
|
33
|
+
.where('sendAt', '<=', oneDayFromNow)
|
|
34
|
+
.get();
|
|
35
|
+
|
|
36
|
+
// Filter to only generator campaigns (can't query on field existence in Firestore)
|
|
37
|
+
const generatorDocs = snapshot.docs.filter(doc => doc.data().generator);
|
|
38
|
+
|
|
39
|
+
if (!generatorDocs.length) {
|
|
40
|
+
assistant.log('No generator campaigns due within 24 hours');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
assistant.log(`Pre-generating ${generatorDocs.length} campaign(s)...`);
|
|
45
|
+
|
|
46
|
+
for (const doc of generatorDocs) {
|
|
47
|
+
const data = doc.data();
|
|
48
|
+
const { settings, type, generator, recurrence } = data;
|
|
49
|
+
const campaignId = doc.id;
|
|
50
|
+
|
|
51
|
+
if (!generators[generator]) {
|
|
52
|
+
assistant.log(`Unknown generator "${generator}" on ${campaignId}, skipping`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
assistant.log(`Generating content for ${campaignId} (${generator}): ${settings.name}`);
|
|
57
|
+
|
|
58
|
+
// Run the generator
|
|
59
|
+
const generated = await generators[generator].generate(Manager, assistant, settings);
|
|
60
|
+
|
|
61
|
+
if (!generated) {
|
|
62
|
+
assistant.log(`Generator "${generator}" returned no content for ${campaignId}, skipping`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Create a new standalone campaign with the generated content
|
|
67
|
+
const newId = pushid();
|
|
68
|
+
const nowISO = new Date().toISOString();
|
|
69
|
+
const nowUNIX = Math.round(Date.now() / 1000);
|
|
70
|
+
|
|
71
|
+
await admin.firestore().doc(`marketing-campaigns/${newId}`).set({
|
|
72
|
+
settings: generated,
|
|
73
|
+
type,
|
|
74
|
+
sendAt: data.sendAt,
|
|
75
|
+
status: 'pending',
|
|
76
|
+
generatedFrom: campaignId,
|
|
77
|
+
metadata: {
|
|
78
|
+
created: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
79
|
+
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
assistant.log(`Created campaign ${newId} from generator ${campaignId}: "${generated.subject}"`);
|
|
84
|
+
|
|
85
|
+
// Advance the recurring doc's sendAt to the next occurrence
|
|
86
|
+
if (recurrence) {
|
|
87
|
+
const nextSendAt = getNextOccurrence(data.sendAt, recurrence);
|
|
88
|
+
|
|
89
|
+
await doc.ref.set({
|
|
90
|
+
sendAt: nextSendAt,
|
|
91
|
+
metadata: {
|
|
92
|
+
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
93
|
+
},
|
|
94
|
+
}, { merge: true });
|
|
95
|
+
|
|
96
|
+
assistant.log(`Advanced ${campaignId} sendAt to ${moment.unix(nextSendAt).toISOString()}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
assistant.log('Pre-generation complete');
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Calculate the next occurrence unix timestamp.
|
|
105
|
+
*/
|
|
106
|
+
function getNextOccurrence(currentSendAt, recurrence) {
|
|
107
|
+
const current = moment.unix(currentSendAt);
|
|
108
|
+
const { pattern } = recurrence;
|
|
109
|
+
|
|
110
|
+
switch (pattern) {
|
|
111
|
+
case 'daily': return current.add(1, 'day').unix();
|
|
112
|
+
case 'weekly': return current.add(1, 'week').unix();
|
|
113
|
+
case 'monthly': return current.add(1, 'month').unix();
|
|
114
|
+
case 'quarterly': return current.add(3, 'months').unix();
|
|
115
|
+
case 'yearly': return current.add(1, 'year').unix();
|
|
116
|
+
default: return current.add(1, 'month').unix();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -40,11 +40,17 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
40
40
|
|
|
41
41
|
const results = await Promise.allSettled(snapshot.docs.map(async (doc) => {
|
|
42
42
|
const data = doc.data();
|
|
43
|
-
|
|
43
|
+
let { settings, type, recurrence, generator } = data;
|
|
44
44
|
const campaignId = doc.id;
|
|
45
45
|
|
|
46
46
|
assistant.log(`Processing campaign ${campaignId} (${type}): ${settings.name}`);
|
|
47
47
|
|
|
48
|
+
// --- Generator campaigns are handled by the daily pre-generation cron, not here ---
|
|
49
|
+
if (generator) {
|
|
50
|
+
assistant.log(`Skipping generator campaign ${campaignId} — handled by daily pre-generation cron`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
// --- Dispatch by type ---
|
|
49
55
|
let campaignResults;
|
|
50
56
|
|
|
@@ -159,6 +159,7 @@ const FIELDS = {
|
|
|
159
159
|
user_subscription_plan: { display: 'Plan', source: 'resolved', path: 'plan', type: 'text' },
|
|
160
160
|
user_subscription_status: { display: 'Status', source: 'resolved', path: 'status', type: 'text' },
|
|
161
161
|
user_subscription_trialing: { display: 'Trialing', source: 'resolved', path: 'trialing', type: 'text' },
|
|
162
|
+
user_subscription_trial_claimed: { display: 'Trial Claimed', source: 'user', path: 'subscription.trial.claimed', type: 'text' },
|
|
162
163
|
user_subscription_cancelling: { display: 'Cancelling', source: 'resolved', path: 'cancelling', type: 'text' },
|
|
163
164
|
user_subscription_ever_paid: { display: 'Ever Paid', source: 'resolved', path: 'everPaid', type: 'text' },
|
|
164
165
|
user_subscription_payment_processor: { display: 'Payment Processor', source: 'user', path: 'subscription.payment.processor', type: 'text' },
|
|
@@ -195,7 +196,8 @@ const SEGMENTS = {
|
|
|
195
196
|
subscription_cancelling: { display: 'Cancelling', conditions: [{ field: 'user_subscription_cancelling', op: '==', value: 'true' }] },
|
|
196
197
|
subscription_suspended: { display: 'Suspended', conditions: [{ field: 'user_subscription_status', op: '==', value: 'suspended' }] },
|
|
197
198
|
subscription_cancelled: { display: 'Cancelled', conditions: [{ field: 'user_subscription_status', op: '==', value: 'cancelled' }] },
|
|
198
|
-
|
|
199
|
+
subscription_churned_paid: { display: 'Churned Paid (Paid → Cancelled)', conditions: [{ field: 'user_subscription_ever_paid', op: '==', value: 'true' }, { field: 'user_subscription_status', op: '==', value: 'cancelled' }] },
|
|
200
|
+
subscription_churned_trial: { display: 'Churned Trial (Trial → Never Paid)', conditions: [{ field: 'user_subscription_trial_claimed', op: '==', value: 'true' }, { field: 'user_subscription_ever_paid', op: '!=', value: 'true' }] },
|
|
199
201
|
subscription_ever_paid: { display: 'Ever Paid', conditions: [{ field: 'user_subscription_ever_paid', op: '==', value: 'true' }] },
|
|
200
202
|
subscription_never_paid: { display: 'Never Paid', conditions: [{ field: 'user_subscription_ever_paid', op: '!=', value: 'true' }] },
|
|
201
203
|
|
|
@@ -212,6 +214,9 @@ const SEGMENTS = {
|
|
|
212
214
|
engagement_inactive_90d: { display: 'Inactive 90+ Days', conditions: [{ type: 'engagement', op: 'not_opened', value: '90d' }] },
|
|
213
215
|
engagement_inactive_5m: { display: 'Inactive 5+ Months', conditions: [{ type: 'engagement', op: 'not_opened', value: '150d' }] },
|
|
214
216
|
engagement_inactive_6m: { display: 'Inactive 6+ Months', conditions: [{ type: 'engagement', op: 'not_opened', value: '180d' }] },
|
|
217
|
+
|
|
218
|
+
// Test
|
|
219
|
+
test_admin: { display: 'Test Admin', conditions: [{ type: 'contact', op: 'email_is', value: 'hello@itwcreativeworks.com' }] },
|
|
215
220
|
};
|
|
216
221
|
|
|
217
222
|
/**
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Newsletter generator — pulls content from parent server and assembles a branded newsletter.
|
|
3
|
+
*
|
|
4
|
+
* Called by the marketing-campaigns cron when a campaign has `generator: 'newsletter'`.
|
|
5
|
+
* Instead of sending the campaign directly, this generates the content first,
|
|
6
|
+
* then returns the assembled settings for the cron to send.
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. Read newsletter categories from Manager.config.marketing.newsletter.categories
|
|
10
|
+
* 2. Fetch ready sources from parent server (GET /newsletter/sources)
|
|
11
|
+
* 3. AI assembles sources into branded markdown newsletter
|
|
12
|
+
* 4. Mark sources as used on parent server (PUT /newsletter/sources)
|
|
13
|
+
* 5. Return assembled settings with content filled in
|
|
14
|
+
*/
|
|
15
|
+
const fetch = require('wonderful-fetch');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate newsletter content from parent server sources.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} Manager - BEM Manager instance
|
|
21
|
+
* @param {object} assistant - BEM assistant instance
|
|
22
|
+
* @param {object} settings - Campaign settings from the recurring template
|
|
23
|
+
* @returns {object} Updated settings with content filled in, or null if no content available
|
|
24
|
+
*/
|
|
25
|
+
async function generate(Manager, assistant, settings) {
|
|
26
|
+
const config = Manager.config?.marketing?.newsletter;
|
|
27
|
+
|
|
28
|
+
if (!config?.enabled) {
|
|
29
|
+
assistant.log('Newsletter generator: disabled in config');
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const categories = config.categories || [];
|
|
34
|
+
|
|
35
|
+
if (!categories.length) {
|
|
36
|
+
assistant.log('Newsletter generator: no categories configured');
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const parentUrl = Manager.config?.parent?.apiUrl;
|
|
41
|
+
|
|
42
|
+
if (!parentUrl) {
|
|
43
|
+
assistant.log('Newsletter generator: no parent API URL configured');
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Fetch and atomically claim sources from parent server
|
|
48
|
+
const brandId = Manager.config?.brand?.id;
|
|
49
|
+
const sources = await fetchSources(parentUrl, categories, brandId, assistant);
|
|
50
|
+
|
|
51
|
+
if (!sources.length) {
|
|
52
|
+
assistant.log('Newsletter generator: no sources available');
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
assistant.log(`Newsletter generator: ${sources.length} sources found, assembling...`);
|
|
57
|
+
|
|
58
|
+
const brand = Manager.config?.brand;
|
|
59
|
+
|
|
60
|
+
// AI assembles sources into newsletter with subject + preheader + content
|
|
61
|
+
const assembled = await assembleNewsletter(Manager, assistant, sources, brand);
|
|
62
|
+
|
|
63
|
+
if (!assembled) {
|
|
64
|
+
assistant.log('Newsletter generator: AI assembly failed');
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Mark sources as used on parent server
|
|
69
|
+
await claimSources(parentUrl, sources, brand?.id, assistant);
|
|
70
|
+
|
|
71
|
+
// Return updated settings — AI-generated fields override template placeholders
|
|
72
|
+
return {
|
|
73
|
+
...settings,
|
|
74
|
+
subject: assembled.subject,
|
|
75
|
+
preheader: assembled.preheader,
|
|
76
|
+
content: assembled.content,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fetch ready newsletter sources from the parent server.
|
|
82
|
+
*/
|
|
83
|
+
async function fetchSources(parentUrl, categories, brandId, assistant) {
|
|
84
|
+
const allSources = [];
|
|
85
|
+
|
|
86
|
+
for (const category of categories) {
|
|
87
|
+
try {
|
|
88
|
+
const data = await fetch(`${parentUrl}/backend-manager/newsletter/sources`, {
|
|
89
|
+
method: 'get',
|
|
90
|
+
response: 'json',
|
|
91
|
+
timeout: 15000,
|
|
92
|
+
query: {
|
|
93
|
+
category,
|
|
94
|
+
limit: 3,
|
|
95
|
+
claimFor: brandId,
|
|
96
|
+
backendManagerKey: process.env.BACKEND_MANAGER_KEY,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (data.sources?.length) {
|
|
101
|
+
allSources.push(...data.sources);
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
assistant.error(`Newsletter generator: Failed to fetch ${category} sources:`, e.message);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return allSources;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Assemble newsletter sources into a branded newsletter via AI.
|
|
113
|
+
* Returns { subject, preheader, content } or null on failure.
|
|
114
|
+
*/
|
|
115
|
+
async function assembleNewsletter(Manager, assistant, sources, brand) {
|
|
116
|
+
const ai = require('../../openai.js');
|
|
117
|
+
|
|
118
|
+
const sourceSummaries = sources.map((s, i) =>
|
|
119
|
+
`[${i + 1}] ${s.ai?.headline || s.subject}\n${s.ai?.summary || ''}\nTakeaways: ${(s.ai?.takeaways || []).join('; ')}`
|
|
120
|
+
).join('\n\n');
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const result = await ai.request({
|
|
124
|
+
model: 'gpt-4o-mini',
|
|
125
|
+
messages: [
|
|
126
|
+
{
|
|
127
|
+
role: 'system',
|
|
128
|
+
content: `You are a newsletter writer for ${brand?.name || 'a tech company'}. ${brand?.description || ''}
|
|
129
|
+
|
|
130
|
+
Given source articles, write a branded newsletter in markdown. Be concise, engaging, and professional.
|
|
131
|
+
|
|
132
|
+
Respond in JSON:
|
|
133
|
+
{
|
|
134
|
+
"subject": "Catchy email subject line (max 60 chars, no emojis)",
|
|
135
|
+
"preheader": "Preview text that complements the subject (max 100 chars)",
|
|
136
|
+
"content": "Full newsletter body in markdown with ## section headers"
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
Guidelines:
|
|
140
|
+
- Start with a brief intro (1-2 sentences)
|
|
141
|
+
- Each source becomes a section with ## header
|
|
142
|
+
- Rewrite in your own voice — don't copy verbatim
|
|
143
|
+
- End with a short sign-off
|
|
144
|
+
- Keep it scannable — use bold, bullets, short paragraphs`,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
role: 'user',
|
|
148
|
+
content: `Write a newsletter from these ${sources.length} sources:\n\n${sourceSummaries}`,
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
response_format: { type: 'json_object' },
|
|
152
|
+
apiKey: process.env.BACKEND_MANAGER_OPENAI_API_KEY,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return result.content;
|
|
156
|
+
} catch (e) {
|
|
157
|
+
assistant.error('Newsletter AI assembly failed:', e.message);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Mark sources as used on the parent server.
|
|
164
|
+
*/
|
|
165
|
+
async function claimSources(parentUrl, sources, brandId, assistant) {
|
|
166
|
+
for (const source of sources) {
|
|
167
|
+
try {
|
|
168
|
+
await fetch(`${parentUrl}/backend-manager/newsletter/sources`, {
|
|
169
|
+
method: 'put',
|
|
170
|
+
response: 'json',
|
|
171
|
+
timeout: 10000,
|
|
172
|
+
body: {
|
|
173
|
+
id: source.id,
|
|
174
|
+
usedBy: brandId || 'unknown',
|
|
175
|
+
backendManagerKey: process.env.BACKEND_MANAGER_KEY,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
} catch (e) {
|
|
179
|
+
assistant.error(`Newsletter generator: Failed to claim source ${source.id}:`, e.message);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { generate };
|
|
@@ -271,19 +271,26 @@ Marketing.prototype.sendCampaign = async function (settings) {
|
|
|
271
271
|
const results = {};
|
|
272
272
|
const promises = [];
|
|
273
273
|
|
|
274
|
-
// Resolve campaign-level variables: {brand.name}, {season}, {
|
|
275
|
-
//
|
|
274
|
+
// Resolve campaign-level variables: {brand.name}, {season.name}, {holiday.name}, {date.*}, {discount.*}
|
|
275
|
+
// Walks all string values in settings and resolves single-brace vars via powertools.template()
|
|
276
|
+
// Double braces {{var}} are left untouched for SendGrid template-level resolution
|
|
276
277
|
const brand = Manager.config?.brand;
|
|
277
|
-
const templateContext = buildTemplateContext(brand);
|
|
278
|
-
|
|
278
|
+
const templateContext = buildTemplateContext(brand, settings);
|
|
279
|
+
let resolvedSettings = resolveTemplateVars(settings, templateContext);
|
|
279
280
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
281
|
+
// Test mode: real Single Send / Beehiiv Post, but targeted only to test_admin segment
|
|
282
|
+
if (settings.test) {
|
|
283
|
+
assistant.log('Marketing.sendCampaign(): TEST MODE — targeting test_admin segment only');
|
|
284
|
+
|
|
285
|
+
resolvedSettings = {
|
|
286
|
+
...resolvedSettings,
|
|
287
|
+
name: `[TEST] ${resolvedSettings.name}`,
|
|
288
|
+
segments: ['test_admin'],
|
|
289
|
+
excludeSegments: [],
|
|
290
|
+
lists: [],
|
|
291
|
+
all: false,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
287
294
|
|
|
288
295
|
// Convert markdown content to HTML, then tag links with UTM params
|
|
289
296
|
let contentHtml = resolvedSettings.content ? md.render(resolvedSettings.content) : '';
|
|
@@ -292,9 +299,9 @@ Marketing.prototype.sendCampaign = async function (settings) {
|
|
|
292
299
|
contentHtml = tagLinks(contentHtml, {
|
|
293
300
|
brandUrl: brand?.url,
|
|
294
301
|
brandId: brand?.id,
|
|
295
|
-
campaign:
|
|
302
|
+
campaign: resolvedSettings.name,
|
|
296
303
|
type: 'marketing',
|
|
297
|
-
utm:
|
|
304
|
+
utm: resolvedSettings.utm,
|
|
298
305
|
});
|
|
299
306
|
}
|
|
300
307
|
|
|
@@ -521,10 +528,17 @@ const MONTH_NAMES = [
|
|
|
521
528
|
* {date.year} — 2026
|
|
522
529
|
* {date.full} — March 17, 2026
|
|
523
530
|
*/
|
|
524
|
-
function buildTemplateContext(brand) {
|
|
531
|
+
function buildTemplateContext(brand, settings) {
|
|
525
532
|
const now = new Date();
|
|
526
533
|
const month = now.getMonth();
|
|
527
534
|
|
|
535
|
+
// Resolve discount code if provided in settings
|
|
536
|
+
const discountCodes = require('../../payment/discount-codes.js');
|
|
537
|
+
const discountCode = settings?.discountCode;
|
|
538
|
+
const discount = discountCode
|
|
539
|
+
? discountCodes.validate(discountCode)
|
|
540
|
+
: { code: '', percent: '' };
|
|
541
|
+
|
|
528
542
|
return {
|
|
529
543
|
brand: brand || {},
|
|
530
544
|
season: {
|
|
@@ -538,7 +552,45 @@ function buildTemplateContext(brand) {
|
|
|
538
552
|
year: String(now.getFullYear()),
|
|
539
553
|
full: now.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
|
|
540
554
|
},
|
|
555
|
+
discount: {
|
|
556
|
+
code: discount.code || '',
|
|
557
|
+
percent: discount.percent || '',
|
|
558
|
+
},
|
|
541
559
|
};
|
|
542
560
|
}
|
|
543
561
|
|
|
562
|
+
/**
|
|
563
|
+
* Recursively walk an object and resolve {template} variables in all string values.
|
|
564
|
+
* Arrays and nested objects are walked. Non-string values are passed through.
|
|
565
|
+
*/
|
|
566
|
+
function resolveTemplateVars(obj, context) {
|
|
567
|
+
const template = require('node-powertools').template;
|
|
568
|
+
|
|
569
|
+
if (typeof obj === 'string') {
|
|
570
|
+
if (!obj.includes('{')) {
|
|
571
|
+
return obj;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Resolve, then replace any "null" or "undefined" leftovers with empty string
|
|
575
|
+
const resolved = template(obj, context);
|
|
576
|
+
return resolved.replace(/\bnull\b|\bundefined\b/g, '').replace(/\s{2,}/g, ' ').trim();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (Array.isArray(obj)) {
|
|
580
|
+
return obj.map(item => resolveTemplateVars(item, context));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (obj && typeof obj === 'object') {
|
|
584
|
+
const result = {};
|
|
585
|
+
|
|
586
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
587
|
+
result[key] = resolveTemplateVars(value, context);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return result;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return obj;
|
|
594
|
+
}
|
|
595
|
+
|
|
544
596
|
module.exports = Marketing;
|
|
@@ -46,12 +46,12 @@ function tagLinks(html, options) {
|
|
|
46
46
|
...options.utm,
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
// Remove null/undefined values
|
|
49
|
+
// Remove null/undefined values and sanitize: lowercase, non-alphanumeric → underscore
|
|
50
50
|
const utmParams = {};
|
|
51
51
|
|
|
52
52
|
for (const [key, value] of Object.entries(utm)) {
|
|
53
53
|
if (value != null && value !== '') {
|
|
54
|
-
utmParams[key] = String(value);
|
|
54
|
+
utmParams[key] = String(value).toLowerCase().replace(/[''`]/g, '').replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
@@ -1,22 +1,64 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Discount codes —
|
|
2
|
+
* Discount codes — SSOT for all promo codes
|
|
3
3
|
*
|
|
4
4
|
* Each code maps to a discount definition:
|
|
5
5
|
* - percent: Percentage off (1-100)
|
|
6
6
|
* - duration: 'once' (first payment only)
|
|
7
|
+
* - eligible: Optional function (user) => boolean. If present, the code is only
|
|
8
|
+
* valid for users who pass this check. Receives the raw user doc or User instance.
|
|
9
|
+
* If omitted, the code is valid for everyone.
|
|
7
10
|
*/
|
|
11
|
+
const User = require('../../helpers/user.js');
|
|
12
|
+
|
|
8
13
|
const DISCOUNT_CODES = {
|
|
9
|
-
|
|
10
|
-
'SAVE10': { percent: 10, duration: 'once' },
|
|
14
|
+
// Website (displayed on pricing page, landing pages — no eligibility restrictions)
|
|
11
15
|
'WELCOME15': { percent: 15, duration: 'once' },
|
|
16
|
+
'SAVE10': { percent: 10, duration: 'once' },
|
|
17
|
+
'FLASH20': { percent: 20, duration: 'once' },
|
|
18
|
+
|
|
19
|
+
// Email campaigns (used by recurring sale seeds — restricted by audience)
|
|
20
|
+
'UPGRADE15': {
|
|
21
|
+
percent: 15,
|
|
22
|
+
duration: 'once',
|
|
23
|
+
eligible: (user) => {
|
|
24
|
+
const sub = User.resolveSubscription(user);
|
|
25
|
+
return sub.plan === 'basic';
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
'COMEBACK20': {
|
|
29
|
+
percent: 20,
|
|
30
|
+
duration: 'once',
|
|
31
|
+
eligible: (user) => {
|
|
32
|
+
const sub = User.resolveSubscription(user);
|
|
33
|
+
return sub.plan === 'basic' && user.subscription?.trial?.claimed === true;
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
'MISSYOU25': {
|
|
37
|
+
percent: 25,
|
|
38
|
+
duration: 'once',
|
|
39
|
+
eligible: (user) => {
|
|
40
|
+
const sub = User.resolveSubscription(user);
|
|
41
|
+
return sub.everPaid && user.subscription?.status === 'cancelled';
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
'TRYAGAIN10': {
|
|
45
|
+
percent: 10,
|
|
46
|
+
duration: 'once',
|
|
47
|
+
eligible: (user) => {
|
|
48
|
+
return user.subscription?.status === 'cancelled';
|
|
49
|
+
},
|
|
50
|
+
},
|
|
12
51
|
};
|
|
13
52
|
|
|
14
53
|
/**
|
|
15
|
-
* Validate a discount code
|
|
54
|
+
* Validate a discount code, optionally checking user eligibility.
|
|
55
|
+
*
|
|
16
56
|
* @param {string} code - The discount code (case-insensitive)
|
|
17
|
-
* @
|
|
57
|
+
* @param {object} [user] - User doc or User instance. If provided and the code has
|
|
58
|
+
* an eligible() function, eligibility is checked. If not provided, eligibility is skipped.
|
|
59
|
+
* @returns {{ valid: boolean, code: string, percent?: number, duration?: string, reason?: string }}
|
|
18
60
|
*/
|
|
19
|
-
function validate(code) {
|
|
61
|
+
function validate(code, user) {
|
|
20
62
|
const normalized = (code || '').trim().toUpperCase();
|
|
21
63
|
|
|
22
64
|
if (!normalized) {
|
|
@@ -29,6 +71,16 @@ function validate(code) {
|
|
|
29
71
|
return { valid: false, code: normalized };
|
|
30
72
|
}
|
|
31
73
|
|
|
74
|
+
// Check eligibility if user is provided and code has a restriction
|
|
75
|
+
if (user && entry.eligible) {
|
|
76
|
+
// Support both raw user doc and User instance (check .properties for User instance)
|
|
77
|
+
const userDoc = user.properties || user;
|
|
78
|
+
|
|
79
|
+
if (!entry.eligible(userDoc)) {
|
|
80
|
+
return { valid: false, code: normalized, reason: 'not eligible' };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
32
84
|
return {
|
|
33
85
|
valid: true,
|
|
34
86
|
code: normalized,
|
|
@@ -24,7 +24,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
24
24
|
// Run the cron job
|
|
25
25
|
const cronPath = require('path').resolve(__dirname, `../../../cron/${settings.id}.js`);
|
|
26
26
|
const cronHandler = require(cronPath);
|
|
27
|
-
const result = await cronHandler({ Manager, assistant, context: {} }).catch(e => e);
|
|
27
|
+
const result = await cronHandler({ Manager, assistant, context: {}, libraries: Manager.libraries }).catch(e => e);
|
|
28
28
|
|
|
29
29
|
if (result instanceof Error) {
|
|
30
30
|
return assistant.respond(result.message, { code: 500 });
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
const discountCodes = require('../../../libraries/payment/discount-codes.js');
|
|
6
6
|
|
|
7
|
-
module.exports = async ({ assistant, settings }) => {
|
|
8
|
-
const result = discountCodes.validate(settings.code);
|
|
7
|
+
module.exports = async ({ assistant, user, settings }) => {
|
|
8
|
+
const result = discountCodes.validate(settings.code, user.authenticated ? user : undefined);
|
|
9
9
|
|
|
10
10
|
assistant.log(`Discount validation: code=${result.code}, valid=${result.valid}`);
|
|
11
11
|
|
|
@@ -83,7 +83,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
83
83
|
// Validate discount code (if provided)
|
|
84
84
|
let resolvedDiscount = null;
|
|
85
85
|
if (discount) {
|
|
86
|
-
const discountResult = discountCodes.validate(discount);
|
|
86
|
+
const discountResult = discountCodes.validate(discount, user);
|
|
87
87
|
if (!discountResult.valid) {
|
|
88
88
|
return assistant.respond(`Invalid discount code: ${discount}`, { code: 400 });
|
|
89
89
|
}
|
|
@@ -28,6 +28,8 @@ module.exports = () => ({
|
|
|
28
28
|
utm: { types: ['object'], default: {} },
|
|
29
29
|
|
|
30
30
|
// Config
|
|
31
|
+
test: { types: ['boolean'], default: false },
|
|
32
|
+
discountCode: { types: ['string'], default: '' },
|
|
31
33
|
sender: { types: ['string'], default: 'marketing' },
|
|
32
34
|
providers: { types: ['array'], default: [] },
|
|
33
35
|
group: { types: ['string'], default: '' },
|