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.
- package/.claude/settings.local.json +12 -0
- package/CHANGELOG.md +23 -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/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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SendGrid webhook processor
|
|
3
|
+
*
|
|
4
|
+
* SendGrid sends an array of events per POST. This module parses the array,
|
|
5
|
+
* decides which events represent an unsubscribe (revocation of marketing consent),
|
|
6
|
+
* and exposes a per-event handler that:
|
|
7
|
+
* 1. Looks up the user by email in this brand's Firestore (silent skip if not found)
|
|
8
|
+
* 2. Writes consent.marketing.status = 'revoked' to the user doc, source: 'sendgrid'
|
|
9
|
+
* 3. Calls Beehiiv to remove the contact too (cross-provider sync — idempotent on 404)
|
|
10
|
+
*
|
|
11
|
+
* Supported event types (anything else is silently ignored):
|
|
12
|
+
* - 'unsubscribe' — user clicked the unified unsubscribe link
|
|
13
|
+
* - 'group_unsubscribe' — user unsubscribed from a specific ASM group
|
|
14
|
+
* - 'spamreport' — user marked email as spam
|
|
15
|
+
* - 'bounce' — hard bounce (treat as revoke to avoid future sends)
|
|
16
|
+
* - 'dropped' — SendGrid dropped the message (often due to suppression)
|
|
17
|
+
*
|
|
18
|
+
* Note: 'group_unsubscribe' is the most common one (matches our ASM-link flow), and since
|
|
19
|
+
* GROUPS.marketing (25928) is account-global across all brands, an unsub from group 25928
|
|
20
|
+
* legitimately removes the user from marketing across the entire SendGrid account.
|
|
21
|
+
*
|
|
22
|
+
* Idempotency is enforced by the dispatcher via marketing-webhooks/{eventId} doc.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const REVOKE_EVENT_TYPES = new Set([
|
|
26
|
+
'unsubscribe',
|
|
27
|
+
'group_unsubscribe',
|
|
28
|
+
'spamreport',
|
|
29
|
+
'bounce',
|
|
30
|
+
'dropped',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse the raw webhook request into a normalized array of events.
|
|
35
|
+
* SendGrid sends an array of events as the body. Some HTTP clients (including
|
|
36
|
+
* BEM's own test client) JSON-encode arrays as objects with numeric keys —
|
|
37
|
+
* we tolerate both shapes plus the rare single-event object form.
|
|
38
|
+
*/
|
|
39
|
+
function parseWebhook(req) {
|
|
40
|
+
const body = req.body;
|
|
41
|
+
|
|
42
|
+
if (!body) {
|
|
43
|
+
throw new Error('Empty webhook body');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let events;
|
|
47
|
+
if (Array.isArray(body)) {
|
|
48
|
+
// Real SendGrid: array body
|
|
49
|
+
events = body;
|
|
50
|
+
} else if (body && typeof body === 'object' && typeof body['0'] === 'object' && body['0'] !== null) {
|
|
51
|
+
// Array serialized as object-with-numeric-keys (test clients, some proxies)
|
|
52
|
+
events = [];
|
|
53
|
+
let i = 0;
|
|
54
|
+
while (body[String(i)]) {
|
|
55
|
+
events.push(body[String(i)]);
|
|
56
|
+
i += 1;
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
// Single event sent as a bare object
|
|
60
|
+
events = [body];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return events.map((event) => {
|
|
64
|
+
// sg_event_id is SendGrid's per-event unique ID, used for idempotency.
|
|
65
|
+
// smtp-id is another stable identifier we fall back to.
|
|
66
|
+
const eventId = event.sg_event_id || event['smtp-id'] || event.smtpId || null;
|
|
67
|
+
const eventType = event.event;
|
|
68
|
+
const email = typeof event.email === 'string' ? event.email.trim().toLowerCase() : null;
|
|
69
|
+
const timestamp = typeof event.timestamp === 'number' ? event.timestamp : null;
|
|
70
|
+
const asmGroupId = event.asm_group_id || null;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
eventId,
|
|
74
|
+
eventType,
|
|
75
|
+
email,
|
|
76
|
+
timestamp,
|
|
77
|
+
asmGroupId,
|
|
78
|
+
raw: event,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Returns true if this event type represents a revocation we should act on.
|
|
85
|
+
*/
|
|
86
|
+
function isSupported(eventType) {
|
|
87
|
+
return REVOKE_EVENT_TYPES.has(eventType);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Process a single parsed event. Called by the dispatcher AFTER idempotency check passes.
|
|
92
|
+
*
|
|
93
|
+
* Returns a result object summarizing what happened (for logging/response).
|
|
94
|
+
*/
|
|
95
|
+
async function handleEvent({ Manager, assistant, parsed }) {
|
|
96
|
+
const { admin } = Manager.libraries;
|
|
97
|
+
const { eventId, eventType, email, timestamp } = parsed;
|
|
98
|
+
|
|
99
|
+
if (!email) {
|
|
100
|
+
assistant.log(`sendgrid webhook: event ${eventId} (${eventType}) missing email, skipping`);
|
|
101
|
+
return { handled: false, reason: 'missing-email' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Convert SendGrid's UNIX timestamp to our canonical { timestamp, timestampUNIX } shape.
|
|
105
|
+
// Fall back to server time if missing.
|
|
106
|
+
const startTime = assistant.meta.startTime;
|
|
107
|
+
const eventUNIX = typeof timestamp === 'number' ? timestamp : startTime.timestampUNIX;
|
|
108
|
+
const eventISO = new Date(eventUNIX * 1000).toISOString();
|
|
109
|
+
|
|
110
|
+
// Look up the user by email
|
|
111
|
+
const snapshot = await admin.firestore().collection('users')
|
|
112
|
+
.where('auth.email', '==', email)
|
|
113
|
+
.limit(1)
|
|
114
|
+
.get()
|
|
115
|
+
.catch((e) => {
|
|
116
|
+
assistant.error(`sendgrid webhook: user lookup failed for ${email}:`, e);
|
|
117
|
+
return null;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (!snapshot || snapshot.empty) {
|
|
121
|
+
// Silent skip — this email may not map to a customer of THIS brand (shared SendGrid account).
|
|
122
|
+
assistant.log(`sendgrid webhook: no user found for ${email}, skipping doc update`);
|
|
123
|
+
return { handled: false, reason: 'user-not-found', email };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const userDoc = snapshot.docs[0];
|
|
127
|
+
const uid = userDoc.id;
|
|
128
|
+
|
|
129
|
+
// Write consent.marketing.status = 'revoked' (preserve grantedAt — informational audit trail)
|
|
130
|
+
await admin.firestore().doc(`users/${uid}`).set({
|
|
131
|
+
consent: {
|
|
132
|
+
marketing: {
|
|
133
|
+
status: 'revoked',
|
|
134
|
+
revokedAt: {
|
|
135
|
+
timestamp: eventISO,
|
|
136
|
+
timestampUNIX: eventUNIX,
|
|
137
|
+
source: 'sendgrid',
|
|
138
|
+
ip: null,
|
|
139
|
+
text: null,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
metadata: Manager.Metadata().set({ tag: 'marketing/webhook:sendgrid' }),
|
|
144
|
+
}, { merge: true });
|
|
145
|
+
|
|
146
|
+
assistant.log(`sendgrid webhook: revoked consent.marketing for ${uid} (${email}) — eventType=${eventType}`);
|
|
147
|
+
|
|
148
|
+
// Cross-provider sync: also remove from Beehiiv (best-effort, idempotent on 404)
|
|
149
|
+
const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
150
|
+
|
|
151
|
+
if (shouldCallExternalAPIs) {
|
|
152
|
+
try {
|
|
153
|
+
const mailer = Manager.Email(assistant);
|
|
154
|
+
await mailer.remove(email);
|
|
155
|
+
assistant.log(`sendgrid webhook: cross-provider sync complete for ${email}`);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
// Best-effort — user doc is already updated. Log + continue.
|
|
158
|
+
assistant.error(`sendgrid webhook: cross-provider sync failed for ${email}:`, e);
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
assistant.log('sendgrid webhook: skipping cross-provider sync (BEM_TESTING=true)');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { handled: true, uid, email, eventType };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
parseWebhook,
|
|
169
|
+
isSupported,
|
|
170
|
+
handleEvent,
|
|
171
|
+
};
|
|
@@ -32,9 +32,9 @@ module.exports = async ({ assistant, user, settings }) => {
|
|
|
32
32
|
return assistant.respond('No active paid subscription found', { code: 400 });
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
// Guard: subscription younger than 24 hours
|
|
35
|
+
// Guard: subscription younger than 24 hours (callers may bypass via skipGuards)
|
|
36
36
|
const startDateUNIX = subscription.payment?.startDate?.timestampUNIX;
|
|
37
|
-
if (startDateUNIX) {
|
|
37
|
+
if (!settings.skipGuards && startDateUNIX) {
|
|
38
38
|
const ageMs = Date.now() - (startDateUNIX * 1000);
|
|
39
39
|
const twentyFourHoursMs = 24 * 60 * 60 * 1000;
|
|
40
40
|
if (ageMs < twentyFourHoursMs) {
|
|
@@ -23,7 +23,8 @@ module.exports = {
|
|
|
23
23
|
const now = Math.floor(timestamp / 1000);
|
|
24
24
|
const periodEnd = now + (30 * 86400);
|
|
25
25
|
|
|
26
|
-
// Look up the Stripe product ID from the existing order so resolveProduct() can match
|
|
26
|
+
// Look up the Stripe product ID from the existing order so resolveProduct() can match.
|
|
27
|
+
// Falls back to the "_test_<id>" sentinel when no real Stripe product is configured.
|
|
27
28
|
const orderId = subscription?.payment?.orderId;
|
|
28
29
|
let stripeProductId = null;
|
|
29
30
|
|
|
@@ -34,7 +35,9 @@ module.exports = {
|
|
|
34
35
|
const productId = orderData.unified?.product?.id;
|
|
35
36
|
const products = assistant.Manager.config.payment?.products || [];
|
|
36
37
|
const product = products.find(p => p.id === productId);
|
|
37
|
-
|
|
38
|
+
if (product) {
|
|
39
|
+
stripeProductId = product.stripe?.productId || `_test_${product.id}`;
|
|
40
|
+
}
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -57,14 +57,18 @@ async function createSubscriptionIntent({ uid, orderId, product, frequency, tria
|
|
|
57
57
|
const now = Math.floor(timestamp / 1000);
|
|
58
58
|
const periodEnd = now + (FREQUENCY_TO_PERIOD[frequency] || 30 * 86400);
|
|
59
59
|
|
|
60
|
-
// Build Stripe-shaped subscription object
|
|
61
|
-
//
|
|
60
|
+
// Build Stripe-shaped subscription object.
|
|
61
|
+
// Prefer the real Stripe product ID if configured; otherwise use a sentinel of the
|
|
62
|
+
// form "_test_<id>" that the Stripe resolver recognizes and maps back to product.id.
|
|
63
|
+
// This lets the test processor work in brands that haven't wired up Stripe yet.
|
|
64
|
+
const planProductId = product.stripe?.productId || `_test_${product.id}`;
|
|
65
|
+
|
|
62
66
|
const subscription = {
|
|
63
67
|
id: subscriptionId,
|
|
64
68
|
object: 'subscription',
|
|
65
69
|
status: trial && product.trial?.days ? 'trialing' : 'active',
|
|
66
70
|
metadata: { uid, orderId },
|
|
67
|
-
plan: { product:
|
|
71
|
+
plan: { product: planProductId, interval },
|
|
68
72
|
current_period_end: periodEnd,
|
|
69
73
|
current_period_start: now,
|
|
70
74
|
start_date: now,
|
|
@@ -22,6 +22,7 @@ module.exports = {
|
|
|
22
22
|
|
|
23
23
|
// Look up the Stripe product ID from the existing order so resolveProduct() can match
|
|
24
24
|
const orderId = subscription?.payment?.orderId;
|
|
25
|
+
// Falls back to the "_test_<id>" sentinel when no real Stripe product is configured.
|
|
25
26
|
let stripeProductId = null;
|
|
26
27
|
|
|
27
28
|
if (orderId) {
|
|
@@ -31,7 +32,9 @@ module.exports = {
|
|
|
31
32
|
const productId = orderData.unified?.product?.id;
|
|
32
33
|
const products = assistant.Manager.config.payment?.products || [];
|
|
33
34
|
const product = products.find(p => p.id === productId);
|
|
34
|
-
|
|
35
|
+
if (product) {
|
|
36
|
+
stripeProductId = product.stripe?.productId || `_test_${product.id}`;
|
|
37
|
+
}
|
|
35
38
|
}
|
|
36
39
|
}
|
|
37
40
|
|
|
@@ -76,7 +76,12 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
|
|
|
76
76
|
await processAffiliate(assistant, uid, email, settings);
|
|
77
77
|
|
|
78
78
|
// 6. Send emails + marketing (non-blocking, fire-and-forget)
|
|
79
|
-
|
|
79
|
+
// Gate marketing sync on explicit consent — never add a user to marketing lists without it
|
|
80
|
+
if (userRecord.consent?.marketing?.status === 'granted') {
|
|
81
|
+
syncMarketingContact(assistant, uid, email);
|
|
82
|
+
} else {
|
|
83
|
+
assistant.log(`signup(): Skipping marketing sync — consent.marketing.status is "${userRecord.consent?.marketing?.status}"`);
|
|
84
|
+
}
|
|
80
85
|
sendWelcomeEmails(assistant, uid, inferred?.firstName);
|
|
81
86
|
|
|
82
87
|
return assistant.respond({ signedUp: true });
|
|
@@ -137,6 +142,7 @@ function buildUserRecord(assistant, settings, inferred) {
|
|
|
137
142
|
},
|
|
138
143
|
},
|
|
139
144
|
attribution: attribution || {},
|
|
145
|
+
consent: buildConsentRecord(assistant, settings.consent),
|
|
140
146
|
metadata: Manager.Metadata().set({ tag: 'user/signup' }),
|
|
141
147
|
};
|
|
142
148
|
|
|
@@ -156,6 +162,64 @@ function buildUserRecord(assistant, settings, inferred) {
|
|
|
156
162
|
return record;
|
|
157
163
|
}
|
|
158
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Translate the client's lightweight consent payload into the canonical user-doc shape.
|
|
167
|
+
*
|
|
168
|
+
* Client sends: { legal: { granted, text }, marketing: { granted, text } }
|
|
169
|
+
* Server writes: { legal: { status, grantedAt: {...} }, marketing: { status, grantedAt: {...}, revokedAt: {...} } }
|
|
170
|
+
*
|
|
171
|
+
* Server time (not client-supplied) is authoritative — defends against clock manipulation.
|
|
172
|
+
* IP is captured from request geolocation.
|
|
173
|
+
*
|
|
174
|
+
* Legal is REQUIRED — the client must send legal.granted=true. If missing/false we still
|
|
175
|
+
* record what the client sent, but the route will not have reached this point in practice
|
|
176
|
+
* (the signup-form HTML5-requires the legal checkbox).
|
|
177
|
+
*/
|
|
178
|
+
function buildConsentRecord(assistant, clientConsent) {
|
|
179
|
+
const consent = clientConsent || {};
|
|
180
|
+
const ip = assistant.request.geolocation?.ip || null;
|
|
181
|
+
const timestamp = assistant.meta.startTime.timestamp;
|
|
182
|
+
const timestampUNIX = assistant.meta.startTime.timestampUNIX;
|
|
183
|
+
|
|
184
|
+
// Build empty leaf shape — used wherever grantedAt or revokedAt is "not set"
|
|
185
|
+
const emptyMeta = { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null };
|
|
186
|
+
|
|
187
|
+
// --- Legal ---
|
|
188
|
+
const legalGranted = consent.legal?.granted === true;
|
|
189
|
+
const legalText = typeof consent.legal?.text === 'string' ? consent.legal.text : null;
|
|
190
|
+
|
|
191
|
+
const legal = legalGranted
|
|
192
|
+
? {
|
|
193
|
+
status: 'granted',
|
|
194
|
+
grantedAt: { timestamp, timestampUNIX, source: 'signup', ip, text: legalText },
|
|
195
|
+
}
|
|
196
|
+
: {
|
|
197
|
+
status: 'revoked',
|
|
198
|
+
grantedAt: { ...emptyMeta },
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// --- Marketing ---
|
|
202
|
+
const marketingGranted = consent.marketing?.granted === true;
|
|
203
|
+
const marketingText = typeof consent.marketing?.text === 'string' ? consent.marketing.text : null;
|
|
204
|
+
|
|
205
|
+
const marketing = marketingGranted
|
|
206
|
+
? {
|
|
207
|
+
status: 'granted',
|
|
208
|
+
grantedAt: { timestamp, timestampUNIX, source: 'signup', ip, text: marketingText },
|
|
209
|
+
revokedAt: { ...emptyMeta },
|
|
210
|
+
}
|
|
211
|
+
: {
|
|
212
|
+
status: 'revoked',
|
|
213
|
+
grantedAt: { ...emptyMeta },
|
|
214
|
+
// Record the decline with source=signup-form-declined. text=null (no message shown).
|
|
215
|
+
revokedAt: { timestamp, timestampUNIX, source: 'signup', ip, text: null },
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
assistant.log(`buildConsentRecord: legal=${legal.status}, marketing=${marketing.status} (raw input legal.granted=${consent.legal?.granted}, marketing.granted=${consent.marketing?.granted})`);
|
|
219
|
+
|
|
220
|
+
return { legal, marketing };
|
|
221
|
+
}
|
|
222
|
+
|
|
159
223
|
/**
|
|
160
224
|
* Infer name/company from email using AI (or regex fallback)
|
|
161
225
|
* Returns the inferred contact info, or null on failure
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Schema for POST /marketing/email-preferences
|
|
3
|
+
*
|
|
4
|
+
* Two supported modes (route decides based on user.authenticated):
|
|
5
|
+
* - Authenticated (account page toggle): action ('subscribe' | 'unsubscribe'). Other fields ignored.
|
|
6
|
+
* - Anonymous (HMAC link from email footer): email + asmId + sig + action ('subscribe' | 'unsubscribe').
|
|
3
7
|
*/
|
|
4
8
|
module.exports = () => ({
|
|
5
|
-
email: { types: ['string'], default: undefined, required:
|
|
6
|
-
asmId: { types: ['string', 'number'], default: undefined, required:
|
|
7
|
-
action: { types: ['string'], default: 'unsubscribe' },
|
|
8
|
-
sig: { types: ['string'], default: undefined, required:
|
|
9
|
+
email: { types: ['string'], default: undefined, required: false },
|
|
10
|
+
asmId: { types: ['string', 'number'], default: undefined, required: false },
|
|
11
|
+
action: { types: ['string'], default: 'unsubscribe', required: true },
|
|
12
|
+
sig: { types: ['string'], default: undefined, required: false },
|
|
9
13
|
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema: POST /marketing/webhook/forward
|
|
3
|
+
*
|
|
4
|
+
* Empty by design — the body is the raw provider webhook payload that gets
|
|
5
|
+
* forwarded to every child BEM unchanged. Validation happens in the dispatcher
|
|
6
|
+
* (provider + key query params) and at each child's receiver.
|
|
7
|
+
*/
|
|
8
|
+
module.exports = () => ({});
|