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.
Files changed (72) hide show
  1. package/CHANGELOG.md +50 -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 +22 -318
  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 -185
  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 +243 -0
  26. package/src/manager/libraries/email/index.js +145 -0
  27. package/src/manager/libraries/email/marketing/index.js +377 -0
  28. package/src/manager/libraries/email/providers/beehiiv.js +258 -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 -164
  38. package/src/manager/routes/marketing/contact/post.js +45 -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 +3 -1
  51. package/src/manager/schemas/marketing/contact/post.js +3 -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/test/events/payments/journey-payments-cancel-endpoint.js +11 -0
  60. package/test/events/payments/journey-payments-trial-cancel.js +11 -0
  61. package/test/functions/admin/edit-post.js +2 -2
  62. package/test/functions/admin/write-repo-content.js +2 -2
  63. package/test/functions/general/add-marketing-contact.js +21 -23
  64. package/test/helpers/email-validation.js +420 -0
  65. package/test/helpers/email.js +119 -6
  66. package/test/helpers/marketing-lifecycle.js +121 -0
  67. package/test/helpers/user.js +2 -2
  68. package/test/routes/admin/create-post.js +2 -2
  69. package/test/routes/admin/post.js +2 -2
  70. package/test/routes/admin/repo-content.js +2 -2
  71. package/test/routes/marketing/contact.js +21 -24
  72. 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
+ };