backend-manager 5.1.4 → 5.2.1
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 +29 -0
- package/CLAUDE.md +2 -1
- package/README.md +15 -0
- package/docs/common-mistakes.md +1 -0
- package/docs/consent.md +333 -0
- package/docs/testing.md +36 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +44 -8
- package/src/cli/commands/serve.js +73 -7
- package/src/cli/commands/test.js +47 -1
- package/src/cli/commands/watch.js +15 -3
- package/src/manager/helpers/user.js +29 -0
- package/src/manager/index.js +29 -0
- package/src/manager/libraries/email/data/disposable-domains.json +8 -0
- package/src/manager/libraries/email/generators/newsletter.js +2 -2
- package/src/manager/libraries/email/providers/beehiiv.js +1 -0
- 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/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/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/runner.js +61 -18
- package/src/test/test-accounts.js +94 -12
- package/src/test/utils/http-client.js +4 -3
- package/templates/_.env +1 -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/newsletter-generate.js +17 -7
- 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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|