backend-manager 5.0.148 → 5.0.150

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