backend-manager 5.0.157 → 5.0.159

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