fix(privacy): separate analytics consent from email consent (#56)
The previous commit widened the marketing consent sentence to cover the Brevo tracker, so one checkbox carried both purposes. That is the specific pattern GDPR rejects: consent has to be granular, and current EDPB guidance treats bundling tracking consent with subscription consent as invalid because the customer cannot accept one purpose and refuse the other. Quebec's Law 25 s.8.1 is stricter again — profiling technology has to be off until the person switches it on, with no pre-ticked box and no consent inherited from agreeing to something else. Building to both standards was the decision, since the storefront is publicly reachable and anyone can register. So the marketing sentence is restored to exactly what it was, which leaves every existing email consent valid and untouched, and analytics gets its own column, its own sentence, its own checkbox at registration, its own toggle in the account page and its own endpoint. A customer can now hold either, both, or neither, and withdrawing one does not disturb the other. The migration defaults analytics_consent to false, which is both the honest answer — none of the existing customers was ever asked — and what Law 25 requires. Nothing about this change opts anybody in. Two details that are compliance requirements rather than wording preferences. The sentence names Brevo instead of saying "our email provider", because informed consent means the customer can tell who receives their data and a description they cannot act on is not disclosure. And the account toggle is as prominent and as easy to switch off as it is to switch on, because withdrawal has to be as easy as consenting. The analytics endpoint is separate from the marketing one rather than a second field on it, so that a single call cannot change an answer the customer did not touch — the bundling problem moved from the form into the API. The unit tests now assert the two consents stay apart in both directions, including that the marketing sentence still says nothing about tracking, because re-bundling them would otherwise pass silently and is the mistake this project already made once. Verified: backend tsc clean, both lint suites 0 errors with no new warnings, 478 unit tests passing across 33 suites, frontend production build green. Not verified: the migration has not been run against a database, and integration and e2e need a Node this machine does not have active. None of this is legal advice and the wording is worth a lawyer's eye before it ships. Refs #56 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ac3f6e91f5
commit
955049eac9
@@ -8,7 +8,7 @@ import { sendMail } from '../mailer';
|
||||
import { renderTemplate, greeting, formatDuration } from '../emailTemplates';
|
||||
import { getSettings } from '../adminSettings';
|
||||
import { loadStoredTemplate } from './adminEmailTemplates';
|
||||
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
||||
import { ANALYTICS_CONSENT_TEXT, MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
||||
import { ItemStatus } from '../types';
|
||||
import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
@@ -109,6 +109,12 @@ interface CustomerRecord extends CustomerRow {
|
||||
unsubscribe_token: string;
|
||||
marketing_consent_at: Date | null;
|
||||
marketing_consent_text: string | null;
|
||||
// A separate purpose from marketing, so a separate column, timestamp and
|
||||
// stored wording rather than a second meaning layered onto the pair above.
|
||||
// False for every customer the migration touched: none of them was asked.
|
||||
analytics_consent: boolean;
|
||||
analytics_consent_at: Date | null;
|
||||
analytics_consent_text: string | null;
|
||||
favorite_alerts_at: Date | null;
|
||||
favorite_alerts_text: string | null;
|
||||
}
|
||||
@@ -177,19 +183,21 @@ interface CustomerOrderRow {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this customer has agreed to the *current* consent wording, which is
|
||||
* Whether this customer has agreed to the *current* analytics 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.
|
||||
* Reads the analytics columns and nothing else. It must never consult
|
||||
* `marketing_consent`: those are two purposes with two recipients, and GDPR
|
||||
* requires consent to be granular — a customer who wants the emails and not the
|
||||
* tracking has to be able to have exactly that. Quebec's Law 25 s.8.1 is
|
||||
* stricter again and requires this to be off until the customer switches it on,
|
||||
* which is why the column defaults to false.
|
||||
*
|
||||
* 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.
|
||||
* detail. The flag says a customer agreed to something; the text says what. If
|
||||
* the sentence is ever re-worded, everyone who agreed to the previous one stops
|
||||
* qualifying and is asked again, rather than being silently carried into a
|
||||
* broader agreement they never saw.
|
||||
*
|
||||
* Computed here rather than stored, so it can never drift from the constant.
|
||||
*
|
||||
@@ -198,9 +206,9 @@ interface CustomerOrderRow {
|
||||
* 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'>
|
||||
c: Pick<CustomerRecord, 'analytics_consent' | 'analytics_consent_text'>
|
||||
): boolean {
|
||||
return c.marketing_consent && c.marketing_consent_text === MARKETING_CONSENT_TEXT;
|
||||
return c.analytics_consent && c.analytics_consent_text === ANALYTICS_CONSENT_TEXT;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,8 +224,8 @@ function publicCustomer(c: CustomerRecord) {
|
||||
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.
|
||||
// Its own purpose, its own answer. A customer can have either, both, or
|
||||
// neither, and the UI has to be able to show that honestly.
|
||||
analytics_consent: analyticsConsent(c),
|
||||
favorite_alerts: c.favorite_alerts,
|
||||
created_at: c.created_at
|
||||
@@ -225,7 +233,7 @@ function publicCustomer(c: CustomerRecord) {
|
||||
}
|
||||
|
||||
router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { email, password, firstName, lastName, marketingConsent } = req.body;
|
||||
const { email, password, firstName, lastName, marketingConsent, analyticsConsent: analyticsConsentGiven } = req.body;
|
||||
if (!email || !isValidEmail(String(email)) || !password || String(password).length < 8) {
|
||||
return res.status(400).json({ error: 'valid email and password (min 8 chars) required' });
|
||||
}
|
||||
@@ -246,13 +254,19 @@ router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
||||
const passwordHash = await bcrypt.hash(password, PASSWORD_HASH_ROUNDS);
|
||||
const unsubscribeToken = crypto.randomBytes(16).toString('hex');
|
||||
const consent = !!marketingConsent;
|
||||
// Read independently of marketingConsent, and absent means false. A client
|
||||
// that sends neither, or only the marketing one, registers a customer who is
|
||||
// not tracked — which is the right answer for a request that never carried an
|
||||
// analytics answer at all.
|
||||
const analytics = !!analyticsConsentGiven;
|
||||
|
||||
const { rows } = await pool.query<CustomerRecord>(
|
||||
`INSERT INTO customers (email, password_hash, first_name, last_name, marketing_consent, marketing_consent_at, marketing_consent_text, unsubscribe_token)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
`INSERT INTO customers (email, password_hash, first_name, last_name, marketing_consent, marketing_consent_at, marketing_consent_text, analytics_consent, analytics_consent_at, analytics_consent_text, unsubscribe_token)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||
[
|
||||
normalizedEmail, passwordHash, first, last,
|
||||
consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null,
|
||||
analytics, analytics ? new Date() : null, analytics ? ANALYTICS_CONSENT_TEXT : null,
|
||||
unsubscribeToken
|
||||
]
|
||||
);
|
||||
@@ -593,6 +607,28 @@ router.post('/me/consent', requireCustomer, asyncRoute(async (req: Request, res:
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
/**
|
||||
* Analytics consent, on its own route rather than as a second field on
|
||||
* `/me/consent` (#56).
|
||||
*
|
||||
* Separate because the two are separate purposes and must be separately
|
||||
* refusable. One endpoint taking both would make it possible for a single call
|
||||
* to change an answer the customer did not touch — which is the bundling
|
||||
* problem again, moved from the form into the API.
|
||||
*
|
||||
* Withdrawal writes the reason rather than the consent sentence, so the stored
|
||||
* text never claims agreement to something that was declined. Same convention
|
||||
* as marketing consent above.
|
||||
*/
|
||||
router.post('/me/analytics-consent', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const consent = !!req.body.analyticsConsent;
|
||||
await pool.query(
|
||||
`UPDATE customers SET analytics_consent = $1, analytics_consent_at = now(), analytics_consent_text = $2 WHERE id = $3`,
|
||||
[consent, consent ? ANALYTICS_CONSENT_TEXT : 'Withdrew analytics consent via account settings', req.customerId]
|
||||
);
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
router.get('/me/orders', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<CustomerOrderRow>(
|
||||
`SELECT o.id, o.processor, o.amount_cents, o.status, o.created_at, i.name AS item_name
|
||||
|
||||
+27
-12
@@ -72,22 +72,37 @@ export function tagColorFor(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Widened for #56 to cover analytics as well as email.
|
||||
* Email marketing only. Deliberately says nothing about tracking.
|
||||
*
|
||||
* 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.
|
||||
* This was briefly widened during #56 to cover analytics as well, and that was
|
||||
* wrong: GDPR requires consent to be granular, and current EDPB guidance treats
|
||||
* bundling tracking consent with subscription consent as invalid because the
|
||||
* customer cannot accept one purpose and refuse the other. Quebec's Law 25 is
|
||||
* stricter still. Analytics has its own sentence and its own column below.
|
||||
*
|
||||
* 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.
|
||||
* Left exactly as it was so that every existing consent record stays valid and
|
||||
* untouched — nobody has to be re-asked for something they already agreed to.
|
||||
*/
|
||||
export const MARKETING_CONSENT_TEXT =
|
||||
'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.';
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||
|
||||
/**
|
||||
* Consent to the Brevo tracker (#56). Separate from marketing consent, and
|
||||
* separately refusable, because they are two purposes with two recipients.
|
||||
*
|
||||
* Names Brevo rather than saying "our email provider": informed consent means
|
||||
* the customer can tell who receives their data, and a description they cannot
|
||||
* act on is not disclosure. Says what is shared and why, states that it is
|
||||
* optional and independent of the emails, and states that it can be turned off
|
||||
* — withdrawal has to be as easy as giving it.
|
||||
*
|
||||
* Stored verbatim in `analytics_consent_text` for the same reason the marketing
|
||||
* sentence is: a record of consent that does not say what was consented to
|
||||
* cannot be audited, and re-wording this later must not silently broaden
|
||||
* anybody's agreement.
|
||||
*/
|
||||
export const ANALYTICS_CONSENT_TEXT =
|
||||
'I agree that what I browse and buy on this site may be shared with Brevo, the service that sends our emails, so that what they contain is relevant to me. This is optional, separate from receiving the emails themselves, and I can turn it off at any time.';
|
||||
|
||||
/**
|
||||
* Strips trailing slashes so a base URL can be joined with a stored path.
|
||||
|
||||
Reference in New Issue
Block a user