backend-manager 5.0.158 → 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 CHANGED
@@ -14,6 +14,24 @@ 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
+
17
35
  # [5.0.158] - 2026-03-17
18
36
  ### Added
19
37
  - 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.159",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -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
 
@@ -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 — 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: '' },