backend-manager 5.0.148 → 5.0.149
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 +50 -0
- package/CLAUDE.md +26 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +14 -4
- package/src/cli/commands/test.js +4 -10
- package/src/manager/cron/daily/ghostii-auto-publisher.js +25 -25
- package/src/manager/cron/frequent/abandoned-carts.js +7 -5
- package/src/manager/cron/frequent/email-queue.js +56 -0
- package/src/manager/events/auth/before-signin.js +3 -0
- package/src/manager/events/auth/on-delete.js +8 -0
- package/src/manager/events/firestore/payments-disputes/on-write.js +2 -1
- package/src/manager/events/firestore/payments-webhooks/on-write.js +9 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +7 -21
- package/src/manager/functions/core/actions/api/admin/get-stats.js +2 -2
- package/src/manager/functions/core/actions/api/admin/send-email.js +14 -14
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +22 -318
- package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +1 -1
- package/src/manager/functions/core/actions/api/general/remove-marketing-contact.js +2 -185
- package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
- package/src/manager/functions/core/actions/api/special/setup-electron-manager-client.js +2 -2
- package/src/manager/functions/core/actions/api/test/health.js +1 -0
- package/src/manager/helpers/api-manager.js +2 -2
- package/src/manager/helpers/user.js +3 -1
- package/src/manager/index.js +15 -10
- package/src/manager/libraries/email/constants.js +243 -0
- package/src/manager/libraries/email/index.js +145 -0
- package/src/manager/libraries/email/marketing/index.js +377 -0
- package/src/manager/libraries/email/providers/beehiiv.js +258 -0
- package/src/manager/libraries/email/providers/sendgrid.js +429 -0
- package/src/manager/libraries/{email.js → email/transactional/index.js} +91 -99
- package/src/manager/libraries/email/validation.js +168 -0
- package/src/manager/routes/admin/cron/post.js +3 -3
- package/src/manager/routes/admin/email/post.js +1 -1
- package/src/manager/routes/admin/stats/get.js +2 -2
- package/src/manager/routes/{app → brand}/get.js +1 -1
- package/src/manager/routes/general/email/templates/download-app-link.js +1 -1
- package/src/manager/routes/marketing/contact/delete.js +2 -164
- package/src/manager/routes/marketing/contact/post.js +45 -298
- package/src/manager/routes/marketing/contact/put.js +39 -0
- package/src/manager/routes/payments/cancel/post.js +11 -0
- package/src/manager/routes/special/electron-client/post.js +3 -3
- package/src/manager/routes/test/health/get.js +1 -0
- package/src/manager/routes/user/data-request/delete.js +2 -2
- package/src/manager/routes/user/data-request/get.js +2 -2
- package/src/manager/routes/user/data-request/post.js +2 -2
- package/src/manager/routes/user/delete.js +1 -1
- package/src/manager/routes/user/feedback/post.js +12 -8
- package/src/manager/routes/user/signup/post.js +48 -37
- package/src/manager/schemas/admin/email/post.js +4 -4
- package/src/manager/schemas/marketing/contact/delete.js +3 -1
- package/src/manager/schemas/marketing/contact/post.js +3 -1
- package/src/manager/schemas/marketing/contact/put.js +6 -0
- package/src/manager/schemas/special/electron-client/post.js +2 -2
- package/src/manager/schemas/user/feedback/post.js +2 -2
- package/src/test/run-tests.js +1 -1
- package/src/test/runner.js +22 -10
- package/src/test/test-accounts.js +9 -0
- package/src/test/utils/extended-mode-warning.js +11 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +11 -0
- package/test/events/payments/journey-payments-trial-cancel.js +11 -0
- package/test/functions/admin/edit-post.js +2 -2
- package/test/functions/admin/write-repo-content.js +2 -2
- package/test/functions/general/add-marketing-contact.js +21 -23
- package/test/helpers/email-validation.js +420 -0
- package/test/helpers/email.js +119 -6
- package/test/helpers/marketing-lifecycle.js +121 -0
- package/test/helpers/user.js +2 -2
- package/test/routes/admin/create-post.js +2 -2
- package/test/routes/admin/post.js +2 -2
- package/test/routes/admin/repo-content.js +2 -2
- package/test/routes/marketing/contact.js +21 -24
- package/test/routes/payments/cancel.js +18 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marketing email library — contact syncing + campaign management
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const email = Manager.Email(assistant);
|
|
6
|
+
*
|
|
7
|
+
* // Add a new contact (newsletter subscribe, lightweight)
|
|
8
|
+
* await email.add({ email, firstName, lastName, source });
|
|
9
|
+
*
|
|
10
|
+
* // Sync a user's full data to SendGrid/Beehiiv (all custom fields)
|
|
11
|
+
* await email.sync(userDoc);
|
|
12
|
+
*
|
|
13
|
+
* // Remove a contact from all providers
|
|
14
|
+
* await email.remove('user@example.com');
|
|
15
|
+
*
|
|
16
|
+
* // Send a marketing campaign (Single Send)
|
|
17
|
+
* await email.send({ type: 'marketing', name, subject, segments, ... });
|
|
18
|
+
*
|
|
19
|
+
* Used by:
|
|
20
|
+
* - routes/marketing/contact (add)
|
|
21
|
+
* - Auth on-create handler (sync on signup)
|
|
22
|
+
* - Payment transition handlers (sync on subscription change)
|
|
23
|
+
* - Auth on-delete handler (remove contact)
|
|
24
|
+
* - Campaign cron jobs (send campaigns)
|
|
25
|
+
*/
|
|
26
|
+
const _ = require('lodash');
|
|
27
|
+
|
|
28
|
+
const { TEMPLATES, GROUPS, SENDERS, DEFAULT_PROVIDERS } = require('../constants.js');
|
|
29
|
+
const sendgridProvider = require('../providers/sendgrid.js');
|
|
30
|
+
const beehiivProvider = require('../providers/beehiiv.js');
|
|
31
|
+
|
|
32
|
+
function Marketing(assistant) {
|
|
33
|
+
const self = this;
|
|
34
|
+
|
|
35
|
+
self.assistant = assistant;
|
|
36
|
+
self.Manager = assistant.Manager;
|
|
37
|
+
self.admin = self.Manager.libraries.admin;
|
|
38
|
+
|
|
39
|
+
return self;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Add a new contact to all providers (lightweight — no full user doc needed).
|
|
44
|
+
* Used by newsletter subscribe and admin bulk import.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} options
|
|
47
|
+
* @param {string} options.email
|
|
48
|
+
* @param {string} [options.firstName]
|
|
49
|
+
* @param {string} [options.lastName]
|
|
50
|
+
* @param {string} [options.source] - UTM source
|
|
51
|
+
* @param {object} [options.customFields] - Extra SendGrid custom fields (keyed by field ID)
|
|
52
|
+
* @param {Array<string>} [options.providers] - Which providers (default: all available)
|
|
53
|
+
* @returns {{ sendgrid?: object, beehiiv?: object }}
|
|
54
|
+
*/
|
|
55
|
+
Marketing.prototype.add = async function (options) {
|
|
56
|
+
const self = this;
|
|
57
|
+
const assistant = self.assistant;
|
|
58
|
+
const { email, firstName, lastName, source, customFields, providers } = options;
|
|
59
|
+
|
|
60
|
+
if (!email) {
|
|
61
|
+
assistant.warn('Marketing.add(): No email provided, skipping');
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const shouldAdd = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
66
|
+
const addProviders = providers || DEFAULT_PROVIDERS;
|
|
67
|
+
const results = {};
|
|
68
|
+
|
|
69
|
+
if (!shouldAdd) {
|
|
70
|
+
assistant.log('Marketing.add(): Skipping providers (testing mode)');
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
assistant.log('Marketing.add():', { email });
|
|
75
|
+
|
|
76
|
+
const promises = [];
|
|
77
|
+
|
|
78
|
+
if (addProviders.includes('sendgrid') && process.env.SENDGRID_API_KEY) {
|
|
79
|
+
promises.push(
|
|
80
|
+
sendgridProvider.addContact({
|
|
81
|
+
email,
|
|
82
|
+
firstName,
|
|
83
|
+
lastName,
|
|
84
|
+
customFields,
|
|
85
|
+
}).then((r) => { results.sendgrid = r; })
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (addProviders.includes('beehiiv') && process.env.BEEHIIV_API_KEY) {
|
|
90
|
+
promises.push(
|
|
91
|
+
beehiivProvider.addContact({
|
|
92
|
+
email,
|
|
93
|
+
firstName,
|
|
94
|
+
lastName,
|
|
95
|
+
source,
|
|
96
|
+
}).then((r) => { results.beehiiv = r; })
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await Promise.all(promises);
|
|
101
|
+
|
|
102
|
+
assistant.log('Marketing.add() result:', results);
|
|
103
|
+
|
|
104
|
+
return results;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Sync a user's data to SendGrid and Beehiiv.
|
|
109
|
+
* Upserts the contact with all custom fields derived from the user doc.
|
|
110
|
+
*
|
|
111
|
+
* @param {string|object} userDocOrUid - UID string (fetches from Firestore) or full user document object
|
|
112
|
+
* @param {object} [options]
|
|
113
|
+
* @param {Array<string>} [options.providers] - Which providers to sync to (default: all available)
|
|
114
|
+
* @returns {{ sendgrid?: object, beehiiv?: object }}
|
|
115
|
+
*/
|
|
116
|
+
Marketing.prototype.sync = async function (userDocOrUid, options) {
|
|
117
|
+
const self = this;
|
|
118
|
+
const assistant = self.assistant;
|
|
119
|
+
const { providers } = options || {};
|
|
120
|
+
|
|
121
|
+
// Resolve UID to user doc if string
|
|
122
|
+
let userDoc;
|
|
123
|
+
|
|
124
|
+
if (typeof userDocOrUid === 'string') {
|
|
125
|
+
const snap = await self.admin.firestore().doc(`users/${userDocOrUid}`).get()
|
|
126
|
+
.catch((e) => {
|
|
127
|
+
assistant.error('Marketing.sync(): Failed to fetch user doc:', e);
|
|
128
|
+
return null;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (!snap || !snap.exists) {
|
|
132
|
+
assistant.warn(`Marketing.sync(): User ${userDocOrUid} not found, skipping`);
|
|
133
|
+
return {};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
userDoc = snap.data();
|
|
137
|
+
} else {
|
|
138
|
+
userDoc = userDocOrUid;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const email = _.get(userDoc, 'auth.email');
|
|
142
|
+
|
|
143
|
+
if (!email) {
|
|
144
|
+
assistant.warn('Marketing.sync(): No email found in user doc, skipping');
|
|
145
|
+
return {};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const shouldSync = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
149
|
+
const syncProviders = providers || DEFAULT_PROVIDERS;
|
|
150
|
+
const results = {};
|
|
151
|
+
|
|
152
|
+
if (!shouldSync) {
|
|
153
|
+
assistant.log('Marketing.sync(): Skipping providers (testing mode)');
|
|
154
|
+
return results;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
assistant.log('Marketing.sync():', { email });
|
|
158
|
+
|
|
159
|
+
const firstName = _.get(userDoc, 'personal.name.first');
|
|
160
|
+
const lastName = _.get(userDoc, 'personal.name.last');
|
|
161
|
+
const source = _.get(userDoc, 'attribution.utm.tags.utm_source');
|
|
162
|
+
const promises = [];
|
|
163
|
+
|
|
164
|
+
if (syncProviders.includes('sendgrid') && process.env.SENDGRID_API_KEY) {
|
|
165
|
+
promises.push(
|
|
166
|
+
sendgridProvider.buildFields(userDoc).then((customFields) =>
|
|
167
|
+
sendgridProvider.addContact({
|
|
168
|
+
email,
|
|
169
|
+
firstName,
|
|
170
|
+
lastName,
|
|
171
|
+
customFields,
|
|
172
|
+
})
|
|
173
|
+
).then((r) => { results.sendgrid = r; })
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (syncProviders.includes('beehiiv') && process.env.BEEHIIV_API_KEY) {
|
|
178
|
+
promises.push(
|
|
179
|
+
beehiivProvider.addContact({
|
|
180
|
+
email,
|
|
181
|
+
firstName,
|
|
182
|
+
lastName,
|
|
183
|
+
source,
|
|
184
|
+
customFields: beehiivProvider.buildFields(userDoc),
|
|
185
|
+
}).then((r) => { results.beehiiv = r; })
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await Promise.all(promises);
|
|
190
|
+
|
|
191
|
+
assistant.log('Marketing.sync() result:', results);
|
|
192
|
+
|
|
193
|
+
return results;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Remove a contact from all providers.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} email - Email address to remove
|
|
200
|
+
* @param {object} [options]
|
|
201
|
+
* @param {Array<string>} [options.providers] - Which providers to remove from (default: all available)
|
|
202
|
+
* @returns {{ sendgrid?: object, beehiiv?: object }}
|
|
203
|
+
*/
|
|
204
|
+
Marketing.prototype.remove = async function (email, options) {
|
|
205
|
+
const self = this;
|
|
206
|
+
const assistant = self.assistant;
|
|
207
|
+
const { providers } = options || {};
|
|
208
|
+
|
|
209
|
+
if (!email) {
|
|
210
|
+
assistant.warn('Marketing.remove(): No email provided, skipping');
|
|
211
|
+
return {};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const removeProviders = providers || DEFAULT_PROVIDERS;
|
|
215
|
+
const results = {};
|
|
216
|
+
|
|
217
|
+
assistant.log('Marketing.remove():', { email });
|
|
218
|
+
|
|
219
|
+
const promises = [];
|
|
220
|
+
|
|
221
|
+
if (removeProviders.includes('sendgrid') && process.env.SENDGRID_API_KEY) {
|
|
222
|
+
promises.push(
|
|
223
|
+
sendgridProvider.removeContact(email)
|
|
224
|
+
.then((r) => { results.sendgrid = r; })
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (removeProviders.includes('beehiiv') && process.env.BEEHIIV_API_KEY) {
|
|
229
|
+
promises.push(
|
|
230
|
+
beehiivProvider.removeContact(email)
|
|
231
|
+
.then((r) => { results.beehiiv = r; })
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
await Promise.all(promises);
|
|
236
|
+
|
|
237
|
+
assistant.log('Marketing.remove() result:', results);
|
|
238
|
+
|
|
239
|
+
return results;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Create and optionally schedule a marketing campaign (SendGrid Single Send).
|
|
244
|
+
*
|
|
245
|
+
* @param {object} settings
|
|
246
|
+
* @param {string} settings.name - Campaign name
|
|
247
|
+
* @param {string} settings.subject - Email subject
|
|
248
|
+
* @param {string} [settings.template] - Template shortcut or SendGrid template ID
|
|
249
|
+
* @param {string} [settings.sender] - Sender category ('marketing', 'newsletter', etc.)
|
|
250
|
+
* @param {Array<string>} [settings.segments] - Segment IDs to target
|
|
251
|
+
* @param {Array<string>} [settings.lists] - List IDs to target
|
|
252
|
+
* @param {boolean} [settings.all] - Target all contacts
|
|
253
|
+
* @param {string|number} [settings.sendAt] - ISO datetime or 'now' to schedule immediately
|
|
254
|
+
* @param {Array<string>} [settings.categories] - Email categories
|
|
255
|
+
* @returns {{ success: boolean, id?: string, scheduled?: boolean, error?: string }}
|
|
256
|
+
*/
|
|
257
|
+
Marketing.prototype.sendCampaign = async function (settings) {
|
|
258
|
+
const self = this;
|
|
259
|
+
const Manager = self.Manager;
|
|
260
|
+
const assistant = self.assistant;
|
|
261
|
+
|
|
262
|
+
if (!process.env.SENDGRID_API_KEY) {
|
|
263
|
+
return { success: false, error: 'SENDGRID_API_KEY not set' };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const templateId = TEMPLATES[settings.template] || settings.template || TEMPLATES['default'];
|
|
267
|
+
|
|
268
|
+
// Resolve sender
|
|
269
|
+
const sender = SENDERS[settings.sender] || SENDERS['marketing'];
|
|
270
|
+
const brand = Manager.config?.brand;
|
|
271
|
+
const brandDomain = brand?.contact?.email?.split('@')[1];
|
|
272
|
+
|
|
273
|
+
const from = settings.from || {
|
|
274
|
+
email: `${sender.localPart}@${brandDomain}`,
|
|
275
|
+
name: sender.displayName.replace('{brand}', brand?.name || ''),
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Build send_to targeting
|
|
279
|
+
const sendTo = {};
|
|
280
|
+
|
|
281
|
+
if (settings.all) {
|
|
282
|
+
sendTo.all = true;
|
|
283
|
+
}
|
|
284
|
+
if (settings.lists && settings.lists.length) {
|
|
285
|
+
sendTo.list_ids = settings.lists;
|
|
286
|
+
}
|
|
287
|
+
if (settings.segments && settings.segments.length) {
|
|
288
|
+
sendTo.segment_ids = settings.segments;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ASM group
|
|
292
|
+
const asmGroupId = settings.group != null
|
|
293
|
+
? (GROUPS[settings.group] || settings.group)
|
|
294
|
+
: sender.group;
|
|
295
|
+
|
|
296
|
+
// Categories
|
|
297
|
+
const categories = _.uniq([
|
|
298
|
+
'marketing',
|
|
299
|
+
brand?.id,
|
|
300
|
+
...require('node-powertools').arrayify(settings.categories),
|
|
301
|
+
].filter(Boolean));
|
|
302
|
+
|
|
303
|
+
assistant.log('Marketing.sendCampaign():', { name: settings.name, sendTo, templateId });
|
|
304
|
+
|
|
305
|
+
// Create the Single Send
|
|
306
|
+
const createResult = await sendgridProvider.createSingleSend({
|
|
307
|
+
name: settings.name,
|
|
308
|
+
subject: settings.subject,
|
|
309
|
+
templateId,
|
|
310
|
+
from,
|
|
311
|
+
sendTo,
|
|
312
|
+
asmGroupId,
|
|
313
|
+
categories,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
if (!createResult.success) {
|
|
317
|
+
assistant.error('Marketing.sendCampaign() create failed:', createResult.error);
|
|
318
|
+
return createResult;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Schedule if sendAt is provided
|
|
322
|
+
if (settings.sendAt) {
|
|
323
|
+
const sendAt = settings.sendAt === 'now' ? 'now' : new Date(settings.sendAt).toISOString();
|
|
324
|
+
|
|
325
|
+
const scheduleResult = await sendgridProvider.scheduleSingleSend(createResult.id, sendAt);
|
|
326
|
+
|
|
327
|
+
if (!scheduleResult.success) {
|
|
328
|
+
assistant.error('Marketing.sendCampaign() schedule failed:', scheduleResult.error);
|
|
329
|
+
return { success: false, id: createResult.id, error: scheduleResult.error };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
assistant.log('Marketing.sendCampaign() scheduled:', createResult.id);
|
|
333
|
+
|
|
334
|
+
return { success: true, id: createResult.id, scheduled: true };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Created but not scheduled (draft)
|
|
338
|
+
return { success: true, id: createResult.id, scheduled: false };
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Cancel a scheduled campaign.
|
|
343
|
+
*
|
|
344
|
+
* @param {string} campaignId - Single Send ID
|
|
345
|
+
* @returns {{ success: boolean, error?: string }}
|
|
346
|
+
*/
|
|
347
|
+
Marketing.prototype.cancelCampaign = async function (campaignId) {
|
|
348
|
+
const self = this;
|
|
349
|
+
const assistant = self.assistant;
|
|
350
|
+
|
|
351
|
+
assistant.log('Marketing.cancelCampaign():', campaignId);
|
|
352
|
+
|
|
353
|
+
return sendgridProvider.cancelSingleSend(campaignId);
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Get a campaign by ID.
|
|
358
|
+
*
|
|
359
|
+
* @param {string} campaignId - Single Send ID
|
|
360
|
+
* @returns {object|null}
|
|
361
|
+
*/
|
|
362
|
+
Marketing.prototype.getCampaign = async function (campaignId) {
|
|
363
|
+
return sendgridProvider.getSingleSend(campaignId);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* List campaigns with optional status filter.
|
|
368
|
+
*
|
|
369
|
+
* @param {object} [options]
|
|
370
|
+
* @param {string} [options.status] - Filter: draft, scheduled, triggered
|
|
371
|
+
* @returns {Array<object>}
|
|
372
|
+
*/
|
|
373
|
+
Marketing.prototype.listCampaigns = async function (options) {
|
|
374
|
+
return sendgridProvider.listSingleSends(options);
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
module.exports = Marketing;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Beehiiv provider — shared API helpers for subscriber management
|
|
3
|
+
*
|
|
4
|
+
* Used by: marketing/index.js (sync, remove)
|
|
5
|
+
*/
|
|
6
|
+
const fetch = require('wonderful-fetch');
|
|
7
|
+
const Manager = require('../../../index.js');
|
|
8
|
+
const { resolveFieldValues } = require('../constants.js');
|
|
9
|
+
|
|
10
|
+
const BASE_URL = 'https://api.beehiiv.com/v2';
|
|
11
|
+
|
|
12
|
+
// --- Internal helpers ---
|
|
13
|
+
|
|
14
|
+
function headers() {
|
|
15
|
+
return {
|
|
16
|
+
'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// --- Subscriber Management ---
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Add or reactivate a subscriber to a Beehiiv publication.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} options
|
|
26
|
+
* @param {string} options.email
|
|
27
|
+
* @param {string} [options.firstName]
|
|
28
|
+
* @param {string} [options.lastName]
|
|
29
|
+
* @param {string} [options.source] - UTM source
|
|
30
|
+
* @param {string} options.publicationId
|
|
31
|
+
* @param {Array<{name: string, value: string}>} [options.customFields] - Additional custom fields
|
|
32
|
+
* @returns {{ success: boolean, id?: string, error?: string }}
|
|
33
|
+
*/
|
|
34
|
+
async function addSubscriber({ email, firstName, lastName, source, publicationId, customFields }) {
|
|
35
|
+
try {
|
|
36
|
+
const body = {
|
|
37
|
+
email,
|
|
38
|
+
reactivate_existing: true,
|
|
39
|
+
send_welcome_email: true,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (source) {
|
|
43
|
+
body.utm_source = source;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Build custom fields array
|
|
47
|
+
const fields = [
|
|
48
|
+
...(customFields || []),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
if (firstName) {
|
|
52
|
+
fields.push({ name: 'first_name', value: firstName });
|
|
53
|
+
}
|
|
54
|
+
if (lastName) {
|
|
55
|
+
fields.push({ name: 'last_name', value: lastName });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (fields.length) {
|
|
59
|
+
body.custom_fields = fields;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const data = await fetch(`${BASE_URL}/publications/${publicationId}/subscriptions`, {
|
|
63
|
+
method: 'post',
|
|
64
|
+
response: 'json',
|
|
65
|
+
headers: headers(),
|
|
66
|
+
timeout: 15000,
|
|
67
|
+
body,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (data.data?.id) {
|
|
71
|
+
return { success: true, id: data.data.id };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { success: false, error: data.message || 'Unknown error' };
|
|
75
|
+
} catch (e) {
|
|
76
|
+
console.error('Beehiiv addSubscriber error:', e);
|
|
77
|
+
return { success: false, error: e.message };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Remove a subscriber from a Beehiiv publication by email.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} email
|
|
85
|
+
* @param {string} publicationId
|
|
86
|
+
* @returns {{ success: boolean, deleted?: boolean, skipped?: boolean, error?: string }}
|
|
87
|
+
*/
|
|
88
|
+
async function removeSubscriber(email, publicationId) {
|
|
89
|
+
try {
|
|
90
|
+
const encodedEmail = encodeURIComponent(email);
|
|
91
|
+
|
|
92
|
+
// Step 1: Get subscription by email
|
|
93
|
+
let searchData;
|
|
94
|
+
try {
|
|
95
|
+
searchData = await fetch(
|
|
96
|
+
`${BASE_URL}/publications/${publicationId}/subscriptions/by_email/${encodedEmail}`,
|
|
97
|
+
{
|
|
98
|
+
response: 'json',
|
|
99
|
+
headers: headers(),
|
|
100
|
+
timeout: 10000,
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (e.status === 404) {
|
|
105
|
+
return { success: true, skipped: true, reason: 'Subscriber not found' };
|
|
106
|
+
}
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!searchData.data?.id) {
|
|
111
|
+
return { success: true, skipped: true, reason: 'Subscription not found' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const subscriptionId = searchData.data.id;
|
|
115
|
+
|
|
116
|
+
// Step 2: Permanently delete the subscription
|
|
117
|
+
await fetch(
|
|
118
|
+
`${BASE_URL}/publications/${publicationId}/subscriptions/${subscriptionId}`,
|
|
119
|
+
{
|
|
120
|
+
method: 'delete',
|
|
121
|
+
headers: headers(),
|
|
122
|
+
timeout: 10000,
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return { success: true, deleted: true, subscriptionId };
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error('Beehiiv removeSubscriber error:', e);
|
|
129
|
+
return { success: false, error: e.message };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get a Beehiiv publication ID by brand name (fuzzy match).
|
|
135
|
+
*
|
|
136
|
+
* @param {string} brandName
|
|
137
|
+
* @returns {string|null} Publication ID or null
|
|
138
|
+
*/
|
|
139
|
+
async function getPublicationId() {
|
|
140
|
+
const brandName = Manager.config.brand?.name;
|
|
141
|
+
|
|
142
|
+
if (!brandName) {
|
|
143
|
+
console.error('Beehiiv: Brand name is required to find publication');
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const brandNameLower = brandName.toLowerCase();
|
|
148
|
+
const allPublications = [];
|
|
149
|
+
let page = 1;
|
|
150
|
+
const limit = 100;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
while (true) {
|
|
154
|
+
const data = await fetch(`${BASE_URL}/publications?limit=${limit}&page=${page}`, {
|
|
155
|
+
response: 'json',
|
|
156
|
+
headers: headers(),
|
|
157
|
+
timeout: 10000,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (!data.data || data.data.length === 0) {
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const matchedPub = data.data.find(pub =>
|
|
165
|
+
pub.name.toLowerCase() === brandNameLower
|
|
166
|
+
|| pub.name.toLowerCase().includes(brandNameLower)
|
|
167
|
+
|| brandNameLower.includes(pub.name.toLowerCase())
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (matchedPub) {
|
|
171
|
+
return matchedPub.id;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
allPublications.push(...data.data);
|
|
175
|
+
|
|
176
|
+
if (data.data.length < limit) {
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
page++;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
console.error('Beehiiv publication lookup error:', e);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Add a contact to Beehiiv — resolves publication, adds subscriber with optional custom fields.
|
|
193
|
+
*
|
|
194
|
+
* @param {object} options
|
|
195
|
+
* @param {string} options.email
|
|
196
|
+
* @param {string} [options.firstName]
|
|
197
|
+
* @param {string} [options.lastName]
|
|
198
|
+
* @param {string} [options.source] - UTM source
|
|
199
|
+
* @param {Array<{name: string, value: string}>} [options.customFields] - Pre-built custom fields
|
|
200
|
+
* @returns {{ success: boolean, id?: string, error?: string }}
|
|
201
|
+
*/
|
|
202
|
+
async function addContact({ email, firstName, lastName, source, customFields }) {
|
|
203
|
+
const publicationId = await getPublicationId();
|
|
204
|
+
|
|
205
|
+
if (!publicationId) {
|
|
206
|
+
return { success: false, error: 'Publication not found' };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return addSubscriber({
|
|
210
|
+
email,
|
|
211
|
+
firstName,
|
|
212
|
+
lastName,
|
|
213
|
+
source,
|
|
214
|
+
publicationId,
|
|
215
|
+
customFields: customFields || [],
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Remove a contact from Beehiiv — resolves publication from config.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} email
|
|
223
|
+
* @returns {{ success: boolean, deleted?: boolean, skipped?: boolean, error?: string }}
|
|
224
|
+
*/
|
|
225
|
+
async function removeContact(email) {
|
|
226
|
+
const publicationId = await getPublicationId();
|
|
227
|
+
|
|
228
|
+
if (!publicationId) {
|
|
229
|
+
return { success: false, error: 'Publication not found' };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return removeSubscriber(email, publicationId);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Build Beehiiv custom_fields array from a user doc.
|
|
237
|
+
* Resolves all field values — the key IS the field name in Beehiiv.
|
|
238
|
+
*
|
|
239
|
+
* @param {object} userDoc - User document from Firestore
|
|
240
|
+
* @returns {Array<{name: string, value: string}>} Custom fields in Beehiiv format
|
|
241
|
+
*/
|
|
242
|
+
function buildFields(userDoc) {
|
|
243
|
+
const values = resolveFieldValues(userDoc, Manager.config);
|
|
244
|
+
const fields = [];
|
|
245
|
+
|
|
246
|
+
for (const [name, value] of Object.entries(values)) {
|
|
247
|
+
fields.push({ name, value: String(value) });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return fields;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = {
|
|
254
|
+
// Contacts
|
|
255
|
+
addContact,
|
|
256
|
+
removeContact,
|
|
257
|
+
buildFields,
|
|
258
|
+
};
|