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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Beehiiv webhook processor
|
|
3
|
+
*
|
|
4
|
+
* Beehiiv sends one event per POST (unlike SendGrid's batched array).
|
|
5
|
+
* Each event includes a `publication_id` so the processor can decide whether
|
|
6
|
+
* the event belongs to THIS brand or a sibling brand sharing the same parent.
|
|
7
|
+
*
|
|
8
|
+
* Supported event types (anything else is silently ignored):
|
|
9
|
+
* - 'subscription.unsubscribed' — user clicked unsubscribe in a Beehiiv email
|
|
10
|
+
* - 'subscription.deleted' — admin or API removed the subscriber
|
|
11
|
+
* - 'subscription.paused' — user paused delivery (treat as revoke; we
|
|
12
|
+
* can't differentiate "pause" semantics)
|
|
13
|
+
*
|
|
14
|
+
* Publication routing:
|
|
15
|
+
* - If the event's publication_id doesn't match this brand's configured
|
|
16
|
+
* publication, silent skip. This is how the shared-devbeans publication
|
|
17
|
+
* case works: the parent fans the event to every child, and only the
|
|
18
|
+
* brand(s) sharing that publication actually process it.
|
|
19
|
+
* - getPublicationId() reads from config.marketing.beehiiv.publicationId
|
|
20
|
+
* or fuzzy-matches by brand name against the Beehiiv API.
|
|
21
|
+
*
|
|
22
|
+
* Idempotency is enforced by the dispatcher via marketing-webhooks/{eventId} doc.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const REVOKE_EVENT_TYPES = new Set([
|
|
26
|
+
'subscription.unsubscribed',
|
|
27
|
+
'subscription.deleted',
|
|
28
|
+
'subscription.paused',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse the raw webhook request into a normalized array (single-element).
|
|
33
|
+
* Beehiiv sends one event per POST. Some HTTP clients serialize objects with
|
|
34
|
+
* extra keys merged in (e.g. our test client merges query params into the body) —
|
|
35
|
+
* we tolerate that by pulling fields explicitly.
|
|
36
|
+
*/
|
|
37
|
+
function parseWebhook(req) {
|
|
38
|
+
const body = req.body;
|
|
39
|
+
|
|
40
|
+
if (!body || typeof body !== 'object') {
|
|
41
|
+
throw new Error('Empty or non-object webhook body');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Beehiiv's event ID lives at `data.id` (per their docs) but their actual
|
|
45
|
+
// delivery shape can vary across endpoints. Try common locations.
|
|
46
|
+
const eventId = body.id
|
|
47
|
+
|| body.event_id
|
|
48
|
+
|| body.data?.id
|
|
49
|
+
|| null;
|
|
50
|
+
|
|
51
|
+
const eventType = body.event || body.type || null;
|
|
52
|
+
|
|
53
|
+
// Email may be at top-level or nested under `data`
|
|
54
|
+
const rawEmail = body.email || body.data?.email || null;
|
|
55
|
+
const email = typeof rawEmail === 'string' ? rawEmail.trim().toLowerCase() : null;
|
|
56
|
+
|
|
57
|
+
// Publication ID — required to decide whether THIS brand should handle it
|
|
58
|
+
const publicationId = body.publication_id || body.data?.publication_id || null;
|
|
59
|
+
|
|
60
|
+
// Timestamp — Beehiiv uses ISO 8601. Fall back to data.created_at variants.
|
|
61
|
+
const timestampISO = body.created_at
|
|
62
|
+
|| body.timestamp
|
|
63
|
+
|| body.data?.created_at
|
|
64
|
+
|| null;
|
|
65
|
+
|
|
66
|
+
const timestampUNIX = timestampISO
|
|
67
|
+
? Math.floor(new Date(timestampISO).getTime() / 1000) || null
|
|
68
|
+
: null;
|
|
69
|
+
|
|
70
|
+
return [{
|
|
71
|
+
eventId,
|
|
72
|
+
eventType,
|
|
73
|
+
email,
|
|
74
|
+
timestamp: timestampUNIX,
|
|
75
|
+
publicationId,
|
|
76
|
+
raw: body,
|
|
77
|
+
}];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Returns true if this event type represents a revocation we should act on.
|
|
82
|
+
*/
|
|
83
|
+
function isSupported(eventType) {
|
|
84
|
+
return REVOKE_EVENT_TYPES.has(eventType);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Process a single parsed event. Called by the dispatcher AFTER idempotency check passes.
|
|
89
|
+
* Returns a result object summarizing what happened.
|
|
90
|
+
*/
|
|
91
|
+
async function handleEvent({ Manager, assistant, parsed }) {
|
|
92
|
+
const { admin } = Manager.libraries;
|
|
93
|
+
const { eventId, eventType, email, timestamp, publicationId } = parsed;
|
|
94
|
+
|
|
95
|
+
if (!email) {
|
|
96
|
+
assistant.log(`beehiiv webhook: event ${eventId} (${eventType}) missing email, skipping`);
|
|
97
|
+
return { handled: false, reason: 'missing-email' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Publication filter — silent skip if the event isn't for our publication.
|
|
101
|
+
// This is THE mechanism that routes shared-publication events (e.g. devbeans
|
|
102
|
+
// across 6 brands) to only the brands that share that publication.
|
|
103
|
+
// beehiivProvider.getPublicationId() reads Manager.config.marketing.beehiiv.publicationId
|
|
104
|
+
// first, then falls back to fuzzy-match against the Beehiiv API by brand name.
|
|
105
|
+
if (publicationId) {
|
|
106
|
+
let ourPublicationId = null;
|
|
107
|
+
try {
|
|
108
|
+
const beehiivProvider = require('../../../../libraries/email/providers/beehiiv.js');
|
|
109
|
+
if (typeof beehiivProvider.getPublicationId === 'function') {
|
|
110
|
+
ourPublicationId = await beehiivProvider.getPublicationId();
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
assistant.error('beehiiv webhook: failed to resolve our publication ID:', e);
|
|
114
|
+
return { handled: false, reason: 'publication-resolve-failed' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!ourPublicationId) {
|
|
118
|
+
assistant.log(`beehiiv webhook: no publication configured for this brand, skipping event ${eventId}`);
|
|
119
|
+
return { handled: false, reason: 'no-local-publication' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (ourPublicationId !== publicationId) {
|
|
123
|
+
assistant.log(`beehiiv webhook: publication mismatch (event=${publicationId}, ours=${ourPublicationId}), skipping`);
|
|
124
|
+
return { handled: false, reason: 'publication-mismatch' };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Build the canonical revokedAt timestamp from the event (or server time if missing)
|
|
129
|
+
const startTime = assistant.meta.startTime;
|
|
130
|
+
const eventUNIX = typeof timestamp === 'number' ? timestamp : startTime.timestampUNIX;
|
|
131
|
+
const eventISO = new Date(eventUNIX * 1000).toISOString();
|
|
132
|
+
|
|
133
|
+
// Look up the user by email
|
|
134
|
+
const snapshot = await admin.firestore().collection('users')
|
|
135
|
+
.where('auth.email', '==', email)
|
|
136
|
+
.limit(1)
|
|
137
|
+
.get()
|
|
138
|
+
.catch((e) => {
|
|
139
|
+
assistant.error(`beehiiv webhook: user lookup failed for ${email}:`, e);
|
|
140
|
+
return null;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (!snapshot || snapshot.empty) {
|
|
144
|
+
// Silent skip — this email may not map to a customer of THIS brand even if
|
|
145
|
+
// the publication matched (legitimate for shared-devbeans where 6 brands
|
|
146
|
+
// process every event but only one has the user).
|
|
147
|
+
assistant.log(`beehiiv webhook: no user found for ${email}, skipping doc update`);
|
|
148
|
+
return { handled: false, reason: 'user-not-found', email };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const userDoc = snapshot.docs[0];
|
|
152
|
+
const uid = userDoc.id;
|
|
153
|
+
|
|
154
|
+
// Write consent.marketing.status = 'revoked' (preserve grantedAt — informational audit trail)
|
|
155
|
+
await admin.firestore().doc(`users/${uid}`).set({
|
|
156
|
+
consent: {
|
|
157
|
+
marketing: {
|
|
158
|
+
status: 'revoked',
|
|
159
|
+
revokedAt: {
|
|
160
|
+
timestamp: eventISO,
|
|
161
|
+
timestampUNIX: eventUNIX,
|
|
162
|
+
source: 'beehiiv',
|
|
163
|
+
ip: null,
|
|
164
|
+
text: null,
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
metadata: Manager.Metadata().set({ tag: 'marketing/webhook:beehiiv' }),
|
|
169
|
+
}, { merge: true });
|
|
170
|
+
|
|
171
|
+
assistant.log(`beehiiv webhook: revoked consent.marketing for ${uid} (${email}) — eventType=${eventType}`);
|
|
172
|
+
|
|
173
|
+
// Cross-provider sync: also remove from SendGrid (best-effort, idempotent on 404)
|
|
174
|
+
const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
175
|
+
|
|
176
|
+
if (shouldCallExternalAPIs) {
|
|
177
|
+
try {
|
|
178
|
+
const mailer = Manager.Email(assistant);
|
|
179
|
+
await mailer.remove(email);
|
|
180
|
+
assistant.log(`beehiiv webhook: cross-provider sync complete for ${email}`);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
// Best-effort — user doc is already updated. Log + continue.
|
|
183
|
+
assistant.error(`beehiiv webhook: cross-provider sync failed for ${email}:`, e);
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
assistant.log('beehiiv webhook: skipping cross-provider sync (BEM_TESTING=true)');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { handled: true, uid, email, eventType };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
parseWebhook,
|
|
194
|
+
isSupported,
|
|
195
|
+
handleEvent,
|
|
196
|
+
};
|