feat(analytics): report consenting customers' activity to Brevo (#56) #317

Merged
bermudalamb merged 4 commits from feature/56-brevo-tracker into main 2026-09-08 12:41:51 -05:00
16 changed files with 439 additions and 8 deletions
Showing only changes of commit ac3f6e91f5 - Show all commits
+10 -1
View File
@@ -60,7 +60,16 @@ app.get('/api/config', (_req, res) => {
//
// Trailing slash trimmed so callers can join with a stored path, which
// always begins with one, without producing a double.
uploadsBaseUrl: trimTrailingSlashes(process.env.UPLOADS_BASE_URL ?? '')
uploadsBaseUrl: trimTrailingSlashes(process.env.UPLOADS_BASE_URL ?? ''),
// Brevo's Marketing Automation key (#56). Not a secret — it ships to the
// browser by design — but it differs per environment, which is the whole
// reason it is here rather than built in.
//
// Null when unset, and the tracker never loads without it. That is what
// keeps QA out of production's Brevo account: QA sets no key, so no QA
// browsing is ever reported, and there is no flag anyone can forget to
// turn off. Same shape as paypalClientId above.
brevoTrackerKey: process.env.BREVO_TRACKER_KEY?.trim() || null
});
});
+36 -1
View File
@@ -176,7 +176,39 @@ interface CustomerOrderRow {
item_name: string;
}
function publicCustomer(c: CustomerRow) {
/**
* Whether this customer has agreed to the *current* consent wording, which is
* the only thing that authorises the Brevo tracker (#56).
*
* Not the same question as `marketing_consent`. That flag says the customer
* agreed to something; `marketing_consent_text` says what. The sentence was
* widened to cover analytics, so a customer who consented to the older wording
* agreed to emails and nothing more — they keep receiving email and are not
* tracked until they re-consent to the current text through the account page.
*
* Comparing the stored string is the point rather than an implementation
* detail: it is why the wording is recorded per customer at all. A boolean on
* its own could not tell these two populations apart, and assuming they are the
* same is exactly the retroactive widening this avoids.
*
* Computed here rather than stored, so it can never drift from the constant.
*
* Exported for the unit test, and narrowed to the two fields it actually reads
* rather than taking a whole CustomerRecord — the rule is about those two and
* nothing else, and a test should not have to invent a customer to state it.
*/
export function analyticsConsent(
c: Pick<CustomerRecord, 'marketing_consent' | 'marketing_consent_text'>
): boolean {
return c.marketing_consent && c.marketing_consent_text === MARKETING_CONSENT_TEXT;
}
/**
* Takes a CustomerRecord rather than a CustomerRow because `analytics_consent`
* is derived from `marketing_consent_text`, which is not on the narrower type.
* Every caller already holds a full record — each query is `SELECT *`.
*/
function publicCustomer(c: CustomerRecord) {
return {
id: c.id,
email: c.email,
@@ -184,6 +216,9 @@ function publicCustomer(c: CustomerRow) {
last_name: c.last_name,
email_verified: c.email_verified,
marketing_consent: c.marketing_consent,
// Deliberately separate from marketing_consent: the two disagree for every
// customer who consented before the wording was widened.
analytics_consent: analyticsConsent(c),
favorite_alerts: c.favorite_alerts,
created_at: c.created_at
};
+16 -1
View File
@@ -71,8 +71,23 @@ export function tagColorFor(name: string): string {
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length] ?? TAG_COLORS[0];
}
/**
* Widened for #56 to cover analytics as well as email.
*
* The previous wording named only emails. Gating the Brevo tracker on
* `marketing_consent` while that sentence was the thing customers agreed to
* would have treated "email me about new items" as authorisation to send their
* browsing to a third party, which it plainly did not say.
*
* Changing the sentence does not retroactively widen anyone's consent, because
* `marketing_consent_text` records what each customer was actually shown.
* Everyone who agreed to the old wording keeps their email consent and is not
* tracked; see `analyticsConsent` in routes/customers.ts, which is what the
* tracker is gated on. That is the whole reason this string is stored per
* customer rather than assumed.
*/
export const MARKETING_CONSENT_TEXT =
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs, and I agree that what I browse and buy here may be shared with our email provider to help choose what they contain. I can unsubscribe at any time.';
/**
* Strips trailing slashes so a base URL can be joined with a stored path.
@@ -0,0 +1,63 @@
import { analyticsConsent } from '../../src/routes/customers';
import { MARKETING_CONSENT_TEXT } from '../../src/utils';
/**
* The rule this file exists for: agreeing to the *old* consent wording does not
* authorise the Brevo tracker (#56).
*
* The sentence customers agree to was widened to mention analytics. Everyone
* who consented before that agreed to a sentence about email and nothing else,
* and `marketing_consent` alone cannot tell the two populations apart — which
* is exactly why the wording is stored per customer. Getting this wrong would
* silently track people who never agreed to it, and would do so invisibly,
* because the flag they set really is `true`.
*/
describe('analyticsConsent', () => {
const OLD_WORDING =
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
it('is true for a customer who agreed to the current wording', () => {
expect(
analyticsConsent({ marketing_consent: true, marketing_consent_text: MARKETING_CONSENT_TEXT })
).toBe(true);
});
it('is false for a customer who agreed to the previous, email-only wording', () => {
// The case the whole mechanism exists for. They consented, and their
// consent does not cover this.
expect(
analyticsConsent({ marketing_consent: true, marketing_consent_text: OLD_WORDING })
).toBe(false);
});
it('is false when consent was never given, whatever text is stored', () => {
expect(
analyticsConsent({ marketing_consent: false, marketing_consent_text: MARKETING_CONSENT_TEXT })
).toBe(false);
});
it('is false when no wording was recorded at all', () => {
// Withdrawal writes a sentinel rather than the consent text, and older rows
// may predate the column being populated. Neither is agreement.
expect(
analyticsConsent({ marketing_consent: true, marketing_consent_text: null })
).toBe(false);
});
it('does not accept a near-miss, so a reworded sentence re-asks rather than assumes', () => {
expect(
analyticsConsent({
marketing_consent: true,
marketing_consent_text: `${MARKETING_CONSENT_TEXT} `
})
).toBe(false);
});
it('guards the wording itself: the current text must mention what is shared', () => {
// Not a tautology — it fails if someone narrows the sentence back to email
// while leaving the tracker gated on it, which would put this project back
// in the position #56 was filed to get it out of.
expect(MARKETING_CONSENT_TEXT).toContain('browse');
expect(MARKETING_CONSENT_TEXT).not.toBe(OLD_WORDING);
});
});
+11
View File
@@ -244,6 +244,17 @@ services:
# See docs/ops/image-background-removal-stack.md.
- REMBG_URL=${REMBG_URL:-}
# Brevo's Marketing Automation key (#56), from
# https://app.brevo.com/automation/parameters. Optional: unset means the
# tracker is never loaded and no browsing is reported to anyone.
#
# Not a secret — it ships to the browser by design — but it is
# per-environment, and this is the only place production's is named. A
# key here is still not sufficient to track anybody: the script loads
# only for a signed-in customer whose stored consent wording covers
# analytics. See the note on MARKETING_CONSENT_TEXT in backend/src/utils.ts.
- BREVO_TRACKER_KEY=${BREVO_TRACKER_KEY:-}
# Signs the regenerate and discard links in the intake notification email
# (#224). Optional: absent, the notification still sends and links to the
# review queue without shortcuts. Rotating it revokes every outstanding
+14
View File
@@ -174,6 +174,20 @@ services:
# unset stack variable cannot fail a deploy.
- REMBG_URL=${QA_REMBG_URL:-}
# Deliberately empty, and deliberately not a stack variable (#56).
#
# This is what keeps QA browsing out of the live Brevo account. Written as
# an empty literal rather than left out entirely so it cannot inherit a
# value from the host environment, and with no `${...}` so there is no
# stack variable anyone could set here by pasting production's in — the
# same reasoning as QA_DB_PASSWORD and the QA_SMTP_* names above.
#
# QA runs against disposable fixtures. Reporting that browsing as though
# it were customer behaviour would corrupt the segmentation the tracker
# exists to feed, and it would be indistinguishable from real traffic
# after the fact.
- BREVO_TRACKER_KEY=
# Signs the regenerate and discard links in the intake notification email
# (#224). Optional: absent, the notification still sends and simply links
# to the review queue without shortcuts. Anyone holding a link can act on
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { fetchConfig } from './api';
import { useCustomerAuth } from './customer/CustomerAuthContext';
import { brevoPage, startBrevoTracking, stopBrevoTracking } from './brevo';
/**
* Drives the Brevo tracker from the two things that decide whether it may run
* at all: the environment's key and the signed-in customer's consent (#56).
*
* Renders nothing. It exists as a component rather than a hook because it needs
* both the router and the auth context, and mounting it inside `AppRoutes` is
* what guarantees it sits under both — a hook called from the wrong place would
* fail at runtime instead of being impossible to misplace.
*
* ## Why the gate is a server-computed field
*
* `analytics_consent` is not `marketing_consent`. The consent sentence was
* widened to mention analytics, and the server reports this flag by comparing
* the wording each customer actually agreed to against the current text. A
* customer who consented to the older, email-only wording is not tracked. Never
* substitute `marketing_consent` here — that is the exact retroactive widening
* the field exists to prevent.
*
* A signed-out visitor has no consent record, so no key is ever used and the
* script is never injected. Anonymous browsing is not reported at all.
*/
export default function BrevoTracking() {
const { customer } = useCustomerAuth();
const location = useLocation();
const [trackerKey, setTrackerKey] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
// Swallowed for the same reason main.tsx swallows its warm-up call: a
// config that cannot be fetched is a broken deployment every other request
// will report, and losing analytics is not worth surfacing to a customer.
void fetchConfig()
.then(config => { if (!cancelled) setTrackerKey(config.brevoTrackerKey); })
.catch(() => {});
return () => { cancelled = true; };
}, []);
const consented = customer?.analytics_consent === true;
const email = customer?.email;
// Start and stop are driven by the same effect so there is no state in which
// consent has gone away and nothing has acted on it. Withdrawing consent,
// signing out, and deleting the account all arrive here as `consented`
// turning false, because each one ends with the customer no longer being a
// consenting signed-in customer.
useEffect(() => {
if (consented && trackerKey && email) {
startBrevoTracking(trackerKey, email);
} else {
stopBrevoTracking();
}
}, [consented, trackerKey, email]);
// Declared after the effect above so that on the render where consent or the
// key first arrives, tracking is started before this reports the route.
//
// The storefront is a single-page app: without this, the tracker's own
// initial call would be the only page view it ever saw, and every customer
// would look like they viewed one page and left.
useEffect(() => {
brevoPage(location.pathname);
}, [location.pathname, consented, trackerKey]);
return null;
}
+6
View File
@@ -55,6 +55,12 @@ export interface SiteConfig {
currency: string;
/** Origin for uploaded images. Empty means the app's own — see uploadUrl. */
uploadsBaseUrl: string;
/**
* Brevo Marketing Automation key (#56). Null when the environment sets none,
* which is how QA avoids reporting test browsing into the live Brevo account.
* A key alone does not start tracking — see brevo.ts.
*/
brevoTrackerKey: string | null;
}
export async function fetchConfig(): Promise<SiteConfig> {
+135
View File
@@ -0,0 +1,135 @@
/**
* Brevo's web tracker (#56).
*
* Same shape as paypal.ts: the script is injected at runtime, so there is no
* package to import types from and this file declares the sliver actually used.
* Deliberately narrow — it describes what is called here, not the whole SDK.
*
* Two rules hold everywhere in this module, and every exported function is
* written so that breaking either is impossible rather than merely discouraged:
*
* 1. **Nothing loads without a key.** No key means no script, no cookie, no
* request. That is what keeps QA out of the production Brevo account.
* 2. **Nothing loads without consent.** `start` is the only thing that injects
* the script, and its caller gates on the customer's `analytics_consent`,
* which the server derives from the wording that customer actually agreed
* to. A signed-out visitor is never tracked, because there is no consent
* record to consult.
*/
interface SendinblueSdk {
page: (name?: string, properties?: Record<string, unknown>) => void;
identify: (email: string, attributes?: Record<string, unknown>) => void;
track: (event: string, properties?: Record<string, unknown>) => void;
}
/** The queue the snippet installs so calls made before load are not lost. */
interface SibQueue {
equeue: unknown[];
client_key?: string;
[method: string]: unknown;
}
declare global {
interface Window {
sendinblue?: SendinblueSdk;
sib?: SibQueue;
}
}
/**
* Gates every call below. False until `start`, and false again after `stop`.
*
* Kept separate from `window.sendinblue` being present because the two answer
* different questions: the SDK stays in the document once injected, but consent
* can be withdrawn within the same page. Checking only for the SDK would keep
* reporting after sign-out.
*/
let enabled = false;
let loaded = false;
/**
* Installs the method queue and injects the script.
*
* The queue matters: `sa.js` loads asynchronously and the methods have to exist
* before it arrives, or the `page()` for the landing route — the one call that
* always happens immediately — is dropped. This is Brevo's own snippet, written
* out as typed code rather than pasted as an opaque blob.
*/
function inject(key: string): void {
if (loaded) return;
loaded = true;
const queue: SibQueue = { equeue: [], client_key: key };
window.sib = queue;
const sdk = {} as SendinblueSdk;
const methods = ['track', 'identify', 'page'] as const;
for (const method of methods) {
sdk[method] = (...args: unknown[]) => {
const ready = queue[method];
if (typeof ready === 'function') {
(ready as (...a: unknown[]) => void)(...args);
} else {
queue.equeue.push({ [method]: args });
}
};
}
window.sendinblue = sdk;
const script = document.createElement('script');
script.id = 'sendinblue-js';
script.async = true;
script.src = `https://sibautomation.com/sa.js?key=${encodeURIComponent(key)}`;
// A tracker that cannot load must never take the page down with it. There is
// no retry and no error surfaced: losing analytics is not worth telling a
// customer about, and a visible failure here would be noise on every visit
// from anyone running a blocker.
script.onerror = () => { enabled = false; };
document.head.appendChild(script);
}
/**
* Begins tracking for a consenting, signed-in customer.
*
* Safe to call repeatedly — React effects will. The script is injected once;
* later calls only re-assert identity, which is what a customer switching
* accounts in one session needs.
*/
export function startBrevoTracking(key: string | null, email: string): void {
if (!key) return;
inject(key);
enabled = true;
window.sendinblue?.identify(email);
}
/**
* Stops tracking, on sign-out or when consent is withdrawn.
*
* **This stops calls, it does not unload the script.** Nothing can un-inject a
* script tag or take back the cookies it set, so `sa.js` stays in the document
* until the next full page load. What this guarantees is that no further page
* view or event is reported, and no identity is re-asserted, which is the part
* this application actually controls. Said plainly here because "tracking
* stops" is easy to read as a stronger promise than any web tracker can make.
*/
export function stopBrevoTracking(): void {
enabled = false;
}
/** A route change. No-op unless tracking is currently permitted. */
export function brevoPage(path: string): void {
if (!enabled) return;
window.sendinblue?.page(path);
}
/** A named event. No-op unless tracking is currently permitted. */
export function brevoTrack(event: string, properties?: Record<string, unknown>): void {
if (!enabled) return;
window.sendinblue?.track(event, properties);
}
/** Exported for tests, which need to observe the gate without a real script. */
export function isBrevoTrackingEnabled(): boolean {
return enabled;
}
+26 -3
View File
@@ -1,3 +1,10 @@
// Reported here rather than at the UI call sites so that no caller can add an
// item or complete a checkout without it being counted (#56). Every one of
// these calls is a no-op unless a consenting customer is signed in and the
// environment has a key, so importing this into the API layer does not make the
// cart depend on analytics being configured.
import { brevoTrack } from '../brevo';
export interface CartItem {
item_id: number;
name: string;
@@ -34,7 +41,14 @@ export function fetchCart(): Promise<{ items: CartItem[] }> {
}
export function addToCart(itemId: number): Promise<{ itemId: number; expiresAt: string }> {
return fetch(`/api/cart/items/${itemId}`, { method: 'POST' }).then(res => handle(res));
// After handle(), which throws on a non-OK response — so a refused add (the
// item was already reserved by someone else) is not reported as one.
return fetch(`/api/cart/items/${itemId}`, { method: 'POST' })
.then(res => handle<{ itemId: number; expiresAt: string }>(res))
.then(result => {
brevoTrack('added_to_cart', { itemId });
return result;
});
}
export function removeFromCart(itemId: number): Promise<void> {
@@ -80,12 +94,19 @@ export function createCartPaypalOrder(shippingAddressId: number): Promise<{ orde
}).then(res => handle(res));
}
// The two checkout completions are separate functions rather than one, so both
// report the event and both say which they were. Without the processor these
// would be indistinguishable in Brevo, and a demo purchase charges nothing —
// counting it as a sale would overstate revenue. QA cannot reach here anyway
// (no key), but DEMO_MODE is not exclusive to QA, so the distinction is real.
export function captureCartPaypalOrder(orderID: string): Promise<void> {
return fetch('/api/checkout/cart/paypal/capture', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderID })
}).then(res => handle(res));
})
.then(res => handle<void>(res))
.then(() => { brevoTrack('checkout_completed', { processor: 'paypal' }); });
}
export function demoCartPurchase(shippingAddressId: number): Promise<void> {
@@ -93,5 +114,7 @@ export function demoCartPurchase(shippingAddressId: number): Promise<void> {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shippingAddressId })
}).then(res => handle(res));
})
.then(res => handle<void>(res))
.then(() => { brevoTrack('checkout_completed', { processor: 'demo' }); });
}
+6 -1
View File
@@ -20,8 +20,13 @@ export type AuthMode = 'register' | 'login';
// this was shared there were three wordings in play — this one, a shorter one in
// the cart prompt, and the string the server actually recorded — and none of
// them matched.
//
// Widened for #56 so it covers the Brevo tracker as well as email. The tracker
// is gated on whether a customer's *stored* copy of this sentence matches the
// current one, so changing it here does not retroactively widen the consent of
// anyone who agreed to the previous wording.
export const MARKETING_CONSENT_TEXT =
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs, and I agree that what I browse and buy here may be shared with our email provider to help choose what they contain. I can unsubscribe at any time.';
type Props = Readonly<{
mode: AuthMode;
+16
View File
@@ -28,6 +28,22 @@ export default function PrivacyPolicy() {
link included in every marketing email no login required.
</Paragraph>
<Title level={4}>Analytics and tracking</Title>
<Paragraph>
If and only if you have opted in to marketing, we share what you browse and buy on
this site with Brevo, our email provider, so the emails we send you are about things you
are actually interested in. That covers the pages you visit here, items you add to your
cart or favorite, and completed orders, linked to your email address.
</Paragraph>
<Paragraph>
If you have not opted in, this does not happen: nothing is loaded, nothing is sent, and no
tracking cookie is set. The same is true if you are not signed in we do not track
visitors who do not have an account. Withdrawing consent from your account page stops any
further activity being sent. Note that anything already shared with Brevo before you
withdrew remains with them, and a tracking cookie set earlier in your visit stays in your
browser until you close the tab or clear it.
</Paragraph>
<Title level={4}>Your rights</Title>
<Paragraph>
You may request a copy of your data ("Download my data" in your account page), or delete your
+10
View File
@@ -7,6 +7,16 @@ export interface Customer {
last_name: string | null;
email_verified: boolean;
marketing_consent: boolean;
/**
* Whether this customer agreed to the *current* consent wording, which is the
* only thing that authorises the Brevo tracker (#56).
*
* Not a duplicate of marketing_consent: the two disagree for anyone who
* consented before that sentence was widened to mention analytics. Computed
* on the server from the wording stored against the customer — never derive
* it here from marketing_consent, which is the mistake it exists to prevent.
*/
analytics_consent: boolean;
favorite_alerts: boolean;
created_at: string;
}
+8
View File
@@ -1,4 +1,7 @@
import { Customer } from './customerApi';
// See the note in cart/cartApi.ts: reported here rather than at the call sites
// so no caller can favorite an item without it being counted (#56).
import { brevoTrack } from '../brevo';
export interface Favorite {
item_id: number;
@@ -26,6 +29,11 @@ export async function addFavorite(itemId: number): Promise<void> {
await fetch(`/api/customers/me/favorites/${itemId}`, { method: 'POST' }),
'failed to save favorite'
);
// After expectOk, which throws on failure, so a favorite that was not saved
// is not reported as one. Only favoriting is tracked, not un-favoriting:
// #56 asked for the act of favoriting as a signal of interest, and removal is
// a different question nobody has asked yet.
brevoTrack('favorited', { itemId });
}
export async function removeFavorite(itemId: number): Promise<void> {
+5
View File
@@ -27,6 +27,7 @@ import { FavoritesProvider } from './customer/FavoritesContext';
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
import './styles.css';
import { fetchConfig } from './api';
import BrevoTracking from './BrevoTracking';
// The brand accent is monochrome, so it inverts between themes rather than
// switching to a different hue.
@@ -163,6 +164,10 @@ function AppRoutes() {
return (
<>
{/* Renders nothing. Mounted here because this is the innermost place
that has both the router and the auth context, which is what it
needs to report route changes for a consenting customer (#56). */}
<BrevoTracking />
{import.meta.env.DEV && <DevThrow scope="page" />}
<Routes location={backdrop}>
<Route path="/" element={<App />} />
+6 -1
View File
@@ -27,8 +27,13 @@ test.describe('Customer accounts', () => {
// customer actually saw. Three different wordings were in circulation
// before the sign-in form was shared between the routes and the cart
// prompt, and none of them matched what was stored.
// Widened in #56 to cover the Brevo tracker as well as email. The literal is
// repeated here rather than imported on purpose: importing it from the app
// would make this assert that a constant equals itself, and the drift it
// guards against is exactly the rendered label parting from the stored
// string.
const consent =
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs, and I agree that what I browse and buy here may be shared with our email provider to help choose what they contain. I can unsubscribe at any time.';
await expect(authModal.registerDialog).toContainText(consent);
});