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
@@ -1,11 +1,6 @@
1
- const fetch = require('wonderful-fetch');
2
1
  const path = require('path');
3
- const dns = require('dns').promises;
4
2
  const recaptcha = require(path.join(__dirname, '..', '..', '..', '..', '..', 'libraries', 'recaptcha.js'));
5
-
6
- // Load disposable domains list
7
- const DISPOSABLE_DOMAINS = require(path.join(__dirname, '..', '..', '..', '..', '..', 'libraries', 'disposable-domains.json'));
8
- const DISPOSABLE_SET = new Set(DISPOSABLE_DOMAINS.map(d => d.toLowerCase()));
3
+ const { validate: validateEmail } = require(path.join(__dirname, '..', '..', '..', '..', '..', 'libraries', 'email', 'validation.js'));
9
4
  const { inferContact } = require(path.join(__dirname, '..', '..', '..', '..', '..', 'libraries', 'infer-contact.js'));
10
5
 
11
6
  function Module() {}
@@ -73,38 +68,29 @@ Module.prototype.main = function () {
73
68
  }
74
69
  }
75
70
 
76
- // Email validation
77
- const validation = { valid: true, checks: {} };
78
-
79
71
  // Skip external API calls in test mode unless TEST_EXTENDED_MODE is set
80
72
  const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
81
73
 
74
+ // Email validation
75
+ let validation = { valid: true, checks: {} };
76
+
82
77
  if (!skipValidation) {
83
- // Check disposable domain
84
- const domain = email.split('@')[1];
85
- if (DISPOSABLE_SET.has(domain.toLowerCase())) {
86
- validation.valid = false;
87
- validation.checks.disposable = { blocked: true, domain };
78
+ validation = await validateEmail(email, {
79
+ zerobounce: isAdmin && shouldCallExternalAPIs,
80
+ });
88
81
 
82
+ if (!validation.valid) {
89
83
  // For public requests, return generic success to prevent enumeration
90
84
  if (!isAdmin) {
91
85
  return resolve({ data: { success: true } });
92
86
  }
93
- return reject(assistant.errorify(`Disposable email domain not allowed: ${domain}`, { code: 400 }));
94
- }
95
- validation.checks.disposable = { blocked: false };
96
-
97
- // MX record check (optional, skip for speed in most cases)
98
- // const hasMx = await checkMxRecord(domain);
99
- // validation.checks.mx = { valid: hasMx };
100
87
 
101
- // ZeroBounce validation (admin only, if key exists, and not in test mode unless TEST_EXTENDED_MODE)
102
- if (isAdmin && process.env.ZEROBOUNCE_API_KEY && shouldCallExternalAPIs) {
103
- const zbResult = await validateWithZeroBounce(email);
104
- validation.checks.zerobounce = zbResult;
105
- if (!zbResult.valid) {
106
- validation.valid = false;
88
+ const disposable = validation.checks.disposable;
89
+ if (disposable && !disposable.valid) {
90
+ return reject(assistant.errorify(`Disposable email domain not allowed: ${disposable.domain}`, { code: 400 }));
107
91
  }
92
+
93
+ return reject(assistant.errorify('Email validation failed', { code: 400 }));
108
94
  }
109
95
  }
110
96
 
@@ -117,35 +103,19 @@ Module.prototype.main = function () {
117
103
  }
118
104
 
119
105
  // Add to providers
120
- const providerResults = {};
106
+ let providerResults = {};
121
107
 
122
108
  if (!shouldCallExternalAPIs) {
123
109
  assistant.log('add-marketing-contact: Skipping providers (BEM_TESTING=true, TEST_EXTENDED_MODE not set)');
124
110
  } else {
125
- assistant.log('add-marketing-contact: Adding contact to providers:', { providers });
126
-
127
- // SendGrid Marketing Contacts
128
- if (providers.includes('sendgrid') && process.env.SENDGRID_API_KEY) {
129
- providerResults.sendgrid = await addToSendGrid({
130
- email,
131
- firstName,
132
- lastName,
133
- source,
134
- appId: Manager.config.app.id,
135
- brandName: Manager.config.brand?.name,
136
- });
137
- }
138
-
139
- // Beehiiv
140
- if (providers.includes('beehiiv') && process.env.BEEHIIV_API_KEY) {
141
- providerResults.beehiiv = await addToBeehiiv({
142
- email,
143
- firstName,
144
- lastName,
145
- source,
146
- brandName: Manager.config.brand?.name,
147
- });
148
- }
111
+ const mailer = Manager.Email(assistant);
112
+ providerResults = await mailer.add({
113
+ email,
114
+ firstName,
115
+ lastName,
116
+ source,
117
+ providers,
118
+ });
149
119
  }
150
120
 
151
121
  // Log result
@@ -177,270 +147,4 @@ Module.prototype.main = function () {
177
147
  });
178
148
  };
179
149
 
180
- /**
181
- * Validate email with ZeroBounce API
182
- */
183
- async function validateWithZeroBounce(email) {
184
- try {
185
- const data = await fetch(
186
- `https://api.zerobounce.net/v2/validate?api_key=${process.env.ZEROBOUNCE_API_KEY}&email=${encodeURIComponent(email)}`,
187
- {
188
- response: 'json',
189
- timeout: 10000,
190
- }
191
- );
192
-
193
- // ZeroBounce returns error in response body (e.g., invalid API key, out of credits)
194
- if (data.error) {
195
- console.error('ZeroBounce API error:', data.error);
196
- return { valid: true, error: data.error }; // Fail open
197
- }
198
-
199
- // Ensure status exists (defensive check)
200
- if (!data.status) {
201
- console.error('ZeroBounce unexpected response:', data);
202
- return { valid: true, error: 'Unexpected response format' };
203
- }
204
-
205
- return {
206
- valid: data.status === 'valid',
207
- status: data.status,
208
- subStatus: data.sub_status || null,
209
- };
210
- } catch (e) {
211
- console.error('ZeroBounce validation error:', e);
212
- return { valid: true, error: e.message }; // Fail open
213
- }
214
- }
215
-
216
- /**
217
- * Check if domain has valid MX records
218
- */
219
- async function checkMxRecord(domain) {
220
- try {
221
- const records = await dns.resolveMx(domain);
222
- return records && records.length > 0;
223
- } catch (e) {
224
- return false;
225
- }
226
- }
227
-
228
- /**
229
- * Add contact to SendGrid Marketing Contacts
230
- */
231
- async function addToSendGrid({ email, firstName, lastName, source, appId, brandName }) {
232
- try {
233
- // Get list ID matched by brand name
234
- const listId = await getSendGridListId(brandName);
235
-
236
- const requestBody = {
237
- contacts: [
238
- {
239
- email,
240
- first_name: firstName || undefined,
241
- last_name: lastName || undefined,
242
- custom_fields: {
243
- e1_T: source, // Assumes custom field exists for source
244
- e2_T: appId, // Assumes custom field exists for app_id
245
- },
246
- },
247
- ],
248
- };
249
-
250
- // Add to specific list if matched
251
- if (listId) {
252
- requestBody.list_ids = [listId];
253
- }
254
-
255
- const data = await fetch('https://api.sendgrid.com/v3/marketing/contacts', {
256
- method: 'put',
257
- response: 'json',
258
- headers: {
259
- 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
260
- },
261
- timeout: 15000,
262
- body: requestBody,
263
- });
264
-
265
- if (data.job_id) {
266
- return { success: true, jobId: data.job_id, listId };
267
- }
268
-
269
- return { success: false, error: data.errors?.[0]?.message || 'Unknown error' };
270
- } catch (e) {
271
- console.error('SendGrid error:', e);
272
- return { success: false, error: e.message };
273
- }
274
- }
275
-
276
- /**
277
- * Get SendGrid list ID by matching brand name (with pagination)
278
- */
279
- async function getSendGridListId(brandName) {
280
- const brandNameLower = (brandName || '').toLowerCase();
281
- const allLists = [];
282
- let pageToken = '';
283
- const pageSize = 1000;
284
-
285
- try {
286
- // Paginate through all lists
287
- while (true) {
288
- const url = `https://api.sendgrid.com/v3/marketing/lists?page_size=${pageSize}${pageToken ? `&page_token=${pageToken}` : ''}`;
289
- const data = await fetch(url, {
290
- response: 'json',
291
- headers: {
292
- 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
293
- },
294
- timeout: 10000,
295
- });
296
-
297
- if (!data.result || data.result.length === 0) {
298
- break;
299
- }
300
-
301
- // Check for match in this page
302
- const matchedList = data.result.find(list =>
303
- list.name.toLowerCase() === brandNameLower
304
- || list.name.toLowerCase().includes(brandNameLower)
305
- || brandNameLower.includes(list.name.toLowerCase())
306
- );
307
-
308
- if (matchedList) {
309
- return matchedList.id;
310
- }
311
-
312
- allLists.push(...data.result);
313
-
314
- // Check for next page token in metadata
315
- if (!data._metadata?.next) {
316
- break;
317
- }
318
-
319
- // Extract page_token from next URL
320
- const nextUrl = new URL(data._metadata.next);
321
- pageToken = nextUrl.searchParams.get('page_token');
322
-
323
- if (!pageToken) {
324
- break;
325
- }
326
- }
327
-
328
- // Fallback to first list if only one exists total
329
- if (allLists.length === 1) {
330
- return allLists[0].id;
331
- }
332
-
333
- if (allLists.length > 0) {
334
- console.error(`SendGrid: No list matched brand "${brandName}". Available: ${allLists.map(l => l.name).join(', ')}`);
335
- }
336
- } catch (e) {
337
- console.error('SendGrid list lookup error:', e);
338
- }
339
-
340
- return null;
341
- }
342
-
343
- /**
344
- * Add contact to Beehiiv newsletter
345
- */
346
- async function addToBeehiiv({ email, firstName, lastName, source, brandName }) {
347
- try {
348
- // Get publication ID (cached, matched by brand name)
349
- const pubId = await getBeehiivPublicationId(brandName);
350
- if (!pubId) {
351
- return { success: false, error: 'Could not find matching publication' };
352
- }
353
-
354
- const data = await fetch(`https://api.beehiiv.com/v2/publications/${pubId}/subscriptions`, {
355
- method: 'post',
356
- response: 'json',
357
- headers: {
358
- 'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
359
- },
360
- timeout: 15000,
361
- body: {
362
- email,
363
- reactivate_existing: true,
364
- send_welcome_email: true,
365
- utm_source: source,
366
- custom_fields: [
367
- firstName ? { name: 'first_name', value: firstName } : null,
368
- lastName ? { name: 'last_name', value: lastName } : null,
369
- ].filter(Boolean),
370
- },
371
- });
372
-
373
- if (data.data?.id) {
374
- return { success: true, id: data.data.id };
375
- }
376
-
377
- return { success: false, error: data.message || 'Unknown error' };
378
- } catch (e) {
379
- console.error('Beehiiv error:', e);
380
- return { success: false, error: e.message };
381
- }
382
- }
383
-
384
- /**
385
- * Get Beehiiv publication ID by matching brand name (with pagination)
386
- * Brand name is REQUIRED - no fallback to random publications
387
- * @param {string} brandName - Brand name to match (required)
388
- */
389
- async function getBeehiivPublicationId(brandName) {
390
- // Brand name is required
391
- if (!brandName) {
392
- console.error('Beehiiv: Brand name is required to find publication');
393
- return null;
394
- }
395
-
396
- const brandNameLower = brandName.toLowerCase();
397
- const allPublications = [];
398
- let page = 1;
399
- const limit = 100;
400
-
401
- try {
402
- // Paginate through all publications
403
- while (true) {
404
- const data = await fetch(`https://api.beehiiv.com/v2/publications?limit=${limit}&page=${page}`, {
405
- response: 'json',
406
- headers: {
407
- 'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
408
- },
409
- timeout: 10000,
410
- });
411
-
412
- if (!data.data || data.data.length === 0) {
413
- break;
414
- }
415
-
416
- // Check for match in this page
417
- const matchedPub = data.data.find(pub =>
418
- pub.name.toLowerCase() === brandNameLower
419
- || pub.name.toLowerCase().includes(brandNameLower)
420
- || brandNameLower.includes(pub.name.toLowerCase())
421
- );
422
-
423
- if (matchedPub) {
424
- return matchedPub.id;
425
- }
426
-
427
- allPublications.push(...data.data);
428
-
429
- // If we got fewer than limit, we've reached the end
430
- if (data.data.length < limit) {
431
- break;
432
- }
433
-
434
- page++;
435
- }
436
-
437
- // No fallback - brand must match
438
- console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
439
- } catch (e) {
440
- console.error('Beehiiv publication lookup error:', e);
441
- }
442
-
443
- return null;
444
- }
445
-
446
150
  module.exports = Module;
@@ -10,10 +10,10 @@ module.exports = function (payload, config) {
10
10
  email: payload.email,
11
11
  name: payload.name,
12
12
  },
13
+ sender: 'marketing',
13
14
  categories: ['download'],
14
15
  subject: `Free ${config.brand.name} download link for ${payload.name || 'you'}!`,
15
16
  template: 'main/misc/app-download-link',
16
- group: 'marketing',
17
17
  copy: false,
18
18
  data: {},
19
19
  }
@@ -1,5 +1,3 @@
1
- const fetch = require('wonderful-fetch');
2
-
3
1
  function Module() {}
4
2
 
5
3
  Module.prototype.main = function () {
@@ -31,21 +29,9 @@ Module.prototype.main = function () {
31
29
  return reject(assistant.errorify('Email is required', { code: 400 }));
32
30
  }
33
31
 
34
- // Get brand name from Manager config
35
- const brandName = Manager.config.brand?.name;
36
-
37
32
  // Remove from providers
38
- const providerResults = {};
39
-
40
- // SendGrid
41
- if (providers.includes('sendgrid') && process.env.SENDGRID_API_KEY) {
42
- providerResults.sendgrid = await removeFromSendGrid(email);
43
- }
44
-
45
- // Beehiiv
46
- if (providers.includes('beehiiv') && process.env.BEEHIIV_API_KEY) {
47
- providerResults.beehiiv = await removeFromBeehiiv(email, brandName);
48
- }
33
+ const mailer = Manager.Email(assistant);
34
+ const providerResults = await mailer.remove(email, { providers });
49
35
 
50
36
  // Log result
51
37
  assistant.log('remove-marketing-contact result:', {
@@ -62,173 +48,4 @@ Module.prototype.main = function () {
62
48
  });
63
49
  };
64
50
 
65
- /**
66
- * Remove contact from SendGrid by email
67
- * Requires looking up contact ID first, then deleting
68
- */
69
- async function removeFromSendGrid(email) {
70
- try {
71
- // Step 1: Get contact ID by email
72
- const searchData = await fetch('https://api.sendgrid.com/v3/marketing/contacts/search/emails', {
73
- method: 'post',
74
- response: 'json',
75
- headers: {
76
- 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
77
- },
78
- timeout: 10000,
79
- body: { emails: [email] },
80
- });
81
-
82
- if (!searchData.result?.[email]?.contact?.id) {
83
- return { success: true, skipped: true, reason: 'Contact not found' };
84
- }
85
-
86
- const contactId = searchData.result[email].contact.id;
87
-
88
- // Step 2: Delete contact by ID
89
- const deleteData = await fetch(`https://api.sendgrid.com/v3/marketing/contacts?ids=${contactId}`, {
90
- method: 'delete',
91
- response: 'json',
92
- headers: {
93
- 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
94
- },
95
- timeout: 10000,
96
- });
97
-
98
- if (deleteData.job_id) {
99
- return { success: true, jobId: deleteData.job_id };
100
- }
101
-
102
- return { success: false, error: deleteData.errors?.[0]?.message || 'Delete failed' };
103
- } catch (e) {
104
- console.error('SendGrid remove error:', e);
105
- return { success: false, error: e.message };
106
- }
107
- }
108
-
109
- /**
110
- * Get Beehiiv publication ID by matching brand name (with pagination)
111
- * Brand name is REQUIRED - no fallback to random publications
112
- * @param {string} brandName - Brand name to match (required)
113
- */
114
- async function getBeehiivPublicationId(brandName) {
115
- // Brand name is required
116
- if (!brandName) {
117
- console.error('Beehiiv: Brand name is required to find publication');
118
- return null;
119
- }
120
-
121
- const brandNameLower = brandName.toLowerCase();
122
- const allPublications = [];
123
- let page = 1;
124
- const limit = 100;
125
-
126
- try {
127
- // Paginate through all publications
128
- while (true) {
129
- const data = await fetch(`https://api.beehiiv.com/v2/publications?limit=${limit}&page=${page}`, {
130
- response: 'json',
131
- headers: {
132
- 'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
133
- },
134
- timeout: 10000,
135
- });
136
-
137
- if (!data.data || data.data.length === 0) {
138
- break;
139
- }
140
-
141
- // Check for match in this page
142
- const matchedPub = data.data.find(pub =>
143
- pub.name.toLowerCase() === brandNameLower
144
- || pub.name.toLowerCase().includes(brandNameLower)
145
- || brandNameLower.includes(pub.name.toLowerCase())
146
- );
147
-
148
- if (matchedPub) {
149
- return matchedPub.id;
150
- }
151
-
152
- allPublications.push(...data.data);
153
-
154
- // If we got fewer than limit, we've reached the end
155
- if (data.data.length < limit) {
156
- break;
157
- }
158
-
159
- page++;
160
- }
161
-
162
- // No fallback - brand must match
163
- console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
164
- } catch (e) {
165
- console.error('Beehiiv publication lookup error:', e);
166
- }
167
-
168
- return null;
169
- }
170
-
171
- /**
172
- * Permanently delete subscriber from Beehiiv by email
173
- * Requires looking up subscription ID first, then calling DELETE endpoint
174
- * @param {string} email - Email to delete
175
- * @param {string} brandName - Brand name to match publication (required)
176
- */
177
- async function removeFromBeehiiv(email, brandName) {
178
- try {
179
- // Get publication ID by brand name (required)
180
- const pubId = await getBeehiivPublicationId(brandName);
181
- if (!pubId) {
182
- return { success: false, error: `Publication not found for brand "${brandName}"` };
183
- }
184
-
185
- // Step 1: Get subscription by email
186
- const encodedEmail = encodeURIComponent(email);
187
-
188
- let searchData;
189
- try {
190
- searchData = await fetch(
191
- `https://api.beehiiv.com/v2/publications/${pubId}/subscriptions/by_email/${encodedEmail}`,
192
- {
193
- response: 'json',
194
- headers: {
195
- 'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
196
- },
197
- timeout: 10000,
198
- }
199
- );
200
- } catch (e) {
201
- // 404 means subscriber doesn't exist - that's fine for cleanup
202
- if (e.status === 404) {
203
- return { success: true, skipped: true, reason: 'Subscriber not found' };
204
- }
205
- throw e;
206
- }
207
-
208
- if (!searchData.data?.id) {
209
- return { success: true, skipped: true, reason: 'Subscription not found' };
210
- }
211
-
212
- const subscriptionId = searchData.data.id;
213
-
214
- // Step 2: Permanently DELETE the subscription
215
- await fetch(
216
- `https://api.beehiiv.com/v2/publications/${pubId}/subscriptions/${subscriptionId}`,
217
- {
218
- method: 'delete',
219
- headers: {
220
- 'Authorization': `Bearer ${process.env.BEEHIIV_API_KEY}`,
221
- },
222
- timeout: 10000,
223
- }
224
- );
225
-
226
- // DELETE returns 204 No Content on success
227
- return { success: true, deleted: true, subscriptionId };
228
- } catch (e) {
229
- console.error('Beehiiv remove error:', e);
230
- return { success: false, error: e.message };
231
- }
232
- }
233
-
234
51
  module.exports = Module;
@@ -26,7 +26,7 @@ Module.prototype.main = function () {
26
26
  delay: 1,
27
27
  payload: {
28
28
  backendManagerKey: process.env.BACKEND_MANAGER_KEY,
29
- app: Manager.config.app.id,
29
+ brand: Manager.config.brand.id,
30
30
  },
31
31
  }
32
32
 
@@ -1,5 +1,5 @@
1
1
  const path = require('path');
2
- const { buildPublicConfig } = require(path.join(__dirname, '..', '..', '..', '..', '..', 'routes', 'app', 'get.js'));
2
+ const { buildPublicConfig } = require(path.join(__dirname, '..', '..', '..', '..', '..', 'routes', 'brand', 'get.js'));
3
3
 
4
4
  function Module() {
5
5
 
@@ -75,7 +75,7 @@ Module.prototype.main = function () {
75
75
  timestamp: new Date().toISOString(),
76
76
  ip: assistant.request.geolocation.ip,
77
77
  country: assistant.request.geolocation.country,
78
- app: buildPublicConfig(Manager.config),
78
+ brand: buildPublicConfig(Manager.config),
79
79
  config: config,
80
80
  }
81
81
  });
@@ -14,6 +14,7 @@ Module.prototype.main = function () {
14
14
  environment: assistant.meta?.environment || 'unknown',
15
15
  version: Manager.package?.version || 'unknown',
16
16
  bemVersion: Manager.version || 'unknown',
17
+ testExtendedMode: !!process.env.TEST_EXTENDED_MODE,
17
18
  };
18
19
 
19
20
  assistant.log('Health check', response);
@@ -26,7 +26,7 @@ function ApiManager(m) {
26
26
  const self = this;
27
27
  self.Manager = m;
28
28
  self.options = {
29
- appId: '',
29
+ brandId: '',
30
30
  plans: {},
31
31
  maxUsersStored: 10000,
32
32
  refetchInterval: 60,
@@ -41,7 +41,7 @@ ApiManager.prototype.init = function (options) {
41
41
  const self = this;
42
42
  return new Promise(async function(resolve, reject) {
43
43
  options = options || {};
44
- options.app = options.app || '';
44
+ options.brand = options.brand || '';
45
45
  options.plans = options.plans || {};
46
46
 
47
47
  // await self.Manager.libraries.admin.firestore
@@ -347,11 +347,12 @@ function User(Manager, settings) {
347
347
 
348
348
  // Resolves calculated subscription fields that require derivation logic
349
349
  // Raw data (product.id, status, trial, cancellation) is on the user object directly
350
- // Returns: { plan, active, trialing, cancelling }
350
+ // Returns: { plan, active, trialing, cancelling, everPaid }
351
351
  // - plan: the plan ID the user effectively has access to RIGHT NOW ('basic' if cancelled/suspended)
352
352
  // - active: user has active access (active, trialing, or cancelling)
353
353
  // - trialing: user is in an active trial (status is 'active' but trial hasn't expired)
354
354
  // - cancelling: cancellation is pending (status is 'active' but cancellation.pending is true)
355
+ // - everPaid: user has had a paid subscription at some point (payment.startDate exists)
355
356
  User.resolveSubscription = function resolveSubscription(account) {
356
357
  const subscription = (account?.subscription || account?.properties?.subscription) || {};
357
358
  const productId = subscription.product?.id || 'basic';
@@ -372,6 +373,7 @@ User.resolveSubscription = function resolveSubscription(account) {
372
373
  active,
373
374
  trialing,
374
375
  cancelling,
376
+ everPaid: (subscription.payment?.startDate?.timestampUNIX || 0) > 0,
375
377
  };
376
378
  };
377
379