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,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
|
|
|
@@ -1,5 +1,22 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { readTestMode, applyEnvFromFile } = require('../../../../test/utils/test-mode-file.js');
|
|
3
|
+
|
|
1
4
|
module.exports = async ({ assistant, Manager }) => {
|
|
2
5
|
|
|
6
|
+
// Belt-and-suspenders freshness check: re-read the test-mode file before
|
|
7
|
+
// reporting `testExtendedMode`. fs.watch installed in Manager.init usually
|
|
8
|
+
// catches changes within ~50ms, but this handler hits the disk directly to
|
|
9
|
+
// guarantee the runner sees the actual current value even if the watcher
|
|
10
|
+
// missed an event. ~1ms cost on a debug endpoint.
|
|
11
|
+
try {
|
|
12
|
+
const projectDir = path.dirname(Manager.cwd);
|
|
13
|
+
const data = readTestMode(projectDir);
|
|
14
|
+
applyEnvFromFile(data);
|
|
15
|
+
} catch (e) {
|
|
16
|
+
// Non-fatal — if the file can't be read, fall through to whatever
|
|
17
|
+
// process.env already has.
|
|
18
|
+
}
|
|
19
|
+
|
|
3
20
|
const response = {
|
|
4
21
|
status: 'healthy',
|
|
5
22
|
timestamp: new Date().toISOString(),
|
|
@@ -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 = () => ({});
|
package/src/test/run-tests.js
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* It reads configuration from BEM_TEST_CONFIG environment variable and runs the test suite
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
// Mark this process as the test runner BEFORE loading any BEM code. Manager.init()
|
|
10
|
+
// auto-detects this and skips Firebase Functions / server / Sentry wiring (which
|
|
11
|
+
// can't run outside a real Functions runtime). This is what lets tests receive a
|
|
12
|
+
// fully-wired Manager + assistant in their context — no per-test stub.
|
|
13
|
+
process.env.BEM_TEST_RUNNER = '1';
|
|
14
|
+
|
|
15
|
+
const path = require('path');
|
|
9
16
|
const TestRunner = require('./runner.js');
|
|
10
17
|
|
|
11
18
|
async function main() {
|
|
@@ -33,10 +40,33 @@ async function main() {
|
|
|
33
40
|
console.error('Warning: Could not initialize Firebase Admin:', error.message);
|
|
34
41
|
}
|
|
35
42
|
|
|
43
|
+
// Boot a real Manager. With BEM_TEST_RUNNER set, init() loads libraries +
|
|
44
|
+
// resolves project config but skips the parts that need a Functions runtime
|
|
45
|
+
// (handler wiring, server boot, Sentry, admin.initializeApp re-init).
|
|
46
|
+
// The resulting Manager + assistant are passed into every test context, so
|
|
47
|
+
// tests can call Manager.AI(), Manager.Email(), Manager.User(), etc. exactly
|
|
48
|
+
// like production code does — no hand-rolled stubs.
|
|
49
|
+
let Manager = null;
|
|
50
|
+
let assistant = null;
|
|
51
|
+
try {
|
|
52
|
+
const projectDir = testConfig.projectDir || process.cwd();
|
|
53
|
+
const BackendManager = require('../manager/index.js');
|
|
54
|
+
Manager = new BackendManager();
|
|
55
|
+
Manager.init(null, {
|
|
56
|
+
cwd: path.join(projectDir, 'functions'),
|
|
57
|
+
log: false,
|
|
58
|
+
});
|
|
59
|
+
assistant = Manager.Assistant({}, { functionName: 'bem-test-runner', accept: 'json' });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error('Warning: Could not initialize BEM Manager for tests:', error.message);
|
|
62
|
+
}
|
|
63
|
+
|
|
36
64
|
// Create and run the test runner
|
|
37
65
|
const runner = new TestRunner({
|
|
38
66
|
...testConfig,
|
|
39
67
|
admin,
|
|
68
|
+
Manager,
|
|
69
|
+
assistant,
|
|
40
70
|
});
|
|
41
71
|
|
|
42
72
|
const results = await runner.run();
|
package/src/test/runner.js
CHANGED
|
@@ -87,7 +87,7 @@ class TestRunner {
|
|
|
87
87
|
// Health check (use basic http client without accounts)
|
|
88
88
|
// Use hosting URL for all requests (rewrites to bm_api function)
|
|
89
89
|
const healthHttp = new HttpClient({
|
|
90
|
-
|
|
90
|
+
apiUrl: this.options.apiUrl,
|
|
91
91
|
timeout: this.options.timeout,
|
|
92
92
|
});
|
|
93
93
|
|
|
@@ -119,23 +119,30 @@ class TestRunner {
|
|
|
119
119
|
await this.runTestsInDir(projectTestsDir, 'project');
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Post-run cleanup: scrub test accounts from third-party marketing providers
|
|
123
|
+
// (SendGrid/Beehiiv) so each test run leaves the contact list in the same
|
|
124
|
+
// state it found it. Pairs with the pre-run cleanup as defense in depth —
|
|
125
|
+
// pre-run handles crashed previous runs, post-run handles the current run.
|
|
126
|
+
// Only fires in extended mode (normal mode never touches real providers).
|
|
127
|
+
if (process.env.TEST_EXTENDED_MODE) {
|
|
128
|
+
process.stdout.write(chalk.gray('\n Cleaning up test accounts from marketing providers... '));
|
|
129
|
+
try {
|
|
130
|
+
const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
|
|
131
|
+
apiUrl: this.options.apiUrl,
|
|
132
|
+
backendManagerKey: this.options.backendManagerKey,
|
|
133
|
+
});
|
|
134
|
+
console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
|
|
135
|
+
} catch (e) {
|
|
136
|
+
// Post-run cleanup is best-effort — failures shouldn't change the test result
|
|
137
|
+
console.log(chalk.yellow(`⚠ cleanup error: ${e.message}`));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
122
141
|
// Cleanup rules context
|
|
123
142
|
if (this.rulesContext) {
|
|
124
143
|
await this.rulesContext.cleanup();
|
|
125
144
|
}
|
|
126
145
|
|
|
127
|
-
// Clean up test accounts from marketing providers (SendGrid/Beehiiv)
|
|
128
|
-
// Run at end of tests so auth:on-create has time to complete
|
|
129
|
-
if (process.env.TEST_EXTENDED_MODE) {
|
|
130
|
-
console.log('');
|
|
131
|
-
process.stdout.write(chalk.gray(' Cleaning test accounts from marketing providers... '));
|
|
132
|
-
const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
|
|
133
|
-
apiUrl: this.options.apiUrl,
|
|
134
|
-
backendManagerKey: this.options.backendManagerKey,
|
|
135
|
-
});
|
|
136
|
-
console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
146
|
// Report results
|
|
140
147
|
this.reportResults();
|
|
141
148
|
|
|
@@ -147,9 +154,9 @@ class TestRunner {
|
|
|
147
154
|
* Validate configuration
|
|
148
155
|
*/
|
|
149
156
|
validateConfig() {
|
|
150
|
-
if (!this.options.
|
|
151
|
-
console.log(chalk.red(' ✗ Missing
|
|
152
|
-
console.log(chalk.gray(' Set
|
|
157
|
+
if (!this.options.apiUrl) {
|
|
158
|
+
console.log(chalk.red(' ✗ Missing apiUrl'));
|
|
159
|
+
console.log(chalk.gray(' Set BEM_API_URL environment variable or pass --url flag'));
|
|
153
160
|
return false;
|
|
154
161
|
}
|
|
155
162
|
|
|
@@ -186,22 +193,20 @@ class TestRunner {
|
|
|
186
193
|
if (response.success) {
|
|
187
194
|
console.log(chalk.green('✓'));
|
|
188
195
|
|
|
189
|
-
//
|
|
190
|
-
|
|
196
|
+
// Report the live mode the emulator just confirmed. The test command
|
|
197
|
+
// writes `.temp/test-mode.json` before invoking us; the emulator's
|
|
198
|
+
// file-watcher mutates its `process.env.TEST_EXTENDED_MODE` to match;
|
|
199
|
+
// the health endpoint re-reads the file as a freshness guard. By
|
|
200
|
+
// construction these are equal — no mismatch warning needed.
|
|
191
201
|
const emulatorExtended = !!response.data?.testExtendedMode;
|
|
192
|
-
|
|
193
|
-
if (runnerExtended !== emulatorExtended) {
|
|
194
|
-
console.log(chalk.red.bold(`\n ⚠️⚠️⚠️ TEST_EXTENDED_MODE mismatch (runner=${runnerExtended}, emulator=${emulatorExtended}) ⚠️⚠️⚠️`));
|
|
195
|
-
console.log(chalk.red(' Both must match or tests will behave unexpectedly.'));
|
|
196
|
-
console.log(chalk.red(` Restart with: ${runnerExtended ? '' : 'TEST_EXTENDED_MODE=true '}npx bm emulator\n`));
|
|
197
|
-
}
|
|
202
|
+
console.log(chalk.gray(` Mode: ${emulatorExtended ? 'EXTENDED (real APIs)' : 'normal (mocked)'}`));
|
|
198
203
|
|
|
199
204
|
return true;
|
|
200
205
|
}
|
|
201
206
|
|
|
202
207
|
console.log(chalk.red('✗'));
|
|
203
208
|
console.log(chalk.red(` Server not responding: ${response.error}`));
|
|
204
|
-
console.log(chalk.gray(` Make sure your functions are deployed and running at ${this.options.
|
|
209
|
+
console.log(chalk.gray(` Make sure your functions are deployed and running at ${this.options.apiUrl}`));
|
|
205
210
|
return false;
|
|
206
211
|
} catch (error) {
|
|
207
212
|
console.log(chalk.red('✗'));
|
|
@@ -224,6 +229,18 @@ class TestRunner {
|
|
|
224
229
|
const deleteResult = await testAccounts.deleteTestUsers(this.options.admin);
|
|
225
230
|
console.log(chalk.green(`✓ (${deleteResult.deleted} deleted, ${deleteResult.skipped} skipped)`));
|
|
226
231
|
|
|
232
|
+
// Clean any leftover test accounts from third-party marketing providers
|
|
233
|
+
// (SendGrid/Beehiiv). Runs BEFORE we create fresh users so a previously
|
|
234
|
+
// killed run doesn't leave the contact list polluted.
|
|
235
|
+
if (process.env.TEST_EXTENDED_MODE) {
|
|
236
|
+
process.stdout.write(chalk.gray(' Cleaning test accounts from marketing providers... '));
|
|
237
|
+
const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
|
|
238
|
+
apiUrl: this.options.apiUrl,
|
|
239
|
+
backendManagerKey: this.options.backendManagerKey,
|
|
240
|
+
});
|
|
241
|
+
console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
|
|
242
|
+
}
|
|
243
|
+
|
|
227
244
|
process.stdout.write(chalk.gray(' Creating test accounts... '));
|
|
228
245
|
|
|
229
246
|
// Create fresh test accounts
|
|
@@ -534,6 +551,20 @@ class TestRunner {
|
|
|
534
551
|
duration,
|
|
535
552
|
suite: suiteDescription,
|
|
536
553
|
});
|
|
554
|
+
|
|
555
|
+
// For suites (sequential, state-dependent tests), a skip on any step means
|
|
556
|
+
// subsequent steps can't run cleanly — propagate skip to the rest of the suite.
|
|
557
|
+
// Groups (independent tests) continue normally.
|
|
558
|
+
const shouldStopOnSkip = suite.type !== 'group' && suite.stopOnFailure !== false;
|
|
559
|
+
if (shouldStopOnSkip) {
|
|
560
|
+
const remaining = tests.length - i - 1;
|
|
561
|
+
if (remaining > 0) {
|
|
562
|
+
console.log(chalk.yellow(` Skipping ${remaining} remaining test(s) in suite (suite-level skip)`));
|
|
563
|
+
this.results.skipped += remaining;
|
|
564
|
+
}
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
|
|
537
568
|
continue;
|
|
538
569
|
}
|
|
539
570
|
|
|
@@ -657,7 +688,7 @@ class TestRunner {
|
|
|
657
688
|
// Create HTTP client with accounts for as() method
|
|
658
689
|
// Use hosting URL for all requests (rewrites to bm_api function)
|
|
659
690
|
const http = new HttpClient({
|
|
660
|
-
|
|
691
|
+
apiUrl: this.options.apiUrl,
|
|
661
692
|
timeout: this.options.timeout,
|
|
662
693
|
accounts: this.accounts,
|
|
663
694
|
backendManagerKey: this.options.backendManagerKey,
|
|
@@ -696,6 +727,15 @@ class TestRunner {
|
|
|
696
727
|
throw new SkipError(reason);
|
|
697
728
|
};
|
|
698
729
|
|
|
730
|
+
// Precomputed map of BEM product id → Stripe-compatible product ID for tests.
|
|
731
|
+
// Falls back to the "_test_<id>" sentinel when no real Stripe product is configured,
|
|
732
|
+
// letting the Stripe resolver match it back to the BEM product. SSOT for tests that
|
|
733
|
+
// need to construct Stripe-shaped webhook payloads (cancel, refund, plan-change, etc.).
|
|
734
|
+
const products = this.config.payment?.products || [];
|
|
735
|
+
const stripeProductIds = Object.fromEntries(
|
|
736
|
+
products.map((p) => [p.id, p.stripe?.productId || `_test_${p.id}`])
|
|
737
|
+
);
|
|
738
|
+
|
|
699
739
|
return {
|
|
700
740
|
http,
|
|
701
741
|
accounts: this.accounts,
|
|
@@ -706,8 +746,14 @@ class TestRunner {
|
|
|
706
746
|
pubsub,
|
|
707
747
|
skip,
|
|
708
748
|
admin: this.config.admin,
|
|
749
|
+
// Real BEM Manager + assistant, booted by run-tests.js with BEM_TEST_RUNNER=1.
|
|
750
|
+
// Tests can call Manager.AI(), Manager.Email(), Manager.User(), etc. exactly
|
|
751
|
+
// like production code — no stubs.
|
|
752
|
+
Manager: this.config.Manager,
|
|
753
|
+
assistant: this.config.assistant,
|
|
709
754
|
rules: this.rulesContext,
|
|
710
755
|
config: this.config,
|
|
756
|
+
payments: { stripeProductIds },
|
|
711
757
|
};
|
|
712
758
|
}
|
|
713
759
|
|