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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.158",
3
+ "version": "5.0.160",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -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 — Upgrade & Save!',
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
- 'For a limited time, upgrade your **{brand.name}** plan and save big.',
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
- 'Don\'t miss out this offer ends soon!',
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', 'subscription_cancelled', 'subscription_churned'],
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
- pattern: 'monthly',
79
- day: 15,
80
- hour: 14,
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': ['subscription_free', 'subscription_cancelled', 'subscription_churned'],
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
- subscription_churned: { display: 'Churned (Paid → Cancelled)', conditions: [{ field: 'user_subscription_ever_paid', op: '==', value: 'true' }, { field: 'user_subscription_status', op: '==', value: 'cancelled' }] },
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}, {year}, etc.
275
- // Uses single braces via powertools.template() distinct from {{template}} vars handled by SendGrid
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
- const template = require('node-powertools').template;
278
+ const templateContext = buildTemplateContext(brand, settings);
279
+ let resolvedSettings = resolveTemplateVars(settings, templateContext);
279
280
 
280
- const resolvedSettings = {
281
- ...settings,
282
- name: template(settings.name || '', templateContext),
283
- subject: template(settings.subject || '', templateContext),
284
- preheader: template(settings.preheader || '', templateContext),
285
- content: template(settings.content || '', templateContext),
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: settings.name,
302
+ campaign: resolvedSettings.name,
296
303
  type: 'marketing',
297
- utm: settings.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
- // Beehiiv: segment resolution will go here when Beehiiv segments are supported
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: settings.segments,
346
- excludeSegments: settings.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 — hardcoded for now, move to Firestore/config later
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
- 'FLASH20': { percent: 20, duration: 'once' },
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
- * @returns {{ valid: boolean, code: string, percent: number, duration: string } | { valid: boolean, code: string }}
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: '' },