backend-manager 5.1.2 → 5.2.0
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/.claude/settings.local.json +12 -0
- package/CHANGELOG.md +52 -0
- package/CLAUDE.md +2 -1
- package/README.md +30 -0
- package/docs/common-mistakes.md +1 -0
- package/docs/consent.md +333 -0
- package/docs/marketing-campaigns.md +41 -4
- package/docs/testing.md +81 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +62 -9
- package/src/cli/commands/serve.js +73 -7
- package/src/cli/commands/test.js +65 -1
- package/src/cli/commands/watch.js +15 -3
- package/src/defaults/CLAUDE.md +7 -5
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
- package/src/manager/helpers/user.js +29 -0
- package/src/manager/index.js +111 -5
- package/src/manager/libraries/ai/index.js +21 -0
- package/src/manager/libraries/ai/providers/openai.js +75 -0
- package/src/manager/libraries/email/data/disposable-domains.json +20 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
- package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
- package/src/manager/libraries/email/generators/lib/structure.js +19 -2
- package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
- package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
- package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
- package/src/manager/libraries/email/generators/newsletter.js +154 -7
- package/src/manager/libraries/email/providers/beehiiv.js +8 -1
- package/src/manager/libraries/payment/processors/stripe.js +12 -0
- package/src/manager/libraries/payment/processors/test.js +8 -1
- package/src/manager/routes/admin/infer-contact/post.js +3 -2
- package/src/manager/routes/admin/post/post.js +3 -3
- package/src/manager/routes/marketing/email-preferences/post.js +165 -37
- package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
- package/src/manager/routes/marketing/webhook/post.js +180 -0
- package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
- package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
- package/src/manager/routes/payments/cancel/post.js +2 -2
- package/src/manager/routes/payments/cancel/processors/test.js +5 -2
- package/src/manager/routes/payments/intent/processors/test.js +7 -3
- package/src/manager/routes/payments/refund/processors/test.js +4 -1
- package/src/manager/routes/test/health/get.js +17 -0
- package/src/manager/routes/user/signup/post.js +65 -1
- package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
- package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
- package/src/manager/schemas/marketing/webhook/post.js +7 -0
- package/src/manager/schemas/payments/cancel/post.js +5 -0
- package/src/manager/schemas/user/signup/post.js +5 -0
- package/src/test/run-tests.js +30 -0
- package/src/test/runner.js +72 -26
- package/src/test/test-accounts.js +94 -12
- package/src/test/utils/http-client.js +4 -3
- package/src/test/utils/test-mode-file.js +192 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
- package/test/events/payments/journey-payments-cancel.js +4 -5
- package/test/events/payments/journey-payments-failure.js +0 -1
- package/test/events/payments/journey-payments-one-time-failure.js +6 -3
- package/test/events/payments/journey-payments-one-time.js +6 -3
- package/test/events/payments/journey-payments-plan-change.js +5 -5
- package/test/events/payments/journey-payments-refund-webhook.js +2 -3
- package/test/events/payments/journey-payments-suspend.js +4 -5
- package/test/events/payments/journey-payments-trial-cancel.js +3 -12
- package/test/events/payments/journey-payments-trial.js +2 -3
- package/test/events/payments/journey-payments-uid-resolution.js +2 -3
- package/test/functions/admin/database-read.js +0 -14
- package/test/functions/admin/database-write.js +0 -14
- package/test/functions/admin/firestore-query.js +0 -14
- package/test/functions/admin/firestore-read.js +0 -15
- package/test/functions/admin/firestore-write.js +0 -11
- package/test/functions/general/add-marketing-contact.js +16 -14
- package/test/helpers/email.js +1 -1
- package/test/helpers/infer-contact.js +3 -3
- package/test/helpers/user.js +241 -2
- package/test/helpers/webhook-forward.js +392 -0
- package/test/marketing/fixtures/clean.json +2 -3
- package/test/marketing/fixtures/editorial.json +2 -3
- package/test/marketing/fixtures/field-report.json +3 -4
- package/test/marketing/newsletter-generate.js +78 -54
- package/test/marketing/newsletter-templates.js +12 -33
- package/test/routes/admin/create-post.js +2 -2
- package/test/routes/admin/database.js +0 -13
- package/test/routes/admin/firestore-query.js +0 -13
- package/test/routes/admin/firestore.js +0 -14
- package/test/routes/admin/infer-contact.js +6 -3
- package/test/routes/admin/post.js +4 -2
- package/test/routes/marketing/contact.js +60 -26
- package/test/routes/marketing/email-preferences.js +145 -69
- package/test/routes/marketing/webhook-forward.js +54 -0
- package/test/routes/marketing/webhook.js +582 -0
- package/test/routes/payments/cancel.js +2 -7
- package/test/routes/payments/dispute-alert.js +0 -39
- package/test/routes/payments/refund.js +3 -1
- package/test/routes/payments/webhook.js +5 -26
- package/test/routes/test/usage.js +2 -2
- package/test/routes/user/signup.js +114 -0
|
@@ -24,6 +24,7 @@ const { filterSources } = require('./lib/filter.js');
|
|
|
24
24
|
const { generateStructure } = require('./lib/structure.js');
|
|
25
25
|
const { generateSectionImage } = require('./lib/svg-illustrator.js');
|
|
26
26
|
const { renderNewsletter } = require('./lib/mjml-template.js');
|
|
27
|
+
const { renderMarkdown } = require('./lib/markdown-renderer.js');
|
|
27
28
|
const { uploadAssets, RAW_BASE } = require('./lib/image-host.js');
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -76,7 +77,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
76
77
|
return null;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
const parentUrl = Manager.
|
|
80
|
+
const parentUrl = Manager.getParentApiUrl();
|
|
80
81
|
|
|
81
82
|
if (!parentUrl) {
|
|
82
83
|
assistant.log('Newsletter generator: no parent URL configured');
|
|
@@ -208,16 +209,34 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
208
209
|
sponsorships,
|
|
209
210
|
});
|
|
210
211
|
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
212
|
+
// 3a. Programmatic markdown view — same `structure`, walked by code (no AI).
|
|
213
|
+
// The markdown is split into ## blocks per section so it can be pasted
|
|
214
|
+
// into Beehiiv's block editor with ad blocks inserted between dispatches.
|
|
215
|
+
// `summary` is a separate short editorial recap, written by the AI
|
|
216
|
+
// during the structure step.
|
|
217
|
+
const markdown = renderMarkdown({
|
|
218
|
+
structure,
|
|
219
|
+
brand,
|
|
220
|
+
imagePaths,
|
|
221
|
+
sponsorships,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const summaryText = (structure.summary || '').trim();
|
|
225
|
+
|
|
226
|
+
// 3b. Upload the rendered HTML + markdown + summary to GitHub alongside the
|
|
227
|
+
// images. All four kinds live in the same {brandId}/{campaignId}/ folder.
|
|
228
|
+
// The folder URL becomes the canonical archive of the issue.
|
|
214
229
|
let assetsFolderUrl = null;
|
|
215
230
|
let htmlUrl = null;
|
|
231
|
+
let markdownUrl = null;
|
|
232
|
+
let summaryUrl = null;
|
|
216
233
|
|
|
217
234
|
if (host === 'github') {
|
|
218
235
|
try {
|
|
219
236
|
const upload = await uploadAssets({
|
|
220
237
|
html,
|
|
238
|
+
markdown,
|
|
239
|
+
summary: summaryText || undefined,
|
|
221
240
|
brandId: brand?.id,
|
|
222
241
|
campaignId,
|
|
223
242
|
subject: structure.subject,
|
|
@@ -225,6 +244,8 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
225
244
|
});
|
|
226
245
|
assetsFolderUrl = upload.folderUrl;
|
|
227
246
|
htmlUrl = upload.htmlUrl;
|
|
247
|
+
markdownUrl = upload.markdownUrl || null;
|
|
248
|
+
summaryUrl = upload.summaryUrl || null;
|
|
228
249
|
} catch (e) {
|
|
229
250
|
assistant.error(`Newsletter generator: HTML upload failed — ${e.message}`);
|
|
230
251
|
}
|
|
@@ -238,6 +259,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
238
259
|
// succeeds regardless. beehiivConfig was already resolved at the top
|
|
239
260
|
// of the function for the initial enabled-check.
|
|
240
261
|
let beehiivPostId = null;
|
|
262
|
+
let beehiivFailureReason = null;
|
|
241
263
|
|
|
242
264
|
if (host === 'github' && beehiivConfig?.enabled) {
|
|
243
265
|
try {
|
|
@@ -248,6 +270,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
248
270
|
subject: structure.subject,
|
|
249
271
|
preheader: structure.preheader,
|
|
250
272
|
content: html,
|
|
273
|
+
contentTags: Array.isArray(structure.tags) ? structure.tags : [],
|
|
251
274
|
status: 'draft',
|
|
252
275
|
});
|
|
253
276
|
|
|
@@ -256,16 +279,39 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
256
279
|
assistant.log(`Newsletter generator: Beehiiv draft created — ${beehiivPostId}`);
|
|
257
280
|
} else {
|
|
258
281
|
// Expected today until Enterprise plan — log, do not throw.
|
|
259
|
-
|
|
282
|
+
beehiivFailureReason = result?.error || 'unknown error';
|
|
283
|
+
assistant.log(`Newsletter generator: Beehiiv draft upload skipped/failed — ${beehiivFailureReason}`);
|
|
260
284
|
}
|
|
261
285
|
} catch (e) {
|
|
286
|
+
beehiivFailureReason = e.message;
|
|
262
287
|
assistant.error(`Newsletter generator: Beehiiv draft upload threw — ${e.message}`);
|
|
263
288
|
}
|
|
264
289
|
}
|
|
265
290
|
|
|
291
|
+
// 3d. Fallback alert email — sent to the brand's internal alerts inbox when
|
|
292
|
+
// Beehiiv draft creation fails. The newsletter is fully generated and
|
|
293
|
+
// archived to GitHub at this point, so the email contains everything
|
|
294
|
+
// needed for a human to manually upload to Beehiiv: HTML URL (one-shot
|
|
295
|
+
// paste), markdown URL (per-section blocks for ad insertion), summary
|
|
296
|
+
// URL, and the tags to set. Failure of THIS email is logged but never
|
|
297
|
+
// blocks the cron — the campaign doc is still written either way.
|
|
298
|
+
if (beehiivFailureReason && htmlUrl) {
|
|
299
|
+
await sendBeehiivFallbackEmail(Manager, assistant, {
|
|
300
|
+
brand,
|
|
301
|
+
subject: structure.subject,
|
|
302
|
+
preheader: structure.preheader,
|
|
303
|
+
tags: Array.isArray(structure.tags) ? structure.tags : [],
|
|
304
|
+
htmlUrl,
|
|
305
|
+
markdownUrl,
|
|
306
|
+
summaryUrl,
|
|
307
|
+
folderUrl: assetsFolderUrl,
|
|
308
|
+
reason: beehiivFailureReason,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
266
312
|
// 4. Mark sources as used on parent server (unless caller opted out)
|
|
267
313
|
if (!opts.skipClaim) {
|
|
268
|
-
const parentUrl = Manager.
|
|
314
|
+
const parentUrl = Manager.getParentApiUrl();
|
|
269
315
|
|
|
270
316
|
if (parentUrl) {
|
|
271
317
|
await claimSources(parentUrl, sources, brand?.id, assistant);
|
|
@@ -308,8 +354,11 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
308
354
|
campaignId,
|
|
309
355
|
folderUrl: assetsFolderUrl,
|
|
310
356
|
htmlUrl,
|
|
357
|
+
markdownUrl,
|
|
358
|
+
summaryUrl,
|
|
311
359
|
imageUrls: imagePaths,
|
|
312
360
|
beehiivPostId,
|
|
361
|
+
tags: Array.isArray(structure.tags) ? structure.tags : [],
|
|
313
362
|
} : null;
|
|
314
363
|
|
|
315
364
|
return {
|
|
@@ -318,14 +367,112 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
318
367
|
preheader: structure.preheader,
|
|
319
368
|
content: '', // legacy markdown field, unused when contentHtml is set
|
|
320
369
|
contentHtml: html, // pre-rendered email-safe HTML
|
|
370
|
+
contentMarkdown: markdown, // programmatic markdown view (per-section blocks for Beehiiv paste)
|
|
371
|
+
summary: summaryText, // editorial recap (separate from preheader)
|
|
372
|
+
tags: Array.isArray(structure.tags) ? structure.tags : [],
|
|
321
373
|
structure, // structured copy for debugging / migration
|
|
322
374
|
mjml, // raw MJML for debugging
|
|
323
375
|
images: opts._lastImages || [], // image buffers for the iteration test to persist locally
|
|
324
|
-
assets, // GitHub asset URLs (folder, html, images) — null in local mode
|
|
376
|
+
assets, // GitHub asset URLs (folder, html, md, summary, images) — null in local mode
|
|
325
377
|
meta, // per-step provider/model/cost/timing telemetry
|
|
326
378
|
};
|
|
327
379
|
}
|
|
328
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Send an internal alert email when Beehiiv draft creation fails so the
|
|
383
|
+
* brand team knows there's a ready newsletter waiting for manual upload.
|
|
384
|
+
*
|
|
385
|
+
* Uses `sender: 'internal'` which auto-resolves to `alerts@{brandDomain}` via
|
|
386
|
+
* the SENDERS table in email/constants.js. Recipient is the same alerts@
|
|
387
|
+
* address — a self-addressed operational alert, no human inbox involved.
|
|
388
|
+
*
|
|
389
|
+
* Errors here are logged but never thrown — the alert is best-effort. If
|
|
390
|
+
* brand.url is unset, the email is skipped entirely.
|
|
391
|
+
*
|
|
392
|
+
* @param {object} Manager
|
|
393
|
+
* @param {object} assistant
|
|
394
|
+
* @param {object} args
|
|
395
|
+
* @param {object} args.brand
|
|
396
|
+
* @param {string} args.subject - The newsletter's subject (used in the alert subject)
|
|
397
|
+
* @param {string} args.preheader
|
|
398
|
+
* @param {string[]} args.tags
|
|
399
|
+
* @param {string} args.htmlUrl - GitHub raw URL to the fully-rendered HTML
|
|
400
|
+
* @param {string} [args.markdownUrl] - GitHub raw URL to the per-section markdown
|
|
401
|
+
* @param {string} [args.summaryUrl] - GitHub raw URL to the 2-3 sentence summary
|
|
402
|
+
* @param {string} [args.folderUrl] - GitHub folder URL (browseable archive)
|
|
403
|
+
* @param {string} args.reason - Why Beehiiv upload failed (API error message)
|
|
404
|
+
*/
|
|
405
|
+
async function sendBeehiivFallbackEmail(Manager, assistant, args) {
|
|
406
|
+
// Send TO and FROM the same internal alerts inbox — alerts@{brandDomain}.
|
|
407
|
+
// The `sender: 'internal'` SENDERS entry already resolves the FROM address
|
|
408
|
+
// to this; we mirror the same domain for the TO so it's a self-addressed
|
|
409
|
+
// operational alert (no human inbox involved).
|
|
410
|
+
const brandDomain = (Manager.config?.brand?.url || '').replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
411
|
+
|
|
412
|
+
if (!brandDomain) {
|
|
413
|
+
assistant.log('Newsletter generator: Beehiiv fallback email skipped — no brand.url');
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const alertsEmail = `alerts@${brandDomain}`;
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const email = Manager.Email(assistant);
|
|
421
|
+
const messageLines = [];
|
|
422
|
+
|
|
423
|
+
messageLines.push(`<strong>Beehiiv draft creation failed</strong> — the newsletter is generated and archived, but needs to be manually uploaded to Beehiiv.`);
|
|
424
|
+
messageLines.push('');
|
|
425
|
+
messageLines.push(`<strong>Failure reason:</strong> ${args.reason}`);
|
|
426
|
+
messageLines.push('');
|
|
427
|
+
messageLines.push('<strong>Newsletter details:</strong>');
|
|
428
|
+
messageLines.push('<ul>');
|
|
429
|
+
messageLines.push(`<li><strong>Subject:</strong> ${args.subject}</li>`);
|
|
430
|
+
messageLines.push(`<li><strong>Preheader:</strong> ${args.preheader || '(none)'}</li>`);
|
|
431
|
+
if (args.tags?.length) {
|
|
432
|
+
messageLines.push(`<li><strong>Tags:</strong> ${args.tags.join(', ')}</li>`);
|
|
433
|
+
}
|
|
434
|
+
messageLines.push('</ul>');
|
|
435
|
+
messageLines.push('');
|
|
436
|
+
messageLines.push('<strong>Assets (manual upload links):</strong>');
|
|
437
|
+
messageLines.push('<ul>');
|
|
438
|
+
messageLines.push(`<li><strong>Full HTML</strong> (one-shot paste): <a href="${args.htmlUrl}">${args.htmlUrl}</a></li>`);
|
|
439
|
+
if (args.markdownUrl) {
|
|
440
|
+
messageLines.push(`<li><strong>Per-section markdown</strong> (paste as separate blocks, ads between): <a href="${args.markdownUrl}">${args.markdownUrl}</a></li>`);
|
|
441
|
+
}
|
|
442
|
+
if (args.summaryUrl) {
|
|
443
|
+
messageLines.push(`<li><strong>Summary</strong> (2-3 sentence recap): <a href="${args.summaryUrl}">${args.summaryUrl}</a></li>`);
|
|
444
|
+
}
|
|
445
|
+
if (args.folderUrl) {
|
|
446
|
+
messageLines.push(`<li><strong>All assets</strong>: <a href="${args.folderUrl}">${args.folderUrl}</a></li>`);
|
|
447
|
+
}
|
|
448
|
+
messageLines.push('</ul>');
|
|
449
|
+
|
|
450
|
+
await email.send({
|
|
451
|
+
sender: 'internal', // resolves to alerts@{brandDomain}
|
|
452
|
+
to: alertsEmail,
|
|
453
|
+
copy: false, // self-addressed operational alert — no CC/BCC clutter
|
|
454
|
+
subject: `Newsletter ready for manual Beehiiv upload: "${args.subject}"`,
|
|
455
|
+
template: 'core/card',
|
|
456
|
+
categories: ['marketing/newsletter-manual-upload'],
|
|
457
|
+
data: {
|
|
458
|
+
email: {
|
|
459
|
+
preview: `Beehiiv upload failed — newsletter awaiting manual upload from ${args.folderUrl || 'GitHub archive'}`,
|
|
460
|
+
},
|
|
461
|
+
body: {
|
|
462
|
+
title: 'Newsletter Ready for Manual Upload',
|
|
463
|
+
message: messageLines.join('\n'),
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
assistant.log(`Newsletter generator: Beehiiv fallback alert sent to ${alertsEmail}`);
|
|
469
|
+
} catch (e) {
|
|
470
|
+
// Best-effort — log and move on. We don't want a misconfigured email
|
|
471
|
+
// setup to break the cron's Firestore write.
|
|
472
|
+
assistant.error(`Newsletter generator: Beehiiv fallback email failed — ${e.message}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
329
476
|
/**
|
|
330
477
|
* Sum tokens + cost across all steps (filter, structure, all SVGs).
|
|
331
478
|
*/
|
|
@@ -329,6 +329,7 @@ async function resolveSegmentIds() {
|
|
|
329
329
|
* @param {string} [options.subject] - Email subject line (defaults to title)
|
|
330
330
|
* @param {string} [options.preheader] - Email preview text
|
|
331
331
|
* @param {string} [options.content] - HTML content body
|
|
332
|
+
* @param {Array<string>} [options.contentTags] - Topical tags attached to the post (Beehiiv `content_tags`)
|
|
332
333
|
* @param {string} [options.status] - 'draft' or 'confirmed' (default: confirmed = send)
|
|
333
334
|
* @param {string} [options.sendAt] - ISO datetime to schedule, or null for immediate
|
|
334
335
|
* @param {Array<string>} [options.segments] - Segment IDs to include
|
|
@@ -342,7 +343,7 @@ async function createPost(options) {
|
|
|
342
343
|
return { success: false, error: 'Publication not found' };
|
|
343
344
|
}
|
|
344
345
|
|
|
345
|
-
const { title, subject, preheader, content, status, sendAt, segments, excludeSegments } = options;
|
|
346
|
+
const { title, subject, preheader, content, contentTags, status, sendAt, segments, excludeSegments } = options;
|
|
346
347
|
|
|
347
348
|
try {
|
|
348
349
|
const body = {
|
|
@@ -355,6 +356,11 @@ async function createPost(options) {
|
|
|
355
356
|
body.body_content = content;
|
|
356
357
|
}
|
|
357
358
|
|
|
359
|
+
// Tags (Beehiiv field is `content_tags`, array of strings)
|
|
360
|
+
if (Array.isArray(contentTags) && contentTags.length) {
|
|
361
|
+
body.content_tags = contentTags;
|
|
362
|
+
}
|
|
363
|
+
|
|
358
364
|
// Scheduling
|
|
359
365
|
if (sendAt && sendAt !== 'now') {
|
|
360
366
|
body.scheduled_at = new Date(sendAt).toISOString();
|
|
@@ -409,6 +415,7 @@ async function createPost(options) {
|
|
|
409
415
|
module.exports = {
|
|
410
416
|
// Resolution
|
|
411
417
|
resolveSegmentIds,
|
|
418
|
+
getPublicationId,
|
|
412
419
|
|
|
413
420
|
// Contacts
|
|
414
421
|
addContact,
|
|
@@ -452,6 +452,18 @@ function resolveProduct(raw, config) {
|
|
|
452
452
|
return { id: 'basic', name: 'Basic' };
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
+
// Test-mode sentinel: the test processor synthesizes "_test_<id>" when no real
|
|
456
|
+
// Stripe product is configured. Map it back to the matching BEM product so the
|
|
457
|
+
// pipeline can be exercised end-to-end without real Stripe credentials.
|
|
458
|
+
if (typeof stripeProductId === 'string' && stripeProductId.startsWith('_test_')) {
|
|
459
|
+
const bemId = stripeProductId.slice('_test_'.length);
|
|
460
|
+
const product = config.payment.products.find((p) => p.id === bemId);
|
|
461
|
+
if (product) {
|
|
462
|
+
return { id: product.id, name: product.name || product.id };
|
|
463
|
+
}
|
|
464
|
+
return { id: 'basic', name: 'Basic' };
|
|
465
|
+
}
|
|
466
|
+
|
|
455
467
|
for (const product of config.payment.products) {
|
|
456
468
|
// Match current product ID
|
|
457
469
|
if (product.stripe?.productId === stripeProductId) {
|
|
@@ -174,5 +174,12 @@ function resolveStripeProductId(productId, config) {
|
|
|
174
174
|
|
|
175
175
|
const product = config.payment.products.find(p => p.id === productId);
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
if (!product) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Real Stripe product ID if configured, otherwise the "_test_<id>" sentinel that the
|
|
182
|
+
// Stripe resolver recognizes and maps back to the BEM product. Lets reconstruction
|
|
183
|
+
// work in brands without real Stripe (Somiibo uses PayPal, Chargebee, etc.).
|
|
184
|
+
return product.stripe?.productId || `_test_${product.id}`;
|
|
178
185
|
}
|
|
@@ -16,8 +16,9 @@ module.exports = async ({ assistant, user, settings }) => {
|
|
|
16
16
|
return assistant.respond('Admin required.', { code: 403 });
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
// Accept single email or array of emails
|
|
20
|
-
|
|
19
|
+
// Accept single email or array of emails. Schema defaults `emails` to [], so check length:
|
|
20
|
+
// an empty default should NOT shadow a provided single `email`.
|
|
21
|
+
const emails = Array.isArray(settings.emails) && settings.emails.length > 0
|
|
21
22
|
? settings.emails
|
|
22
23
|
: [settings.email];
|
|
23
24
|
|
|
@@ -82,7 +82,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
82
82
|
settings.layout = settings.layout;
|
|
83
83
|
settings.date = moment(settings.date || now).subtract(1, 'days').format('YYYY-MM-DD');
|
|
84
84
|
settings.id = settings.id || Math.round(new Date(now).getTime() / 1000);
|
|
85
|
-
settings.
|
|
85
|
+
settings.directory = `src/_posts/${moment(now).format('YYYY')}/${settings.postPath}`;
|
|
86
86
|
settings.githubUser = settings.githubUser || bemRepo.user;
|
|
87
87
|
settings.githubRepo = settings.githubRepo || bemRepo.name;
|
|
88
88
|
|
|
@@ -108,7 +108,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
108
108
|
);
|
|
109
109
|
|
|
110
110
|
// Build post file entry
|
|
111
|
-
|
|
111
|
+
settings.path = `${settings.directory}/${settings.date}-${settings.url}.md`;
|
|
112
112
|
const allFiles = [
|
|
113
113
|
...imageFiles.map(img => ({
|
|
114
114
|
path: img.githubPath,
|
|
@@ -116,7 +116,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
116
116
|
encoding: 'base64',
|
|
117
117
|
})),
|
|
118
118
|
{
|
|
119
|
-
path:
|
|
119
|
+
path: settings.path,
|
|
120
120
|
content: Buffer.from(formattedContent).toString('base64'),
|
|
121
121
|
encoding: 'base64',
|
|
122
122
|
},
|
|
@@ -1,24 +1,118 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* POST /marketing/email-preferences - Update email
|
|
3
|
-
* Public endpoint — no authentication required
|
|
2
|
+
* POST /marketing/email-preferences - Update marketing email consent
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
4
|
+
* Two supported modes:
|
|
5
|
+
*
|
|
6
|
+
* 1) Authenticated (account-page toggle):
|
|
7
|
+
* - User must be logged in.
|
|
8
|
+
* - settings.action = 'opt-in' | 'opt-out'.
|
|
9
|
+
* - Writes canonical consent.marketing to user doc.
|
|
10
|
+
* - Hits BOTH SendGrid + Beehiiv via the email library (one source of truth).
|
|
11
|
+
*
|
|
12
|
+
* 2) Anonymous (HMAC unsubscribe link from email footer):
|
|
13
|
+
* - No login. settings.email + settings.asmId + settings.sig + settings.action.
|
|
14
|
+
* - HMAC validates the link was generated by us.
|
|
15
|
+
* - Hits SendGrid ASM directly (legacy behavior — preserves existing one-click unsub).
|
|
16
|
+
* - Also writes canonical consent.marketing to user doc when email maps to a user.
|
|
8
17
|
*/
|
|
9
18
|
const fetch = require('wonderful-fetch');
|
|
10
19
|
const crypto = require('crypto');
|
|
11
20
|
|
|
12
21
|
const RATE_LIMIT = 5;
|
|
13
22
|
|
|
14
|
-
module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
23
|
+
module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
24
|
+
|
|
25
|
+
// --- AUTHENTICATED MODE ---
|
|
26
|
+
if (user.authenticated) {
|
|
27
|
+
return handleAuthenticated({ assistant, Manager, user, settings, analytics });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// --- ANONYMOUS HMAC MODE ---
|
|
31
|
+
return handleAnonymous({ assistant, Manager, settings, analytics });
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Authenticated user toggling marketing on/off from the account page.
|
|
36
|
+
*/
|
|
37
|
+
async function handleAuthenticated({ assistant, Manager, user, settings, analytics }) {
|
|
38
|
+
const { admin } = Manager.libraries;
|
|
39
|
+
const action = settings.action;
|
|
40
|
+
|
|
41
|
+
if (action !== 'subscribe' && action !== 'unsubscribe') {
|
|
42
|
+
return assistant.respond('Invalid action — must be "subscribe" or "unsubscribe"', { code: 400 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Rate-limit per-user (defense against accidental toggling spam)
|
|
46
|
+
const usage = await Manager.Usage().init(assistant);
|
|
47
|
+
const currentUsage = usage.getUsage('email-preferences');
|
|
48
|
+
if (currentUsage >= RATE_LIMIT) {
|
|
49
|
+
return assistant.respond('Rate limit exceeded', { code: 429 });
|
|
50
|
+
}
|
|
51
|
+
usage.increment('email-preferences');
|
|
52
|
+
await usage.update();
|
|
53
|
+
|
|
54
|
+
const uid = user.auth.uid;
|
|
55
|
+
const email = user.auth.email;
|
|
56
|
+
const ip = assistant.request.geolocation?.ip || null;
|
|
57
|
+
const timestamp = assistant.meta.startTime.timestamp;
|
|
58
|
+
const timestampUNIX = assistant.meta.startTime.timestampUNIX;
|
|
59
|
+
|
|
60
|
+
// Build the consent.marketing mutation
|
|
61
|
+
const marketingPatch = action === 'subscribe'
|
|
62
|
+
? {
|
|
63
|
+
status: 'granted',
|
|
64
|
+
grantedAt: { timestamp, timestampUNIX, source: 'account', ip, text: null },
|
|
65
|
+
// Leave revokedAt untouched — informational record of most recent revoke
|
|
66
|
+
}
|
|
67
|
+
: {
|
|
68
|
+
status: 'revoked',
|
|
69
|
+
revokedAt: { timestamp, timestampUNIX, source: 'account', ip, text: null },
|
|
70
|
+
// Leave grantedAt untouched — informational record of original grant
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
await admin.firestore().doc(`users/${uid}`).set({
|
|
74
|
+
consent: { marketing: marketingPatch },
|
|
75
|
+
metadata: Manager.Metadata().set({ tag: 'marketing/email-preferences' }),
|
|
76
|
+
}, { merge: true });
|
|
77
|
+
|
|
78
|
+
assistant.log(`email-preferences (auth): ${uid} → ${action}`);
|
|
79
|
+
|
|
80
|
+
// Skip provider calls in test mode unless extended mode is on
|
|
81
|
+
const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
82
|
+
|
|
83
|
+
if (shouldCallExternalAPIs) {
|
|
84
|
+
const mailer = Manager.Email(assistant);
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
if (action === 'unsubscribe') {
|
|
88
|
+
await mailer.remove(email);
|
|
89
|
+
} else {
|
|
90
|
+
await mailer.sync(uid);
|
|
91
|
+
}
|
|
92
|
+
} catch (e) {
|
|
93
|
+
assistant.error(`email-preferences (auth) provider sync failed:`, e);
|
|
94
|
+
// Doc is already updated — provider sync is best-effort. Don't fail the request.
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
assistant.log('email-preferences (auth): Skipping provider calls (BEM_TESTING=true)');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
analytics.event('marketing/email-preferences', { action, mode: 'authenticated' });
|
|
101
|
+
|
|
102
|
+
return assistant.respond({ success: true, action });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Anonymous HMAC unsubscribe link (preserves existing email-footer one-click flow).
|
|
107
|
+
*/
|
|
108
|
+
async function handleAnonymous({ assistant, Manager, settings, analytics }) {
|
|
109
|
+
const { admin } = Manager.libraries;
|
|
15
110
|
|
|
16
|
-
// Extract parameters
|
|
17
111
|
const email = (settings.email || '').trim().toLowerCase();
|
|
18
112
|
const asmId = parseInt(settings.asmId, 10);
|
|
19
113
|
const action = settings.action || 'unsubscribe';
|
|
20
114
|
|
|
21
|
-
// Validate
|
|
115
|
+
// Validate inputs
|
|
22
116
|
if (!email) {
|
|
23
117
|
return assistant.respond('Email is required', { code: 400 });
|
|
24
118
|
}
|
|
@@ -28,29 +122,25 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
28
122
|
return assistant.respond('Invalid email format', { code: 400 });
|
|
29
123
|
}
|
|
30
124
|
|
|
31
|
-
// Validate ASM group ID
|
|
32
125
|
if (!asmId || isNaN(asmId)) {
|
|
33
126
|
return assistant.respond('ASM group ID is required', { code: 400 });
|
|
34
127
|
}
|
|
35
128
|
|
|
36
|
-
|
|
37
|
-
if (action !== 'unsubscribe' && action !== 'resubscribe') {
|
|
129
|
+
if (action !== 'subscribe' && action !== 'unsubscribe') {
|
|
38
130
|
return assistant.respond('Invalid action', { code: 400 });
|
|
39
131
|
}
|
|
40
132
|
|
|
41
|
-
//
|
|
133
|
+
// HMAC validation (proves we generated this link)
|
|
42
134
|
const expectedSig = crypto.createHmac('sha256', process.env.UNSUBSCRIBE_HMAC_KEY).update(email).digest('hex');
|
|
43
135
|
if (settings.sig !== expectedSig) {
|
|
44
136
|
return assistant.respond('Invalid signature', { code: 403 });
|
|
45
137
|
}
|
|
46
138
|
|
|
47
|
-
//
|
|
139
|
+
// IP rate limiting (anonymous flow — unauthenticated firestore storage)
|
|
48
140
|
const usage = await Manager.Usage().init(assistant, {
|
|
49
141
|
unauthenticatedMode: 'firestore',
|
|
50
142
|
key: assistant.request.geolocation.ip,
|
|
51
143
|
});
|
|
52
|
-
|
|
53
|
-
// Rate limiting (manual check since email-preferences isn't in product limits)
|
|
54
144
|
const currentUsage = usage.getUsage('email-preferences');
|
|
55
145
|
if (currentUsage >= RATE_LIMIT) {
|
|
56
146
|
return assistant.respond('Rate limit exceeded', { code: 429 });
|
|
@@ -58,37 +148,34 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
58
148
|
usage.increment('email-preferences');
|
|
59
149
|
await usage.update();
|
|
60
150
|
|
|
61
|
-
//
|
|
151
|
+
// Mirror to the user doc if this email maps to a user (best-effort, silent on miss)
|
|
152
|
+
await mirrorAnonymousToUserDoc({ assistant, Manager, email, action });
|
|
153
|
+
|
|
154
|
+
// Call SendGrid ASM (legacy behavior)
|
|
62
155
|
const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
63
156
|
|
|
64
157
|
if (!shouldCallExternalAPIs) {
|
|
65
|
-
assistant.log('
|
|
158
|
+
assistant.log('email-preferences (anon): Skipping SendGrid (BEM_TESTING=true)');
|
|
66
159
|
return assistant.respond({ success: true });
|
|
67
160
|
}
|
|
68
161
|
|
|
69
|
-
// Call SendGrid ASM API
|
|
70
162
|
try {
|
|
71
163
|
if (action === 'unsubscribe') {
|
|
72
|
-
//
|
|
164
|
+
// POST returns JSON ({recipient_emails: [...]}) on success
|
|
73
165
|
await fetch(`https://api.sendgrid.com/v3/asm/groups/${asmId}/suppressions`, {
|
|
74
166
|
method: 'POST',
|
|
75
167
|
response: 'json',
|
|
76
|
-
headers: {
|
|
77
|
-
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
|
|
78
|
-
},
|
|
168
|
+
headers: { 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}` },
|
|
79
169
|
timeout: 10000,
|
|
80
|
-
body: {
|
|
81
|
-
recipient_emails: [email],
|
|
82
|
-
},
|
|
170
|
+
body: { recipient_emails: [email] },
|
|
83
171
|
});
|
|
84
172
|
} else {
|
|
85
|
-
//
|
|
173
|
+
// DELETE returns 204 with empty body — must NOT request response: 'json'
|
|
174
|
+
// or wonderful-fetch throws SyntaxError on the empty body.
|
|
86
175
|
await fetch(`https://api.sendgrid.com/v3/asm/groups/${asmId}/suppressions/${encodeURIComponent(email)}`, {
|
|
87
176
|
method: 'DELETE',
|
|
88
|
-
response: '
|
|
89
|
-
headers: {
|
|
90
|
-
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
|
|
91
|
-
},
|
|
177
|
+
response: 'text',
|
|
178
|
+
headers: { 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}` },
|
|
92
179
|
timeout: 10000,
|
|
93
180
|
});
|
|
94
181
|
}
|
|
@@ -97,12 +184,53 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
97
184
|
return assistant.respond('Failed to process your request', { code: 500 });
|
|
98
185
|
}
|
|
99
186
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// Track analytics
|
|
104
|
-
analytics.event('marketing/email-preferences', { action });
|
|
187
|
+
assistant.log('email-preferences (anon) result:', { email, asmId, action });
|
|
188
|
+
analytics.event('marketing/email-preferences', { action, mode: 'anonymous' });
|
|
105
189
|
|
|
106
|
-
// Generic success (no sensitive info leakage)
|
|
107
190
|
return assistant.respond({ success: true });
|
|
108
|
-
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Anonymous HMAC unsub also writes to the user doc (if found) so consent stays in sync.
|
|
195
|
+
* Silent if the email doesn't map to a user. Source is recorded as 'sendgrid' since the
|
|
196
|
+
* HMAC link only fires from SendGrid email footers.
|
|
197
|
+
*/
|
|
198
|
+
async function mirrorAnonymousToUserDoc({ assistant, Manager, email, action }) {
|
|
199
|
+
const { admin } = Manager.libraries;
|
|
200
|
+
|
|
201
|
+
const snapshot = await admin.firestore().collection('users')
|
|
202
|
+
.where('auth.email', '==', email)
|
|
203
|
+
.limit(1)
|
|
204
|
+
.get()
|
|
205
|
+
.catch((e) => {
|
|
206
|
+
assistant.error('email-preferences (anon): Failed to look up user by email:', e);
|
|
207
|
+
return null;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (!snapshot || snapshot.empty) {
|
|
211
|
+
return; // Silent — email may not map to a current user
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const userDoc = snapshot.docs[0];
|
|
215
|
+
const uid = userDoc.id;
|
|
216
|
+
const timestamp = assistant.meta.startTime.timestamp;
|
|
217
|
+
const timestampUNIX = assistant.meta.startTime.timestampUNIX;
|
|
218
|
+
|
|
219
|
+
const marketingPatch = action === 'unsubscribe'
|
|
220
|
+
? {
|
|
221
|
+
status: 'revoked',
|
|
222
|
+
revokedAt: { timestamp, timestampUNIX, source: 'sendgrid', ip: null, text: null },
|
|
223
|
+
}
|
|
224
|
+
: {
|
|
225
|
+
status: 'granted',
|
|
226
|
+
grantedAt: { timestamp, timestampUNIX, source: 'sendgrid', ip: null, text: null },
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
await admin.firestore().doc(`users/${uid}`).set({
|
|
230
|
+
consent: { marketing: marketingPatch },
|
|
231
|
+
metadata: Manager.Metadata().set({ tag: 'marketing/email-preferences' }),
|
|
232
|
+
}, { merge: true })
|
|
233
|
+
.catch((e) => {
|
|
234
|
+
assistant.error(`email-preferences (anon): Failed to mirror to user doc ${uid}:`, e);
|
|
235
|
+
});
|
|
236
|
+
}
|