backend-manager 5.0.158 → 5.0.160
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 +26 -0
- package/CLAUDE.md +135 -0
- package/README.md +15 -0
- package/package.json +1 -1
- package/src/cli/commands/serve.js +6 -0
- package/src/cli/commands/setup-tests/helpers/seed-campaigns.js +136 -11
- package/src/manager/libraries/email/constants.js +6 -1
- package/src/manager/libraries/email/marketing/index.js +76 -17
- package/src/manager/libraries/email/providers/beehiiv.js +44 -0
- package/src/manager/libraries/email/utm.js +2 -2
- package/src/manager/libraries/payment/discount-codes.js +58 -6
- 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/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,32 @@ 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.160] - 2026-03-18
|
|
18
|
+
### Added
|
|
19
|
+
- Beehiiv `resolveSegmentIds()` — fetches segments from API, builds name→ID cache (same pattern as SendGrid)
|
|
20
|
+
- Beehiiv segment resolution in `sendCampaign()` — SSOT keys auto-translate to Beehiiv segment IDs
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- Beehiiv `createPost()` now receives resolved segment IDs instead of raw SSOT keys
|
|
24
|
+
|
|
25
|
+
# [5.0.159] - 2026-03-18
|
|
26
|
+
### Added
|
|
27
|
+
- Audience-specific email discount codes: `UPGRADE15`, `COMEBACK20`, `MISSYOU25`, `TRYAGAIN10` with eligibility validation per user
|
|
28
|
+
- `{discount.code}` and `{discount.percent}` campaign template variables
|
|
29
|
+
- `test: true` flag on campaign route — sends real Single Send to `test_admin` segment only
|
|
30
|
+
- `test_admin` segment in SSOT (targets `hello@itwcreativeworks.com`)
|
|
31
|
+
- `trial_claimed` custom field (`user_subscription_trial_claimed`) for marketing sync
|
|
32
|
+
- `subscription_churned_paid` and `subscription_churned_trial` segments (replaces `subscription_churned`)
|
|
33
|
+
- 4 audience-specific recurring sale seed campaigns with tailored messaging + discount codes
|
|
34
|
+
- Full marketing campaign system documentation in CLAUDE.md, README.md, and BEM:patterns skill
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
- Template variable resolution now recursive — walks all string values in settings (future-proof)
|
|
38
|
+
- UTM values resolved through template vars (`{holiday.name}_sale` → `black_friday_sale`)
|
|
39
|
+
- UTM sanitizer strips apostrophes before underscore conversion
|
|
40
|
+
- Payment intent + discount routes now pass user object for discount eligibility checking
|
|
41
|
+
- Discount code `validate()` accepts optional user param for eligibility checks (backwards compatible)
|
|
42
|
+
|
|
17
43
|
# [5.0.158] - 2026-03-17
|
|
18
44
|
### Added
|
|
19
45
|
- Newsletter generator system (`libraries/email/generators/newsletter.js`) — fetches sources from parent server, AI assembles branded content with subject/preheader
|
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
|
@@ -11,6 +11,12 @@ class ServeCommand extends BaseCommand {
|
|
|
11
11
|
const port = self.argv.port || self.argv?._?.[1] || '5000';
|
|
12
12
|
const projectDir = self.firebaseProjectPath;
|
|
13
13
|
|
|
14
|
+
// Check for port conflicts before starting server
|
|
15
|
+
const canProceed = await this.checkAndKillBlockingProcesses({ serving: parseInt(port, 10) });
|
|
16
|
+
if (!canProceed) {
|
|
17
|
+
throw new Error('Port conflicts could not be resolved');
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
// Start BEM watcher in background
|
|
15
21
|
const watcher = new WatchCommand(self);
|
|
16
22
|
watcher.startBackground();
|
|
@@ -51,34 +51,76 @@ function buildSeedCampaigns() {
|
|
|
51
51
|
const nowUNIX = now.unix();
|
|
52
52
|
|
|
53
53
|
return [
|
|
54
|
+
// --- Seasonal sale campaigns (one per audience segment) ---
|
|
54
55
|
{
|
|
55
|
-
id: '_recurring-sale',
|
|
56
|
+
id: '_recurring-sale-free',
|
|
56
57
|
doc: {
|
|
57
58
|
settings: {
|
|
58
|
-
name: '{holiday.name} Sale',
|
|
59
|
-
subject: '{holiday.name} Sale —
|
|
59
|
+
name: '{holiday.name} Sale — Free Users',
|
|
60
|
+
subject: '{holiday.name} Sale — {discount.percent}% Off!',
|
|
60
61
|
preheader: 'Limited time offer from {brand.name}',
|
|
62
|
+
discountCode: 'UPGRADE15',
|
|
61
63
|
content: [
|
|
62
64
|
'# {holiday.name} Sale',
|
|
63
65
|
'',
|
|
64
|
-
'
|
|
66
|
+
'You\'ve been using **{brand.name}** on our free plan — and we think you\'ll love what\'s on the other side.',
|
|
65
67
|
'',
|
|
66
|
-
'
|
|
68
|
+
'For a limited time, upgrade to Premium and get **{discount.percent}% off** your first month.',
|
|
69
|
+
'',
|
|
70
|
+
'Use code **{discount.code}** at checkout.',
|
|
67
71
|
].join('\n'),
|
|
68
72
|
template: 'default',
|
|
69
73
|
sender: 'marketing',
|
|
70
74
|
providers: ['sendgrid'],
|
|
71
|
-
segments: ['subscription_free'
|
|
75
|
+
segments: ['subscription_free'],
|
|
72
76
|
excludeSegments: ['subscription_paid'],
|
|
77
|
+
utm: { utm_campaign: '{holiday.name}_sale', utm_content: 'free_users' },
|
|
73
78
|
},
|
|
74
79
|
sendAt: nextMonthDay(15, 14),
|
|
75
80
|
status: 'pending',
|
|
76
81
|
type: 'email',
|
|
77
|
-
recurrence: {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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' },
|
|
81
119
|
},
|
|
120
|
+
sendAt: nextMonthDay(15, 14),
|
|
121
|
+
status: 'pending',
|
|
122
|
+
type: 'email',
|
|
123
|
+
recurrence: { pattern: 'monthly', day: 15, hour: 14 },
|
|
82
124
|
metadata: {
|
|
83
125
|
created: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
84
126
|
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
@@ -88,10 +130,92 @@ function buildSeedCampaigns() {
|
|
|
88
130
|
'type': 'email',
|
|
89
131
|
'settings.providers': ['sendgrid'],
|
|
90
132
|
'settings.sender': 'marketing',
|
|
91
|
-
'settings.segments': ['
|
|
133
|
+
'settings.segments': ['subscription_churned_trial'],
|
|
92
134
|
'settings.excludeSegments': ['subscription_paid'],
|
|
93
135
|
},
|
|
94
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' },
|
|
160
|
+
},
|
|
161
|
+
sendAt: nextMonthDay(15, 14),
|
|
162
|
+
status: 'pending',
|
|
163
|
+
type: 'email',
|
|
164
|
+
recurrence: { pattern: 'monthly', day: 15, hour: 14 },
|
|
165
|
+
metadata: {
|
|
166
|
+
created: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
167
|
+
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
enforced: {
|
|
171
|
+
'type': 'email',
|
|
172
|
+
'settings.providers': ['sendgrid'],
|
|
173
|
+
'settings.sender': 'marketing',
|
|
174
|
+
'settings.segments': ['subscription_churned_paid'],
|
|
175
|
+
'settings.excludeSegments': ['subscription_paid'],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
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
|
+
},
|
|
95
219
|
{
|
|
96
220
|
id: '_recurring-newsletter',
|
|
97
221
|
doc: {
|
|
@@ -102,6 +226,7 @@ function buildSeedCampaigns() {
|
|
|
102
226
|
content: '', // Generated at send time by newsletter generator
|
|
103
227
|
sender: 'newsletter',
|
|
104
228
|
providers: ['beehiiv'],
|
|
229
|
+
utm: { utm_campaign: 'newsletter_{date.month}_{date.year}', utm_content: 'newsletter' },
|
|
105
230
|
},
|
|
106
231
|
sendAt: nextWeekday(1, 10),
|
|
107
232
|
status: 'pending',
|
|
@@ -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
|
/**
|
|
@@ -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
|
|
|
@@ -310,7 +317,14 @@ Marketing.prototype.sendCampaign = async function (settings) {
|
|
|
310
317
|
};
|
|
311
318
|
}
|
|
312
319
|
|
|
313
|
-
|
|
320
|
+
if (useProviders.includes('beehiiv') && self.providers.beehiiv) {
|
|
321
|
+
const segmentIdMap = await beehiivProvider.resolveSegmentIds();
|
|
322
|
+
|
|
323
|
+
resolvedSegments.beehiiv = {
|
|
324
|
+
segments: (resolvedSettings.segments || []).map(key => segmentIdMap[key] || key).filter(Boolean),
|
|
325
|
+
excludeSegments: (resolvedSettings.excludeSegments || []).map(key => segmentIdMap[key] || key).filter(Boolean),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
314
328
|
|
|
315
329
|
assistant.log('Marketing.sendCampaign():', {
|
|
316
330
|
name: resolvedSettings.name,
|
|
@@ -342,8 +356,8 @@ Marketing.prototype.sendCampaign = async function (settings) {
|
|
|
342
356
|
preheader: resolvedSettings.preheader,
|
|
343
357
|
content: contentHtml,
|
|
344
358
|
sendAt: settings.sendAt,
|
|
345
|
-
segments:
|
|
346
|
-
excludeSegments:
|
|
359
|
+
segments: resolvedSegments.beehiiv?.segments || [],
|
|
360
|
+
excludeSegments: resolvedSegments.beehiiv?.excludeSegments || [],
|
|
347
361
|
})
|
|
348
362
|
.then((r) => { results.beehiiv = r; })
|
|
349
363
|
.catch((e) => { results.beehiiv = { success: false, error: e.message }; })
|
|
@@ -521,10 +535,17 @@ const MONTH_NAMES = [
|
|
|
521
535
|
* {date.year} — 2026
|
|
522
536
|
* {date.full} — March 17, 2026
|
|
523
537
|
*/
|
|
524
|
-
function buildTemplateContext(brand) {
|
|
538
|
+
function buildTemplateContext(brand, settings) {
|
|
525
539
|
const now = new Date();
|
|
526
540
|
const month = now.getMonth();
|
|
527
541
|
|
|
542
|
+
// Resolve discount code if provided in settings
|
|
543
|
+
const discountCodes = require('../../payment/discount-codes.js');
|
|
544
|
+
const discountCode = settings?.discountCode;
|
|
545
|
+
const discount = discountCode
|
|
546
|
+
? discountCodes.validate(discountCode)
|
|
547
|
+
: { code: '', percent: '' };
|
|
548
|
+
|
|
528
549
|
return {
|
|
529
550
|
brand: brand || {},
|
|
530
551
|
season: {
|
|
@@ -538,7 +559,45 @@ function buildTemplateContext(brand) {
|
|
|
538
559
|
year: String(now.getFullYear()),
|
|
539
560
|
full: now.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
|
|
540
561
|
},
|
|
562
|
+
discount: {
|
|
563
|
+
code: discount.code || '',
|
|
564
|
+
percent: discount.percent || '',
|
|
565
|
+
},
|
|
541
566
|
};
|
|
542
567
|
}
|
|
543
568
|
|
|
569
|
+
/**
|
|
570
|
+
* Recursively walk an object and resolve {template} variables in all string values.
|
|
571
|
+
* Arrays and nested objects are walked. Non-string values are passed through.
|
|
572
|
+
*/
|
|
573
|
+
function resolveTemplateVars(obj, context) {
|
|
574
|
+
const template = require('node-powertools').template;
|
|
575
|
+
|
|
576
|
+
if (typeof obj === 'string') {
|
|
577
|
+
if (!obj.includes('{')) {
|
|
578
|
+
return obj;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Resolve, then replace any "null" or "undefined" leftovers with empty string
|
|
582
|
+
const resolved = template(obj, context);
|
|
583
|
+
return resolved.replace(/\bnull\b|\bundefined\b/g, '').replace(/\s{2,}/g, ' ').trim();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (Array.isArray(obj)) {
|
|
587
|
+
return obj.map(item => resolveTemplateVars(item, context));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (obj && typeof obj === 'object') {
|
|
591
|
+
const result = {};
|
|
592
|
+
|
|
593
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
594
|
+
result[key] = resolveTemplateVars(value, context);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return result;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return obj;
|
|
601
|
+
}
|
|
602
|
+
|
|
544
603
|
module.exports = Marketing;
|
|
@@ -274,6 +274,47 @@ function buildFields(userDoc) {
|
|
|
274
274
|
return fields;
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// Cached segment name → Beehiiv segment ID map
|
|
278
|
+
let _segmentIdCache = null;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Fetch segment definitions from Beehiiv and build a name → id map.
|
|
282
|
+
* Segments are created by OMEGA with names matching the SSOT keys in constants.js.
|
|
283
|
+
* Cached in memory for the lifetime of the process.
|
|
284
|
+
*
|
|
285
|
+
* @returns {object} Map of segment name → Beehiiv segment ID
|
|
286
|
+
*/
|
|
287
|
+
async function resolveSegmentIds() {
|
|
288
|
+
if (_segmentIdCache) {
|
|
289
|
+
return _segmentIdCache;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const publicationId = await getPublicationId();
|
|
293
|
+
|
|
294
|
+
if (!publicationId) {
|
|
295
|
+
return {};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const data = await fetch(`${BASE_URL}/publications/${publicationId}/segments?limit=100`, {
|
|
300
|
+
response: 'json',
|
|
301
|
+
headers: headers(),
|
|
302
|
+
timeout: 10000,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
_segmentIdCache = {};
|
|
306
|
+
|
|
307
|
+
for (const segment of (data.data || [])) {
|
|
308
|
+
_segmentIdCache[segment.name] = segment.id;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return _segmentIdCache;
|
|
312
|
+
} catch (e) {
|
|
313
|
+
console.error('Beehiiv resolveSegmentIds error:', e);
|
|
314
|
+
return {};
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
277
318
|
// --- Campaigns (Posts) ---
|
|
278
319
|
|
|
279
320
|
/**
|
|
@@ -362,6 +403,9 @@ async function createPost(options) {
|
|
|
362
403
|
}
|
|
363
404
|
|
|
364
405
|
module.exports = {
|
|
406
|
+
// Resolution
|
|
407
|
+
resolveSegmentIds,
|
|
408
|
+
|
|
365
409
|
// Contacts
|
|
366
410
|
addContact,
|
|
367
411
|
removeContact,
|
|
@@ -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,
|
|
@@ -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: '' },
|