Skip to content

Commit e98d48e

Browse files
refactor(audience): unify consent level checks via capability-query functions (#2848)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fc9f900 commit e98d48e

File tree

9 files changed

+64
-28
lines changed

9 files changed

+64
-28
lines changed

packages/audience/core/src/consent.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { createConsentManager } from './consent';
1+
import {
2+
createConsentManager, canTrack, canIdentify,
3+
} from './consent';
24
import type { HttpSend } from './transport';
35
import { TransportError } from './errors';
46

@@ -25,6 +27,24 @@ beforeEach(() => {
2527
jest.clearAllMocks();
2628
});
2729

30+
describe('consent capability queries', () => {
31+
it.each([
32+
['none', false],
33+
['anonymous', true],
34+
['full', true],
35+
] as const)('canTrack(%s) returns %s', (level, expected) => {
36+
expect(canTrack(level)).toBe(expected);
37+
});
38+
39+
it.each([
40+
['none', false],
41+
['anonymous', false],
42+
['full', true],
43+
] as const)('canIdentify(%s) returns %s', (level, expected) => {
44+
expect(canIdentify(level)).toBe(expected);
45+
});
46+
});
47+
2848
describe('createConsentManager', () => {
2949
it('defaults to none when no initial level provided', () => {
3050
const queue = createMockQueue();

packages/audience/core/src/consent.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ export interface ConsentManager {
1111
setLevel(next: ConsentLevel): void;
1212
}
1313

14+
export function canTrack(level: ConsentLevel): boolean {
15+
return level !== 'none';
16+
}
17+
18+
export function canIdentify(level: ConsentLevel): boolean {
19+
return level === 'full';
20+
}
21+
1422
export function detectDoNotTrack(): boolean {
1523
if (typeof navigator === 'undefined') return false;
1624
// DNT header

packages/audience/core/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ export type { SessionResult } from './session';
5050
export { collectAttribution } from './attribution';
5151
export type { Attribution } from './attribution';
5252

53-
export { createConsentManager, detectDoNotTrack } from './consent';
53+
export {
54+
createConsentManager, detectDoNotTrack,
55+
canTrack, canIdentify,
56+
} from './consent';
5457
export type { ConsentManager } from './consent';
5558

5659
export { detectCmp, startCmpDetection } from './cmp';

packages/audience/pixel/src/autocapture.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ConsentLevel } from '@imtbl/audience-core';
2+
import { canTrack, canIdentify } from '@imtbl/audience-core';
23

34
export interface AutocaptureOptions {
45
/** Enable form submission auto-capture. Default: true */
@@ -68,7 +69,7 @@ export function setupAutocapture(
6869

6970
if (options.forms !== false) {
7071
const onSubmit = (e: Event): void => {
71-
if (getConsent() === 'none') return;
72+
if (!canTrack(getConsent())) return;
7273

7374
const form = e.target as HTMLFormElement;
7475
if (!form || form.tagName !== 'FORM') return;
@@ -81,7 +82,7 @@ export function setupAutocapture(
8182
};
8283

8384
const consent = getConsent();
84-
if (consent === 'full') {
85+
if (canIdentify(consent)) {
8586
const email = findEmail(form);
8687
if (email) {
8788
// Hash before enqueuing — raw email never enters the queue.
@@ -109,7 +110,7 @@ export function setupAutocapture(
109110

110111
if (options.clicks !== false) {
111112
const onClick = (e: Event): void => {
112-
if (getConsent() === 'none') return;
113+
if (!canTrack(getConsent())) return;
113114

114115
const target = e.target as HTMLElement;
115116
const anchor = target.closest?.('a') as HTMLAnchorElement | null;

packages/audience/pixel/src/pixel.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ jest.mock('@imtbl/audience-core', () => ({
7272
};
7373
},
7474
),
75+
canTrack: jest.fn().mockImplementation((level: string) => level !== 'none'),
7576
}));
7677

7778
// Mock fetch globally

packages/audience/pixel/src/pixel.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
collectAttribution,
2323
getOrCreateSession,
2424
createConsentManager,
25+
canTrack,
2526
startCmpDetection,
2627
} from '@imtbl/audience-core';
2728
import { setupAutocapture } from './autocapture';
@@ -124,7 +125,7 @@ export class Pixel {
124125
if (isAutoConsent) {
125126
// CMP detection will fire the deferred page view when consent upgrades.
126127
this.startCmpDetection();
127-
} else if (this.consent.level !== 'none') {
128+
} else if (canTrack(this.consent.level)) {
128129
// Static consent — fire page view immediately.
129130
this.initialPageViewFired = true;
130131
this.page();
@@ -139,7 +140,7 @@ export class Pixel {
139140
}
140141

141142
page(properties?: Record<string, unknown>): void {
142-
if (!this.canTrack()) return;
143+
if (!this.isTrackingAllowed()) return;
143144

144145
const { sessionId, isNew } = getOrCreateSession(this.domain);
145146
this.refreshSession(sessionId, isNew);
@@ -168,7 +169,7 @@ export class Pixel {
168169
// Fire the deferred page view if consent was upgraded from 'none'
169170
// (covers the case where CMP detection failed and the caller
170171
// manually sets consent as a fallback).
171-
if (level !== 'none' && !this.initialPageViewFired) {
172+
if (canTrack(level) && !this.initialPageViewFired) {
172173
this.initialPageViewFired = true;
173174
this.page();
174175
}
@@ -200,7 +201,7 @@ export class Pixel {
200201
this.consent!.setLevel(level);
201202

202203
// Fire the deferred page view on first consent upgrade from 'none'.
203-
if (level !== 'none' && !this.initialPageViewFired) {
204+
if (canTrack(level) && !this.initialPageViewFired) {
204205
this.initialPageViewFired = true;
205206
this.page();
206207
}
@@ -210,7 +211,7 @@ export class Pixel {
210211
onCmpUpdate,
211212
(detector: CmpDetector) => {
212213
// CMP found — apply the initial consent level it reported.
213-
if (detector.level !== 'none') {
214+
if (canTrack(detector.level)) {
214215
onCmpUpdate(detector.level);
215216
}
216217
},
@@ -220,7 +221,7 @@ export class Pixel {
220221
// -- Auto-capture helper --------------------------------------------------
221222

222223
private track(eventName: string, properties: Record<string, unknown>): void {
223-
if (!this.canTrack()) return;
224+
if (!this.isTrackingAllowed()) return;
224225

225226
const { sessionId, isNew } = getOrCreateSession(this.domain);
226227
this.refreshSession(sessionId, isNew);
@@ -247,7 +248,7 @@ export class Pixel {
247248
}
248249

249250
private fireSessionStart(sessionId: string): void {
250-
if (!this.canTrack()) return;
251+
if (!this.isTrackingAllowed()) return;
251252

252253
const message: TrackMessage = {
253254
...this.buildBase(),
@@ -261,7 +262,7 @@ export class Pixel {
261262
}
262263

263264
private fireSessionEnd(): void {
264-
if (!this.canTrack() || !this.sessionId) return;
265+
if (!this.isTrackingAllowed() || !this.sessionId) return;
265266

266267
const duration = this.sessionStartTime
267268
? Math.round((Date.now() - this.sessionStartTime) / 1000)
@@ -337,8 +338,8 @@ export class Pixel {
337338

338339
// -- Guards -------------------------------------------------------------
339340

340-
private canTrack(): boolean {
341-
return this.isReady() && this.consent!.level !== 'none';
341+
private isTrackingAllowed(): boolean {
342+
return this.isReady() && canTrack(this.consent!.level);
342343
}
343344

344345
private isReady(): boolean {

packages/audience/pixel/src/snippet.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import type { Environment } from '@imtbl/audience-core';
1+
import type { ConsentLevel, Environment } from '@imtbl/audience-core';
22

33
const DEFAULT_CDN_URL = 'https://cdn.immutable.com/pixel/v1/imtbl.js';
44

55
export interface SnippetOptions {
66
key: string;
77
cdnUrl?: string;
8-
consent?: 'none' | 'anonymous' | 'full';
8+
consent?: ConsentLevel;
99
environment?: Environment;
1010
}
1111

packages/audience/sdk/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export { Audience } from './sdk';
2-
export { IdentityType } from '@imtbl/audience-core';
2+
export { IdentityType, canTrack, canIdentify } from '@imtbl/audience-core';
33
export type { AudienceConfig } from './types';
44
export type { Environment, ConsentLevel, UserTraits } from '@imtbl/audience-core';

packages/audience/sdk/src/sdk.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import {
2727
collectAttribution,
2828
getOrCreateSession,
2929
createConsentManager,
30+
canTrack,
31+
canIdentify,
3032
SESSION_START,
3133
SESSION_END,
3234
} from '@imtbl/audience-core';
@@ -82,7 +84,7 @@ export class Audience {
8284
this.debug = new DebugLogger(config.debug ?? false);
8385

8486
let isNewSession = false;
85-
if (consentLevel !== 'none') {
87+
if (canTrack(consentLevel)) {
8688
this.anonymousId = getOrCreateAnonymousId(cookieDomain);
8789
isNewSession = this.startSession();
8890
} else {
@@ -142,14 +144,14 @@ export class Audience {
142144

143145
// --- Helpers ---
144146

145-
/** True when consent is 'none' — SDK should not enqueue anything. */
147+
/** True when the current consent level does not permit tracking. */
146148
private isTrackingDisabled(): boolean {
147-
return this.consent.level === 'none';
149+
return !canTrack(this.consent.level);
148150
}
149151

150152
/** Returns userId if consent is full, undefined otherwise. */
151153
private effectiveUserId(): string | undefined {
152-
return this.consent.level === 'full' ? this.userId : undefined;
154+
return canIdentify(this.consent.level) ? this.userId : undefined;
153155
}
154156

155157
/** Create or resume a session, returning whether it's new. */
@@ -274,7 +276,7 @@ export class Audience {
274276
identityType?: IdentityType,
275277
traits?: UserTraits,
276278
): void {
277-
if (this.consent.level !== 'full') {
279+
if (!canIdentify(this.consent.level)) {
278280
this.debug.logWarning('identify() requires full consent — call ignored.');
279281
return;
280282
}
@@ -314,7 +316,7 @@ export class Audience {
314316
from: { id: string; identityType: IdentityType },
315317
to: { id: string; identityType: IdentityType },
316318
): void {
317-
if (this.consent.level !== 'full') {
319+
if (!canIdentify(this.consent.level)) {
318320
this.debug.logWarning('alias() requires full consent — call ignored.');
319321
return;
320322
}
@@ -350,7 +352,7 @@ export class Audience {
350352

351353
this.debug.logConsent(previous, level);
352354

353-
const isUpgradeFromNone = previous === 'none' && level !== 'none';
355+
const isUpgradeFromNone = !canTrack(previous) && canTrack(level);
354356

355357
// When upgrading from none, create the persisted anonymousId first
356358
// so the consent sync sends the correct ID to the server.
@@ -360,7 +362,7 @@ export class Audience {
360362

361363
// Web-specific cleanup before core handles queue purge/transform and server sync.
362364
// session_end is intentionally not emitted — no events should be sent after opt-out.
363-
if (level === 'none') {
365+
if (!canTrack(level)) {
364366
this.queue.stop();
365367
}
366368

@@ -369,10 +371,10 @@ export class Audience {
369371
this.consent.setLevel(level);
370372

371373
// Web-specific cleanup after core's transition.
372-
if (level === 'none') {
374+
if (!canTrack(level)) {
373375
deleteCookie(COOKIE_NAME, this.cookieDomain);
374376
deleteCookie(SESSION_COOKIE, this.cookieDomain);
375-
} else if (level === 'anonymous' && previous === 'full') {
377+
} else if (canIdentify(previous) && !canIdentify(level)) {
376378
this.userId = undefined;
377379
}
378380

0 commit comments

Comments
 (0)