backend-manager 5.1.4 → 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.
Files changed (75) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/CHANGELOG.md +23 -0
  3. package/CLAUDE.md +2 -1
  4. package/README.md +15 -0
  5. package/docs/common-mistakes.md +1 -0
  6. package/docs/consent.md +333 -0
  7. package/docs/testing.md +36 -0
  8. package/package.json +1 -1
  9. package/src/cli/commands/emulator.js +44 -8
  10. package/src/cli/commands/serve.js +73 -7
  11. package/src/cli/commands/test.js +47 -1
  12. package/src/cli/commands/watch.js +15 -3
  13. package/src/manager/helpers/user.js +29 -0
  14. package/src/manager/index.js +29 -0
  15. package/src/manager/libraries/email/data/disposable-domains.json +8 -0
  16. package/src/manager/libraries/email/generators/newsletter.js +2 -2
  17. package/src/manager/libraries/email/providers/beehiiv.js +1 -0
  18. package/src/manager/libraries/payment/processors/stripe.js +12 -0
  19. package/src/manager/libraries/payment/processors/test.js +8 -1
  20. package/src/manager/routes/admin/infer-contact/post.js +3 -2
  21. package/src/manager/routes/marketing/email-preferences/post.js +165 -37
  22. package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
  23. package/src/manager/routes/marketing/webhook/post.js +180 -0
  24. package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
  25. package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
  26. package/src/manager/routes/payments/cancel/post.js +2 -2
  27. package/src/manager/routes/payments/cancel/processors/test.js +5 -2
  28. package/src/manager/routes/payments/intent/processors/test.js +7 -3
  29. package/src/manager/routes/payments/refund/processors/test.js +4 -1
  30. package/src/manager/routes/user/signup/post.js +65 -1
  31. package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
  32. package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
  33. package/src/manager/schemas/marketing/webhook/post.js +7 -0
  34. package/src/manager/schemas/payments/cancel/post.js +5 -0
  35. package/src/manager/schemas/user/signup/post.js +5 -0
  36. package/src/test/runner.js +61 -18
  37. package/src/test/test-accounts.js +94 -12
  38. package/src/test/utils/http-client.js +4 -3
  39. package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
  40. package/test/events/payments/journey-payments-cancel.js +4 -5
  41. package/test/events/payments/journey-payments-failure.js +0 -1
  42. package/test/events/payments/journey-payments-one-time-failure.js +6 -3
  43. package/test/events/payments/journey-payments-one-time.js +6 -3
  44. package/test/events/payments/journey-payments-plan-change.js +5 -5
  45. package/test/events/payments/journey-payments-refund-webhook.js +2 -3
  46. package/test/events/payments/journey-payments-suspend.js +4 -5
  47. package/test/events/payments/journey-payments-trial-cancel.js +3 -12
  48. package/test/events/payments/journey-payments-trial.js +2 -3
  49. package/test/events/payments/journey-payments-uid-resolution.js +2 -3
  50. package/test/functions/admin/database-read.js +0 -14
  51. package/test/functions/admin/database-write.js +0 -14
  52. package/test/functions/admin/firestore-query.js +0 -14
  53. package/test/functions/admin/firestore-read.js +0 -15
  54. package/test/functions/admin/firestore-write.js +0 -11
  55. package/test/functions/general/add-marketing-contact.js +16 -14
  56. package/test/helpers/email.js +1 -1
  57. package/test/helpers/infer-contact.js +3 -3
  58. package/test/helpers/user.js +241 -2
  59. package/test/helpers/webhook-forward.js +392 -0
  60. package/test/marketing/newsletter-generate.js +17 -7
  61. package/test/routes/admin/database.js +0 -13
  62. package/test/routes/admin/firestore-query.js +0 -13
  63. package/test/routes/admin/firestore.js +0 -14
  64. package/test/routes/admin/infer-contact.js +6 -3
  65. package/test/routes/admin/post.js +4 -2
  66. package/test/routes/marketing/contact.js +60 -26
  67. package/test/routes/marketing/email-preferences.js +145 -69
  68. package/test/routes/marketing/webhook-forward.js +54 -0
  69. package/test/routes/marketing/webhook.js +582 -0
  70. package/test/routes/payments/cancel.js +2 -7
  71. package/test/routes/payments/dispute-alert.js +0 -39
  72. package/test/routes/payments/refund.js +3 -1
  73. package/test/routes/payments/webhook.js +5 -26
  74. package/test/routes/test/usage.js +2 -2
  75. package/test/routes/user/signup.js +114 -0
@@ -1,24 +1,118 @@
1
1
  /**
2
- * POST /marketing/email-preferences - Update email preferences
3
- * Public endpoint — no authentication required
2
+ * POST /marketing/email-preferences - Update marketing email consent
4
3
  *
5
- * Supports two actions:
6
- * - "unsubscribe": Adds email to SendGrid ASM suppression group
7
- * - "resubscribe": Removes email from SendGrid ASM suppression group
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 email
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
- // Validate action
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
- // Verify HMAC signature (proves the link was generated by our server)
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
- // Initialize Usage for rate limiting (key: IP forces unauthenticated storage always)
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
- // Skip external API calls in test mode unless TEST_EXTENDED_MODE is set
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('marketing/email-preferences: Skipping SendGrid (BEM_TESTING=true, TEST_EXTENDED_MODE not set)');
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
- // Add email to suppression group
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
- // Remove email from suppression group
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: 'json',
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
- // Log result
101
- assistant.log('marketing/email-preferences result:', { email, asmId, action });
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
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * POST /marketing/webhook/forward?provider=sendgrid|beehiiv&key=<BACKEND_MANAGER_WEBHOOK_KEY>
3
+ *
4
+ * Parent-only forwarder. SendGrid and Beehiiv send webhooks to this single URL
5
+ * on the parent BEM. The parent reads its `brands` collection and re-POSTs the
6
+ * raw body to every child's /marketing/webhook?provider=X. Each child then
7
+ * processes the event against its own Firestore and providers.
8
+ *
9
+ * Gating:
10
+ * - Only enabled when Manager.config.parent === 'self'. Any other value (a URL
11
+ * pointing TO the parent, the typical setup for child BEMs) returns 404.
12
+ * - Same BACKEND_MANAGER_WEBHOOK_KEY is shared across all brands, so the
13
+ * parent forwards the key it received (already validated) when calling
14
+ * each child.
15
+ *
16
+ * Brand URL derivation:
17
+ * - Each brand doc in the `brands` collection has `brand.url` (e.g. 'https://somiibo.com').
18
+ * - API URL is derived by inserting 'api.' subdomain: 'https://api.somiibo.com'.
19
+ * - Child receivers live at `/backend-manager/marketing/webhook` on that host.
20
+ *
21
+ * Self-inclusion:
22
+ * - The parent's own brand IS included in the fan-out. The parent BEM has
23
+ * its own user base (e.g. itwcreativeworks.com users) and needs the same
24
+ * consent updates as any other brand. Self-fan-out goes via HTTP like
25
+ * every other child — no special inline path.
26
+ *
27
+ * Failure isolation:
28
+ * - Each child POST is awaited via Promise.allSettled so one slow/down child
29
+ * doesn't block the others. Failures are logged but the overall request
30
+ * still returns 200 so the provider doesn't retry the parent indefinitely.
31
+ * Children themselves track idempotency, so provider retries are safe.
32
+ */
33
+ const fetch = require('wonderful-fetch');
34
+
35
+ const CHILD_TIMEOUT_MS = 10000;
36
+
37
+ module.exports = async ({ assistant, Manager, libraries }) => {
38
+ const { admin } = libraries;
39
+ const query = assistant.request.query;
40
+
41
+ // Gate: only the parent BEM exposes this route. Any brand whose config.parent
42
+ // points to a URL (the normal case) returns 404 — pretend the route doesn't exist.
43
+ if (!Manager.isParent()) {
44
+ return assistant.respond('Not found', { code: 404 });
45
+ }
46
+
47
+ const provider = query.provider;
48
+ const key = query.key;
49
+
50
+ if (!provider) {
51
+ return assistant.respond('Missing provider parameter', { code: 400 });
52
+ }
53
+
54
+ // Same key used for the receiver — parent validates incoming, then re-uses
55
+ // it for outbound calls to children (all brands share this env value).
56
+ if (!key || key !== process.env.BACKEND_MANAGER_WEBHOOK_KEY) {
57
+ return assistant.respond('Invalid key', { code: 401 });
58
+ }
59
+
60
+ // Read the brands collection. This lives in the PARENT's Firestore.
61
+ const snapshot = await admin.firestore().collection('brands').get()
62
+ .catch((e) => {
63
+ assistant.error('marketing webhook forward: failed to read brands collection:', e);
64
+ return null;
65
+ });
66
+
67
+ if (!snapshot) {
68
+ return assistant.respond('Failed to load brands', { code: 500 });
69
+ }
70
+
71
+ // Collect brand URLs from the docs
72
+ const brands = [];
73
+ snapshot.forEach((doc) => {
74
+ const data = doc.data() || {};
75
+ const brandUrl = data.brand?.url || null;
76
+ const brandId = data.brand?.id || doc.id;
77
+
78
+ if (!brandUrl) {
79
+ assistant.log(`marketing webhook forward: brand ${brandId} has no brand.url, skipping`);
80
+ return;
81
+ }
82
+
83
+ brands.push({ brandId, brandUrl });
84
+ });
85
+
86
+ if (brands.length === 0) {
87
+ assistant.log('marketing webhook forward: no brands to forward to');
88
+ return assistant.respond({ received: true, forwarded: 0 });
89
+ }
90
+
91
+ assistant.log(`marketing webhook forward: fanning out ${provider} event to ${brands.length} brand(s)`);
92
+
93
+ // Forward the raw body to every child. assistant.ref.req.body holds the body
94
+ // as we received it from the provider. We re-POST it without modification.
95
+ const rawBody = assistant.ref.req?.body;
96
+
97
+ const results = await Promise.allSettled(
98
+ brands.map(({ brandId, brandUrl }) => forwardToChild({
99
+ assistant,
100
+ brandId,
101
+ brandUrl,
102
+ provider,
103
+ key,
104
+ body: rawBody,
105
+ }))
106
+ );
107
+
108
+ let succeeded = 0;
109
+ let failed = 0;
110
+ const failures = [];
111
+ for (let i = 0; i < results.length; i += 1) {
112
+ const r = results[i];
113
+ const brand = brands[i];
114
+ if (r.status === 'fulfilled' && r.value?.ok) {
115
+ succeeded += 1;
116
+ } else {
117
+ failed += 1;
118
+ const reason = r.status === 'rejected' ? r.reason?.message : (r.value?.error || 'unknown');
119
+ failures.push({ brandId: brand.brandId, reason });
120
+ assistant.error(`marketing webhook forward: ${brand.brandId} failed:`, reason);
121
+ }
122
+ }
123
+
124
+ assistant.log(`marketing webhook forward: ${provider} complete — succeeded=${succeeded}, failed=${failed}`);
125
+
126
+ // Always return 200 — child failures shouldn't make the provider retry the
127
+ // parent. Each child tracks its own idempotency so safe to re-fan on retry.
128
+ return assistant.respond({
129
+ received: true,
130
+ forwarded: brands.length,
131
+ succeeded,
132
+ failed,
133
+ failures: failures.length > 0 ? failures : undefined,
134
+ });
135
+ };
136
+
137
+ /**
138
+ * POST the raw body to one child BEM's /marketing/webhook receiver.
139
+ * Returns { ok: true } on success, { ok: false, error } on failure.
140
+ */
141
+ async function forwardToChild({ assistant, brandId, brandUrl, provider, key, body }) {
142
+ // Derive API URL: brandUrl 'https://somiibo.com' → 'https://api.somiibo.com'.
143
+ // Use URL parsing so we tolerate trailing slashes and unusual hosts.
144
+ let apiUrl;
145
+ try {
146
+ const url = new URL(brandUrl);
147
+ url.hostname = `api.${url.hostname}`;
148
+ url.pathname = '/backend-manager/marketing/webhook';
149
+ url.search = `?provider=${encodeURIComponent(provider)}&key=${encodeURIComponent(key)}`;
150
+ apiUrl = url.toString();
151
+ } catch (e) {
152
+ return { ok: false, error: `Invalid brand URL "${brandUrl}": ${e.message}` };
153
+ }
154
+
155
+ try {
156
+ const result = await fetch(apiUrl, {
157
+ method: 'POST',
158
+ response: 'json',
159
+ timeout: CHILD_TIMEOUT_MS,
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body,
162
+ });
163
+ assistant.log(`marketing webhook forward: ${brandId} OK — ${JSON.stringify(result)}`);
164
+ return { ok: true };
165
+ } catch (e) {
166
+ return { ok: false, error: e?.message || String(e) };
167
+ }
168
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * POST /marketing/webhook?provider=sendgrid|beehiiv&key=<BACKEND_MANAGER_WEBHOOK_KEY>
3
+ *
4
+ * Receives cross-provider unsubscribe webhooks (SendGrid + Beehiiv) and:
5
+ * 1. Authenticates via ?key= query param (BACKEND_MANAGER_WEBHOOK_KEY env)
6
+ * 2. Optionally rejects mismatched brand via ?brand= filter
7
+ * 3. Loads the matching processor module from ./processors/{provider}.js
8
+ * 4. Parses the webhook payload into one or more normalized events
9
+ * 5. For each event: idempotency check via marketing-webhooks/{eventId}, then dispatch
10
+ * 6. Returns 200 immediately so the provider doesn't retry
11
+ *
12
+ * Each processor module defines:
13
+ * - parseWebhook(req) — returns Array<{ eventId, eventType, email, timestamp, raw, ... }>
14
+ * - isSupported(type) — returns true if this event should be processed
15
+ * - handleEvent(ctx) — does the work for one event (user doc + cross-provider sync)
16
+ *
17
+ * Mirrors the existing payments-webhook pattern. Processes events inline rather than
18
+ * via a Firestore trigger — marketing webhooks are lower volume and lighter work than
19
+ * payments, so the extra async layer isn't justified.
20
+ */
21
+ const path = require('path');
22
+ const powertools = require('node-powertools');
23
+
24
+ module.exports = async ({ assistant, Manager, libraries }) => {
25
+ const { admin } = libraries;
26
+ const query = assistant.request.query;
27
+
28
+ const provider = query.provider;
29
+ const key = query.key;
30
+
31
+ // Validate provider
32
+ if (!provider) {
33
+ return assistant.respond('Missing provider parameter', { code: 400 });
34
+ }
35
+
36
+ // Validate key against BACKEND_MANAGER_WEBHOOK_KEY (separate from BACKEND_MANAGER_KEY
37
+ // so it can be rotated independently and scoped narrowly)
38
+ if (!key || key !== process.env.BACKEND_MANAGER_WEBHOOK_KEY) {
39
+ return assistant.respond('Invalid key', { code: 401 });
40
+ }
41
+
42
+ // Brand filter (defensive — mirror payments webhook pattern). If a brand is
43
+ // specified and doesn't match ours, silently ignore. This lets one webhook
44
+ // URL be shared across brands while each brand only processes its own events.
45
+ const brand = query.brand;
46
+ const ourBrand = Manager.config.brand?.id;
47
+ if (brand && ourBrand && brand !== ourBrand) {
48
+ assistant.log(`marketing webhook: brand mismatch (received=${brand}, expected=${ourBrand}), ignoring`);
49
+ return assistant.respond({ received: true, ignored: true });
50
+ }
51
+
52
+ // Load the processor module
53
+ let processorModule;
54
+ try {
55
+ processorModule = require(path.resolve(__dirname, `processors/${provider}.js`));
56
+ } catch (e) {
57
+ assistant.error(`marketing webhook: failed to load processor "${provider}":`, e);
58
+ return assistant.respond(`Unknown provider: ${provider}`, { code: 400 });
59
+ }
60
+
61
+ // Parse the webhook body into events
62
+ let events;
63
+ try {
64
+ events = processorModule.parseWebhook(assistant.ref.req);
65
+ } catch (e) {
66
+ assistant.error(`marketing webhook: parse failed for ${provider}:`, e);
67
+ return assistant.respond(`Failed to parse webhook: ${e.message}`, { code: 400 });
68
+ }
69
+
70
+ if (!Array.isArray(events) || events.length === 0) {
71
+ assistant.log(`marketing webhook: ${provider} returned no events`);
72
+ return assistant.respond({ received: true, processed: 0 });
73
+ }
74
+
75
+ assistant.log(`marketing webhook: ${provider} delivered ${events.length} event(s)`);
76
+
77
+ // Process each event independently — one failure shouldn't block the others.
78
+ // Use Promise.allSettled so we return success only after all events have been
79
+ // attempted.
80
+ const results = await Promise.allSettled(
81
+ events.map((event) => processOneEvent({ Manager, assistant, admin, provider, event, processorModule }))
82
+ );
83
+
84
+ let processed = 0;
85
+ let skipped = 0;
86
+ let failed = 0;
87
+ for (const r of results) {
88
+ if (r.status === 'fulfilled') {
89
+ if (r.value?.processed) processed++;
90
+ else skipped++;
91
+ } else {
92
+ failed++;
93
+ assistant.error('marketing webhook: event processing rejected:', r.reason);
94
+ }
95
+ }
96
+
97
+ assistant.log(`marketing webhook: ${provider} complete — processed=${processed}, skipped=${skipped}, failed=${failed}`);
98
+
99
+ return assistant.respond({ received: true, processed, skipped, failed });
100
+ };
101
+
102
+ /**
103
+ * Process a single event end-to-end: idempotency check, support check, dispatch to handler.
104
+ * Returns { processed: bool, skipped?: string, error?: any }.
105
+ */
106
+ async function processOneEvent({ Manager, assistant, admin, provider, event, processorModule }) {
107
+ const { eventId, eventType } = event;
108
+
109
+ // No eventId means we can't dedupe — skip rather than risk double-processing
110
+ if (!eventId) {
111
+ assistant.log(`marketing webhook: ${provider} event missing eventId (type=${eventType}), skipping`);
112
+ return { processed: false, skipped: 'missing-event-id' };
113
+ }
114
+
115
+ // Filter by supported event types
116
+ if (processorModule.isSupported && !processorModule.isSupported(eventType)) {
117
+ return { processed: false, skipped: 'unsupported-event-type' };
118
+ }
119
+
120
+ // Idempotency: skip if we've already processed this event
121
+ const idempotencyRef = admin.firestore().doc(`marketing-webhooks/${eventId}`);
122
+ const existingDoc = await idempotencyRef.get();
123
+
124
+ if (existingDoc.exists) {
125
+ const existingStatus = existingDoc.data()?.status;
126
+ if (existingStatus !== 'failed') {
127
+ assistant.log(`marketing webhook: ${provider} duplicate event ${eventId} (status=${existingStatus}), skipping`);
128
+ return { processed: false, skipped: 'duplicate' };
129
+ }
130
+ assistant.log(`marketing webhook: ${provider} retrying previously failed event ${eventId}`);
131
+ }
132
+
133
+ // Build the audit doc
134
+ const now = powertools.timestamp(new Date(), { output: 'string' });
135
+ const nowUNIX = powertools.timestamp(now, { output: 'unix' });
136
+
137
+ // Write 'pending' state before dispatching so concurrent deliveries see the lock
138
+ await idempotencyRef.set({
139
+ id: eventId,
140
+ provider,
141
+ status: 'pending',
142
+ raw: event.raw || null,
143
+ event: {
144
+ type: eventType,
145
+ email: event.email || null,
146
+ timestamp: event.timestamp || null,
147
+ },
148
+ error: null,
149
+ metadata: {
150
+ created: { timestamp: now, timestampUNIX: nowUNIX },
151
+ completed: { timestamp: null, timestampUNIX: null },
152
+ },
153
+ });
154
+
155
+ // Dispatch to the processor's event handler
156
+ let handlerResult;
157
+ try {
158
+ handlerResult = await processorModule.handleEvent({ Manager, assistant, parsed: event });
159
+
160
+ // Mark completed
161
+ await idempotencyRef.set({
162
+ status: 'completed',
163
+ result: handlerResult || null,
164
+ metadata: {
165
+ completed: { timestamp: powertools.timestamp(new Date(), { output: 'string' }), timestampUNIX: powertools.timestamp(new Date(), { output: 'unix' }) },
166
+ },
167
+ }, { merge: true });
168
+
169
+ return { processed: true };
170
+ } catch (e) {
171
+ assistant.error(`marketing webhook: handler failed for ${provider} event ${eventId}:`, e);
172
+
173
+ await idempotencyRef.set({
174
+ status: 'failed',
175
+ error: { message: e.message || String(e), stack: e.stack || null },
176
+ }, { merge: true }).catch(() => {});
177
+
178
+ return { processed: false, error: e };
179
+ }
180
+ }