backend-manager 5.0.154 → 5.0.157
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 +41 -0
- package/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/src/cli/commands/setup-tests/firestore-indexes-required.js +1 -1
- package/src/cli/commands/setup-tests/functions-package.js +3 -1
- package/src/cli/commands/setup-tests/{required-indexes.js → helpers/required-indexes.js} +22 -0
- package/src/cli/commands/setup-tests/helpers/seed-campaigns.js +132 -0
- package/src/cli/commands/setup-tests/index.js +2 -0
- package/src/cli/commands/setup-tests/marketing-campaigns-seeded.js +109 -0
- package/src/manager/cron/daily/marketing-prune.js +140 -0
- package/src/manager/cron/frequent/marketing-campaigns.js +158 -0
- package/src/manager/events/auth/on-create.js +36 -1
- package/src/manager/libraries/email/constants.js +46 -2
- package/src/manager/libraries/email/index.js +13 -3
- package/src/manager/libraries/email/marketing/index.js +205 -33
- package/src/manager/libraries/email/providers/beehiiv.js +90 -0
- package/src/manager/libraries/email/providers/sendgrid.js +179 -1
- package/src/manager/libraries/email/transactional/index.js +17 -0
- package/src/manager/libraries/email/utm.js +116 -0
- package/src/manager/libraries/notification.js +223 -0
- package/src/manager/routes/admin/notification/post.js +16 -241
- package/src/manager/routes/marketing/campaign/delete.js +45 -0
- package/src/manager/routes/marketing/campaign/get.js +69 -0
- package/src/manager/routes/marketing/campaign/post.js +161 -0
- package/src/manager/routes/marketing/campaign/put.js +122 -0
- package/src/manager/routes/user/data-request/delete.js +3 -1
- package/src/manager/routes/user/data-request/get.js +3 -1
- package/src/manager/routes/user/data-request/post.js +3 -1
- package/src/manager/routes/user/delete.js +4 -3
- package/src/manager/routes/user/signup/post.js +10 -8
- package/src/manager/schemas/marketing/campaign/delete.js +6 -0
- package/src/manager/schemas/marketing/campaign/get.js +11 -0
- package/src/manager/schemas/marketing/campaign/post.js +35 -0
- package/src/manager/schemas/marketing/campaign/put.js +35 -0
- package/templates/backend-manager-config.json +3 -0
|
@@ -14,7 +14,10 @@ const RETRY_DELAY_MS = 1000;
|
|
|
14
14
|
* - Batch writes user doc + increment count atomically
|
|
15
15
|
* - Retries up to 3 times with exponential backoff on failure
|
|
16
16
|
*
|
|
17
|
-
*
|
|
17
|
+
* If the user signed up via a provider (Google, Facebook, etc.), their display name
|
|
18
|
+
* is extracted and stored as personal.name.first/last on the user doc.
|
|
19
|
+
*
|
|
20
|
+
* Non-critical work (welcome emails, marketing contact) is handled
|
|
18
21
|
* by the user/signup endpoint, which the frontend calls after account creation.
|
|
19
22
|
*/
|
|
20
23
|
module.exports = async ({ Manager, assistant, user, context, libraries }) => {
|
|
@@ -42,12 +45,20 @@ module.exports = async ({ Manager, assistant, user, context, libraries }) => {
|
|
|
42
45
|
return;
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
// Extract name from provider data (e.g., Google, Facebook, GitHub)
|
|
49
|
+
const providerName = extractProviderName(user);
|
|
50
|
+
|
|
51
|
+
assistant.log(`onCreate: Inferred name from provider:`, providerName);
|
|
52
|
+
|
|
45
53
|
// Create user record using Manager.User() helper
|
|
46
54
|
const userRecord = Manager.User({
|
|
47
55
|
auth: {
|
|
48
56
|
uid: user.uid,
|
|
49
57
|
email: user.email,
|
|
50
58
|
},
|
|
59
|
+
personal: providerName ? {
|
|
60
|
+
name: providerName,
|
|
61
|
+
} : undefined,
|
|
51
62
|
}).properties;
|
|
52
63
|
|
|
53
64
|
// Add metadata tag (merge into existing metadata to preserve metadata.created from User schema)
|
|
@@ -81,6 +92,30 @@ module.exports = async ({ Manager, assistant, user, context, libraries }) => {
|
|
|
81
92
|
}
|
|
82
93
|
};
|
|
83
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Extract first/last name from provider data (Google, Facebook, GitHub, etc.)
|
|
97
|
+
* Returns { first, last } or null if no name found
|
|
98
|
+
*/
|
|
99
|
+
function extractProviderName(user) {
|
|
100
|
+
// Try provider-specific displayName first, then top-level displayName
|
|
101
|
+
const displayName = user.providerData?.find(p =>
|
|
102
|
+
p.providerId !== 'password'
|
|
103
|
+
&& p.providerId !== 'anonymous'
|
|
104
|
+
&& p.displayName
|
|
105
|
+
)?.displayName || user.displayName;
|
|
106
|
+
|
|
107
|
+
if (!displayName) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const parts = displayName.trim().split(/\s+/);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
first: parts[0] || null,
|
|
115
|
+
last: parts.slice(1).join(' ') || null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
84
119
|
/**
|
|
85
120
|
* Retry a function up to maxRetries times with exponential backoff
|
|
86
121
|
*/
|
|
@@ -151,7 +151,7 @@ const FIELDS = {
|
|
|
151
151
|
user_personal_name_first: { display: 'First Name', source: 'user', path: 'personal.name.first', type: 'text', skip: ['sendgrid'] },
|
|
152
152
|
user_personal_name_last: { display: 'Last Name', source: 'user', path: 'personal.name.last', type: 'text', skip: ['sendgrid'] },
|
|
153
153
|
user_personal_company: { display: 'Company', source: 'user', path: 'personal.company.name', type: 'text' },
|
|
154
|
-
user_personal_country: { display: 'Country', source: 'user', path: 'personal.location.country', type: 'text' },
|
|
154
|
+
user_personal_country: { display: 'Country', source: 'user', path: 'personal.location.country', type: 'text', skip: ['beehiiv'] },
|
|
155
155
|
user_metadata_signup_date: { display: 'Signup Date', source: 'user', path: 'metadata.created.timestamp', type: 'date' },
|
|
156
156
|
user_metadata_last_activity: { display: 'Last Activity', source: 'user', path: 'metadata.updated.timestamp', type: 'date' },
|
|
157
157
|
|
|
@@ -167,9 +167,52 @@ const FIELDS = {
|
|
|
167
167
|
user_subscription_payment_last_date: { display: 'Last Payment Date', source: 'user', path: 'subscription.payment.updatedBy.date.timestamp', type: 'date' },
|
|
168
168
|
|
|
169
169
|
// Attribution
|
|
170
|
-
user_attribution_utm_source: { display: 'UTM Source', source: 'user', path: 'attribution.utm.tags.utm_source', type: 'text' },
|
|
170
|
+
user_attribution_utm_source: { display: 'UTM Source', source: 'user', path: 'attribution.utm.tags.utm_source', type: 'text', skip: ['beehiiv'] },
|
|
171
171
|
};
|
|
172
172
|
|
|
173
|
+
// Master segment dictionary — SSOT for all marketing segments.
|
|
174
|
+
//
|
|
175
|
+
// Segments are created in each provider by OMEGA (like custom fields).
|
|
176
|
+
// BEM references them by key. Provider-specific IDs are resolved at runtime.
|
|
177
|
+
//
|
|
178
|
+
// Condition types:
|
|
179
|
+
// 'field' — custom field condition (uses FIELDS above)
|
|
180
|
+
// 'engagement' — provider built-in engagement tracking (opens, clicks)
|
|
181
|
+
//
|
|
182
|
+
// To add a new segment:
|
|
183
|
+
// 1. Add an entry here
|
|
184
|
+
// 2. Add matching entry in OMEGA's src/lib/bem-segments.js
|
|
185
|
+
// 3. Run OMEGA: npm start -- --service=sendgrid,beehiiv --brand=X
|
|
186
|
+
//
|
|
187
|
+
// Providers:
|
|
188
|
+
// skip — Array of provider names to skip segment creation for
|
|
189
|
+
// (e.g., engagement segments may not be supported on all providers)
|
|
190
|
+
const SEGMENTS = {
|
|
191
|
+
// Subscription
|
|
192
|
+
subscription_free: { display: 'Free Users', conditions: [{ field: 'user_subscription_plan', op: '==', value: 'basic' }] },
|
|
193
|
+
subscription_paid: { display: 'Paid Users', conditions: [{ field: 'user_subscription_plan', op: '!=', value: 'basic' }, { field: 'user_subscription_status', op: '==', value: 'active' }] },
|
|
194
|
+
subscription_trialing: { display: 'Trialing', conditions: [{ field: 'user_subscription_trialing', op: '==', value: 'true' }] },
|
|
195
|
+
subscription_cancelling: { display: 'Cancelling', conditions: [{ field: 'user_subscription_cancelling', op: '==', value: 'true' }] },
|
|
196
|
+
subscription_suspended: { display: 'Suspended', conditions: [{ field: 'user_subscription_status', op: '==', value: 'suspended' }] },
|
|
197
|
+
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_ever_paid: { display: 'Ever Paid', conditions: [{ field: 'user_subscription_ever_paid', op: '==', value: 'true' }] },
|
|
200
|
+
subscription_never_paid: { display: 'Never Paid', conditions: [{ field: 'user_subscription_ever_paid', op: '!=', value: 'true' }] },
|
|
201
|
+
|
|
202
|
+
// Lifecycle (time since signup)
|
|
203
|
+
lifecycle_7d: { display: 'Signed Up Last 7 Days', conditions: [{ field: 'user_metadata_signup_date', op: 'within', value: '7d' }] },
|
|
204
|
+
lifecycle_30d: { display: 'Signed Up Last 30 Days', conditions: [{ field: 'user_metadata_signup_date', op: 'within', value: '30d' }] },
|
|
205
|
+
lifecycle_90d: { display: 'Signed Up Last 90 Days', conditions: [{ field: 'user_metadata_signup_date', op: 'within', value: '90d' }] },
|
|
206
|
+
lifecycle_6m: { display: 'Signed Up Last 6 Months', conditions: [{ field: 'user_metadata_signup_date', op: 'within', value: '180d' }] },
|
|
207
|
+
lifecycle_1y: { display: 'Signed Up Last 1 Year', conditions: [{ field: 'user_metadata_signup_date', op: 'within', value: '365d' }] },
|
|
208
|
+
|
|
209
|
+
// Engagement (provider built-in open/click tracking)
|
|
210
|
+
engagement_active_30d: { display: 'Engaged Last 30 Days', conditions: [{ type: 'engagement', op: 'opened_or_clicked', value: '30d' }] },
|
|
211
|
+
engagement_active_90d: { display: 'Engaged Last 90 Days', conditions: [{ type: 'engagement', op: 'opened_or_clicked', value: '90d' }] },
|
|
212
|
+
engagement_inactive_90d: { display: 'Inactive 90+ Days', conditions: [{ type: 'engagement', op: 'not_opened', value: '90d' }] },
|
|
213
|
+
engagement_inactive_5m: { display: 'Inactive 5+ Months', conditions: [{ type: 'engagement', op: 'not_opened', value: '150d' }] },
|
|
214
|
+
engagement_inactive_6m: { display: 'Inactive 6+ Months', conditions: [{ type: 'engagement', op: 'not_opened', value: '180d' }] },
|
|
215
|
+
};
|
|
173
216
|
|
|
174
217
|
/**
|
|
175
218
|
* Resolve all field values from a user doc + config.
|
|
@@ -239,6 +282,7 @@ module.exports = {
|
|
|
239
282
|
GROUPS,
|
|
240
283
|
SENDERS,
|
|
241
284
|
FIELDS,
|
|
285
|
+
SEGMENTS,
|
|
242
286
|
SEND_AT_LIMIT,
|
|
243
287
|
sanitizeImagesForEmail,
|
|
244
288
|
encode,
|
|
@@ -104,7 +104,17 @@ Email.prototype.remove = function (email) {
|
|
|
104
104
|
};
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
|
-
*
|
|
107
|
+
* Create and optionally schedule a marketing campaign across enabled providers.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} settings - See Marketing.sendCampaign() for full options
|
|
110
|
+
* @returns {{ sendgrid?: object, beehiiv?: object }}
|
|
111
|
+
*/
|
|
112
|
+
Email.prototype.sendCampaign = function (settings) {
|
|
113
|
+
return this._marketing.sendCampaign(settings);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Cancel a scheduled marketing campaign (SendGrid only).
|
|
108
118
|
*
|
|
109
119
|
* @param {string} campaignId - Single Send ID
|
|
110
120
|
* @returns {{ success: boolean, error?: string }}
|
|
@@ -114,7 +124,7 @@ Email.prototype.cancelCampaign = function (campaignId) {
|
|
|
114
124
|
};
|
|
115
125
|
|
|
116
126
|
/**
|
|
117
|
-
* Get a marketing campaign by ID.
|
|
127
|
+
* Get a marketing campaign by ID (SendGrid only).
|
|
118
128
|
*
|
|
119
129
|
* @param {string} campaignId - Single Send ID
|
|
120
130
|
* @returns {object|null}
|
|
@@ -124,7 +134,7 @@ Email.prototype.getCampaign = function (campaignId) {
|
|
|
124
134
|
};
|
|
125
135
|
|
|
126
136
|
/**
|
|
127
|
-
* List marketing campaigns.
|
|
137
|
+
* List marketing campaigns (SendGrid only).
|
|
128
138
|
*
|
|
129
139
|
* @param {object} [options] - { status: 'draft' | 'scheduled' | 'triggered' }
|
|
130
140
|
* @returns {Array<object>}
|
|
@@ -24,8 +24,11 @@
|
|
|
24
24
|
* - Campaign cron jobs (send campaigns)
|
|
25
25
|
*/
|
|
26
26
|
const _ = require('lodash');
|
|
27
|
+
const MarkdownIt = require('markdown-it');
|
|
28
|
+
const md = new MarkdownIt({ html: true, breaks: true, linkify: true });
|
|
27
29
|
|
|
28
30
|
const { TEMPLATES, GROUPS, SENDERS } = require('../constants.js');
|
|
31
|
+
const { tagLinks } = require('../utm.js');
|
|
29
32
|
const sendgridProvider = require('../providers/sendgrid.js');
|
|
30
33
|
const beehiivProvider = require('../providers/beehiiv.js');
|
|
31
34
|
|
|
@@ -235,29 +238,133 @@ Marketing.prototype.remove = async function (email) {
|
|
|
235
238
|
};
|
|
236
239
|
|
|
237
240
|
/**
|
|
238
|
-
* Create and optionally schedule a marketing campaign
|
|
241
|
+
* Create and optionally schedule a marketing campaign across enabled providers.
|
|
242
|
+
*
|
|
243
|
+
* Unified interface — each provider handles what it supports:
|
|
244
|
+
* SendGrid: Single Send with lists, segments, excludes, templates
|
|
245
|
+
* Beehiiv: Post with segments, HTML content, scheduling
|
|
239
246
|
*
|
|
240
247
|
* @param {object} settings
|
|
241
|
-
* @param {string} settings.name - Campaign name
|
|
242
|
-
* @param {string} settings.subject - Email subject
|
|
248
|
+
* @param {string} settings.name - Campaign name (internal, used as title for Beehiiv)
|
|
249
|
+
* @param {string} settings.subject - Email subject line
|
|
250
|
+
* @param {string} [settings.preheader] - Email preview text
|
|
243
251
|
* @param {string} [settings.template] - Template shortcut or SendGrid template ID
|
|
252
|
+
* @param {string} [settings.content] - Markdown content (converted to HTML per provider)
|
|
253
|
+
* @param {object} [settings.data] - Dynamic template variables (SendGrid only)
|
|
244
254
|
* @param {string} [settings.sender] - Sender category ('marketing', 'newsletter', etc.)
|
|
245
|
-
* @param {Array<string>} [settings.
|
|
246
|
-
* @param {Array<string>} [settings.
|
|
247
|
-
* @param {
|
|
248
|
-
* @param {
|
|
249
|
-
* @param {
|
|
250
|
-
* @
|
|
255
|
+
* @param {Array<string>} [settings.lists] - SendGrid list IDs (defaults to brand list)
|
|
256
|
+
* @param {Array<string>} [settings.segments] - Segment IDs to target (both providers)
|
|
257
|
+
* @param {Array<string>} [settings.excludeSegments] - Segment IDs to exclude (both providers)
|
|
258
|
+
* @param {boolean} [settings.all] - Target all contacts (SendGrid only)
|
|
259
|
+
* @param {string} [settings.sendAt] - ISO datetime, 'now', or omit for draft
|
|
260
|
+
* @param {string} [settings.group] - ASM unsubscribe group (SendGrid only)
|
|
261
|
+
* @param {Array<string>} [settings.categories] - Analytics categories (SendGrid only)
|
|
262
|
+
* @param {Array<string>} [settings.providers] - Override which providers to use
|
|
263
|
+
* @returns {{ sendgrid?: object, beehiiv?: object }}
|
|
251
264
|
*/
|
|
252
265
|
Marketing.prototype.sendCampaign = async function (settings) {
|
|
253
266
|
const self = this;
|
|
254
267
|
const Manager = self.Manager;
|
|
255
268
|
const assistant = self.assistant;
|
|
256
269
|
|
|
257
|
-
|
|
258
|
-
|
|
270
|
+
const useProviders = settings.providers || Object.keys(self.providers).filter(p => self.providers[p]);
|
|
271
|
+
const results = {};
|
|
272
|
+
const promises = [];
|
|
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
|
|
276
|
+
const brand = Manager.config?.brand;
|
|
277
|
+
const templateContext = buildTemplateContext(brand);
|
|
278
|
+
const template = require('node-powertools').template;
|
|
279
|
+
|
|
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
|
+
};
|
|
287
|
+
|
|
288
|
+
// Convert markdown content to HTML, then tag links with UTM params
|
|
289
|
+
let contentHtml = resolvedSettings.content ? md.render(resolvedSettings.content) : '';
|
|
290
|
+
|
|
291
|
+
if (contentHtml) {
|
|
292
|
+
contentHtml = tagLinks(contentHtml, {
|
|
293
|
+
brandUrl: brand?.url,
|
|
294
|
+
brandId: brand?.id,
|
|
295
|
+
campaign: settings.name,
|
|
296
|
+
type: 'marketing',
|
|
297
|
+
utm: settings.utm,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Resolve SSOT segment keys → provider segment IDs
|
|
302
|
+
const resolvedSegments = {};
|
|
303
|
+
|
|
304
|
+
if (useProviders.includes('sendgrid') && self.providers.sendgrid) {
|
|
305
|
+
const segmentIdMap = await sendgridProvider.resolveSegmentIds();
|
|
306
|
+
|
|
307
|
+
resolvedSegments.sendgrid = {
|
|
308
|
+
segments: (settings.segments || []).map(key => segmentIdMap[key] || key).filter(Boolean),
|
|
309
|
+
excludeSegments: (settings.excludeSegments || []).map(key => segmentIdMap[key] || key).filter(Boolean),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Beehiiv: segment resolution will go here when Beehiiv segments are supported
|
|
314
|
+
|
|
315
|
+
assistant.log('Marketing.sendCampaign():', {
|
|
316
|
+
name: resolvedSettings.name,
|
|
317
|
+
providers: useProviders,
|
|
318
|
+
sendAt: settings.sendAt || 'draft',
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// --- SendGrid ---
|
|
322
|
+
if (useProviders.includes('sendgrid') && self.providers.sendgrid) {
|
|
323
|
+
const sgSettings = {
|
|
324
|
+
...resolvedSettings,
|
|
325
|
+
segments: resolvedSegments.sendgrid?.segments || [],
|
|
326
|
+
excludeSegments: resolvedSegments.sendgrid?.excludeSegments || [],
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
promises.push(
|
|
330
|
+
self._sendCampaignSendGrid(sgSettings, contentHtml)
|
|
331
|
+
.then((r) => { results.sendgrid = r; })
|
|
332
|
+
.catch((e) => { results.sendgrid = { success: false, error: e.message }; })
|
|
333
|
+
);
|
|
259
334
|
}
|
|
260
335
|
|
|
336
|
+
// --- Beehiiv ---
|
|
337
|
+
if (useProviders.includes('beehiiv') && self.providers.beehiiv) {
|
|
338
|
+
promises.push(
|
|
339
|
+
beehiivProvider.createPost({
|
|
340
|
+
title: resolvedSettings.name,
|
|
341
|
+
subject: resolvedSettings.subject,
|
|
342
|
+
preheader: resolvedSettings.preheader,
|
|
343
|
+
content: contentHtml,
|
|
344
|
+
sendAt: settings.sendAt,
|
|
345
|
+
segments: settings.segments,
|
|
346
|
+
excludeSegments: settings.excludeSegments,
|
|
347
|
+
})
|
|
348
|
+
.then((r) => { results.beehiiv = r; })
|
|
349
|
+
.catch((e) => { results.beehiiv = { success: false, error: e.message }; })
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
await Promise.all(promises);
|
|
354
|
+
|
|
355
|
+
assistant.log('Marketing.sendCampaign() results:', results);
|
|
356
|
+
|
|
357
|
+
return results;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* SendGrid-specific campaign creation (Single Send + optional schedule).
|
|
362
|
+
* @private
|
|
363
|
+
*/
|
|
364
|
+
Marketing.prototype._sendCampaignSendGrid = async function (settings, contentHtml) {
|
|
365
|
+
const self = this;
|
|
366
|
+
const Manager = self.Manager;
|
|
367
|
+
|
|
261
368
|
const templateId = TEMPLATES[settings.template] || settings.template || TEMPLATES['default'];
|
|
262
369
|
|
|
263
370
|
// Resolve sender
|
|
@@ -295,61 +402,57 @@ Marketing.prototype.sendCampaign = async function (settings) {
|
|
|
295
402
|
...require('node-powertools').arrayify(settings.categories),
|
|
296
403
|
].filter(Boolean));
|
|
297
404
|
|
|
298
|
-
assistant.log('Marketing.sendCampaign():', { name: settings.name, sendTo, templateId });
|
|
299
|
-
|
|
300
405
|
// Create the Single Send
|
|
301
406
|
const createResult = await sendgridProvider.createSingleSend({
|
|
302
407
|
name: settings.name,
|
|
303
408
|
subject: settings.subject,
|
|
409
|
+
preheader: settings.preheader,
|
|
304
410
|
templateId,
|
|
305
411
|
from,
|
|
306
412
|
sendTo,
|
|
413
|
+
excludeSegments: settings.excludeSegments,
|
|
307
414
|
asmGroupId,
|
|
308
415
|
categories,
|
|
416
|
+
dynamicTemplateData: {
|
|
417
|
+
...settings.data,
|
|
418
|
+
...(contentHtml ? { content: contentHtml } : {}),
|
|
419
|
+
},
|
|
309
420
|
});
|
|
310
421
|
|
|
311
422
|
if (!createResult.success) {
|
|
312
|
-
assistant.error('Marketing.sendCampaign() create failed:', createResult.error);
|
|
313
423
|
return createResult;
|
|
314
424
|
}
|
|
315
425
|
|
|
316
426
|
// Schedule if sendAt is provided
|
|
317
|
-
if (settings.sendAt) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const scheduleResult = await sendgridProvider.scheduleSingleSend(createResult.id, sendAt);
|
|
321
|
-
|
|
322
|
-
if (!scheduleResult.success) {
|
|
323
|
-
assistant.error('Marketing.sendCampaign() schedule failed:', scheduleResult.error);
|
|
324
|
-
return { success: false, id: createResult.id, error: scheduleResult.error };
|
|
325
|
-
}
|
|
427
|
+
if (!settings.sendAt) {
|
|
428
|
+
return { success: true, id: createResult.id, scheduled: false };
|
|
429
|
+
}
|
|
326
430
|
|
|
327
|
-
|
|
431
|
+
const sendAt = settings.sendAt === 'now' ? 'now' : new Date(settings.sendAt).toISOString();
|
|
432
|
+
const scheduleResult = await sendgridProvider.scheduleSingleSend(createResult.id, sendAt);
|
|
328
433
|
|
|
329
|
-
|
|
434
|
+
if (!scheduleResult.success) {
|
|
435
|
+
return { success: false, id: createResult.id, error: scheduleResult.error };
|
|
330
436
|
}
|
|
331
437
|
|
|
332
|
-
|
|
333
|
-
return { success: true, id: createResult.id, scheduled: false };
|
|
438
|
+
return { success: true, id: createResult.id, scheduled: true };
|
|
334
439
|
};
|
|
335
440
|
|
|
336
441
|
/**
|
|
337
|
-
* Cancel a scheduled campaign.
|
|
442
|
+
* Cancel a scheduled campaign (SendGrid only).
|
|
338
443
|
*
|
|
339
444
|
* @param {string} campaignId - Single Send ID
|
|
340
445
|
* @returns {{ success: boolean, error?: string }}
|
|
341
446
|
*/
|
|
342
447
|
Marketing.prototype.cancelCampaign = async function (campaignId) {
|
|
343
448
|
const self = this;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
assistant.log('Marketing.cancelCampaign():', campaignId);
|
|
449
|
+
self.assistant.log('Marketing.cancelCampaign():', campaignId);
|
|
347
450
|
|
|
348
451
|
return sendgridProvider.cancelSingleSend(campaignId);
|
|
349
452
|
};
|
|
350
453
|
|
|
351
454
|
/**
|
|
352
|
-
* Get a campaign by ID.
|
|
455
|
+
* Get a campaign by ID (SendGrid only).
|
|
353
456
|
*
|
|
354
457
|
* @param {string} campaignId - Single Send ID
|
|
355
458
|
* @returns {object|null}
|
|
@@ -359,7 +462,7 @@ Marketing.prototype.getCampaign = async function (campaignId) {
|
|
|
359
462
|
};
|
|
360
463
|
|
|
361
464
|
/**
|
|
362
|
-
* List campaigns with optional status filter.
|
|
465
|
+
* List campaigns with optional status filter (SendGrid only).
|
|
363
466
|
*
|
|
364
467
|
* @param {object} [options]
|
|
365
468
|
* @param {string} [options.status] - Filter: draft, scheduled, triggered
|
|
@@ -369,4 +472,73 @@ Marketing.prototype.listCampaigns = async function (options) {
|
|
|
369
472
|
return sendgridProvider.listSingleSends(options);
|
|
370
473
|
};
|
|
371
474
|
|
|
475
|
+
// --- Campaign variable resolution ---
|
|
476
|
+
|
|
477
|
+
const SEASONS = {
|
|
478
|
+
0: 'Winter', // Jan
|
|
479
|
+
1: 'Winter', // Feb
|
|
480
|
+
2: 'Spring', // Mar
|
|
481
|
+
3: 'Spring', // Apr
|
|
482
|
+
4: 'Spring', // May
|
|
483
|
+
5: 'Summer', // Jun
|
|
484
|
+
6: 'Summer', // Jul
|
|
485
|
+
7: 'Summer', // Aug
|
|
486
|
+
8: 'Fall', // Sep
|
|
487
|
+
9: 'Fall', // Oct
|
|
488
|
+
10: 'Fall', // Nov
|
|
489
|
+
11: 'Winter', // Dec
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const HOLIDAYS = {
|
|
493
|
+
0: 'New Year', // Jan
|
|
494
|
+
1: 'Valentine\'s Day', // Feb
|
|
495
|
+
2: 'Spring', // Mar
|
|
496
|
+
3: 'Spring', // Apr
|
|
497
|
+
4: 'Memorial Day', // May
|
|
498
|
+
5: 'Summer', // Jun
|
|
499
|
+
6: 'Independence Day', // Jul
|
|
500
|
+
7: 'Back to School', // Aug
|
|
501
|
+
8: 'Labor Day', // Sep
|
|
502
|
+
9: 'Halloween', // Oct
|
|
503
|
+
10: 'Black Friday', // Nov
|
|
504
|
+
11: 'Christmas', // Dec
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const MONTH_NAMES = [
|
|
508
|
+
'January', 'February', 'March', 'April', 'May', 'June',
|
|
509
|
+
'July', 'August', 'September', 'October', 'November', 'December',
|
|
510
|
+
];
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Build template context for campaign variable resolution.
|
|
514
|
+
* Used with powertools.template() — supports nested paths like {brand.name}.
|
|
515
|
+
*
|
|
516
|
+
* Available variables:
|
|
517
|
+
* {brand.name}, {brand.id}, {brand.url}
|
|
518
|
+
* {season.name} — Winter, Spring, Summer, Fall
|
|
519
|
+
* {holiday.name} — New Year, Valentine's Day, Black Friday, Christmas, etc.
|
|
520
|
+
* {date.month} — January, February, etc.
|
|
521
|
+
* {date.year} — 2026
|
|
522
|
+
* {date.full} — March 17, 2026
|
|
523
|
+
*/
|
|
524
|
+
function buildTemplateContext(brand) {
|
|
525
|
+
const now = new Date();
|
|
526
|
+
const month = now.getMonth();
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
brand: brand || {},
|
|
530
|
+
season: {
|
|
531
|
+
name: SEASONS[month],
|
|
532
|
+
},
|
|
533
|
+
holiday: {
|
|
534
|
+
name: HOLIDAYS[month],
|
|
535
|
+
},
|
|
536
|
+
date: {
|
|
537
|
+
month: MONTH_NAMES[month],
|
|
538
|
+
year: String(now.getFullYear()),
|
|
539
|
+
full: now.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
372
544
|
module.exports = Marketing;
|
|
@@ -274,9 +274,99 @@ function buildFields(userDoc) {
|
|
|
274
274
|
return fields;
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// --- Campaigns (Posts) ---
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Create a Beehiiv post (their equivalent of a campaign/newsletter).
|
|
281
|
+
*
|
|
282
|
+
* @param {object} options
|
|
283
|
+
* @param {string} options.title - Post title (required)
|
|
284
|
+
* @param {string} [options.subject] - Email subject line (defaults to title)
|
|
285
|
+
* @param {string} [options.preheader] - Email preview text
|
|
286
|
+
* @param {string} [options.content] - HTML content body
|
|
287
|
+
* @param {string} [options.status] - 'draft' or 'confirmed' (default: confirmed = send)
|
|
288
|
+
* @param {string} [options.sendAt] - ISO datetime to schedule, or null for immediate
|
|
289
|
+
* @param {Array<string>} [options.segments] - Segment IDs to include
|
|
290
|
+
* @param {Array<string>} [options.excludeSegments] - Segment IDs to exclude
|
|
291
|
+
* @returns {{ success: boolean, id?: string, scheduled?: boolean, error?: string }}
|
|
292
|
+
*/
|
|
293
|
+
async function createPost(options) {
|
|
294
|
+
const publicationId = await getPublicationId();
|
|
295
|
+
|
|
296
|
+
if (!publicationId) {
|
|
297
|
+
return { success: false, error: 'Publication not found' };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const { title, subject, preheader, content, status, sendAt, segments, excludeSegments } = options;
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const body = {
|
|
304
|
+
title,
|
|
305
|
+
status: sendAt ? 'confirmed' : (status || 'confirmed'),
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// Content
|
|
309
|
+
if (content) {
|
|
310
|
+
body.body_content = content;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Scheduling
|
|
314
|
+
if (sendAt && sendAt !== 'now') {
|
|
315
|
+
body.scheduled_at = new Date(sendAt).toISOString();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Email settings
|
|
319
|
+
const emailSettings = {};
|
|
320
|
+
|
|
321
|
+
if (subject) {
|
|
322
|
+
emailSettings.subject_line = subject;
|
|
323
|
+
}
|
|
324
|
+
if (preheader) {
|
|
325
|
+
emailSettings.preview_text = preheader;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (Object.keys(emailSettings).length) {
|
|
329
|
+
body.email_settings = emailSettings;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Audience targeting (segments)
|
|
333
|
+
if ((segments && segments.length) || (excludeSegments && excludeSegments.length)) {
|
|
334
|
+
body.recipients = {};
|
|
335
|
+
|
|
336
|
+
if (segments && segments.length) {
|
|
337
|
+
body.recipients.segment_ids = segments;
|
|
338
|
+
}
|
|
339
|
+
if (excludeSegments && excludeSegments.length) {
|
|
340
|
+
body.recipients.exclude_segment_ids = excludeSegments;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const data = await fetch(`${BASE_URL}/publications/${publicationId}/posts`, {
|
|
345
|
+
method: 'post',
|
|
346
|
+
response: 'json',
|
|
347
|
+
headers: headers(),
|
|
348
|
+
timeout: 15000,
|
|
349
|
+
body,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
if (data.data?.id) {
|
|
353
|
+
const scheduled = !!sendAt;
|
|
354
|
+
return { success: true, id: data.data.id, scheduled };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return { success: false, error: data.message || 'Unknown error' };
|
|
358
|
+
} catch (e) {
|
|
359
|
+
console.error('Beehiiv createPost error:', e);
|
|
360
|
+
return { success: false, error: e.message };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
277
364
|
module.exports = {
|
|
278
365
|
// Contacts
|
|
279
366
|
addContact,
|
|
280
367
|
removeContact,
|
|
281
368
|
buildFields,
|
|
369
|
+
|
|
370
|
+
// Campaigns
|
|
371
|
+
createPost,
|
|
282
372
|
};
|