From 18d98a9cd1ae307e8fd0dcfbe82386699d85e70b Mon Sep 17 00:00:00 2001 From: Alexandre Philibeaux Date: Wed, 3 Apr 2024 09:39:49 +0000 Subject: [PATCH] feat(cookies): add consent middleware --- .changeset/wet-horses-prove.md | 5 ++ packages/cookie-consent/README.md | 7 ++ packages/cookie-consent/package.json | 6 +- .../CookieConsentProvider.tsx | 15 ++-- .../SegmentConsentMiddleware.tsx | 69 +++++++++++++++++++ .../useSegmentIntegrations/emptyConfig.tsx | 2 +- .../useSegmentIntegrations/fetchError.tsx | 2 +- .../useSegmentIntegrations/networkError.tsx | 2 +- .../useSegmentIntegrations/working.tsx | 2 +- .../src/CookieConsentProvider/helpers.ts | 12 ++++ .../src/CookieConsentProvider/index.tsx | 3 +- .../src/CookieConsentProvider/types.ts | 8 +-- packages/cookie-consent/src/index.ts | 2 +- pnpm-lock.yaml | 7 ++ 14 files changed, 122 insertions(+), 20 deletions(-) create mode 100644 .changeset/wet-horses-prove.md create mode 100644 packages/cookie-consent/src/CookieConsentProvider/SegmentConsentMiddleware.tsx create mode 100644 packages/cookie-consent/src/CookieConsentProvider/helpers.ts diff --git a/.changeset/wet-horses-prove.md b/.changeset/wet-horses-prove.md new file mode 100644 index 000000000..9338fba59 --- /dev/null +++ b/.changeset/wet-horses-prove.md @@ -0,0 +1,5 @@ +--- +"@scaleway/cookie-consent": minor +--- + +Add Consent Middleware, fix an amplitude issue with session_id not forward correctly to the destination diff --git a/packages/cookie-consent/README.md b/packages/cookie-consent/README.md index 08fefa6fe..7af51f47d 100644 --- a/packages/cookie-consent/README.md +++ b/packages/cookie-consent/README.md @@ -73,6 +73,13 @@ export function PanelConsent() { } ``` +### Segment Consent Middleware + +As it's necessary now to have a consent management. +https://segment.com/docs/privacy/consent-management/configure-consent-management/ + +you will have the possibility to add the SegmentConsentMiddleware, be aware that there is a dependency with SegmenttProvider. + ### User flow ```mermaid diff --git a/packages/cookie-consent/package.json b/packages/cookie-consent/package.json index 4b5b3b1fa..8537d8370 100644 --- a/packages/cookie-consent/package.json +++ b/packages/cookie-consent/package.json @@ -29,9 +29,11 @@ }, "devDependencies": { "@types/cookie": "0.6.0", - "react": "18.2.0" + "react": "18.2.0", + "@scaleway/use-segment": "1.0.1" }, "peerDependencies": { - "react": "18.x || 18" + "react": "18.x || 18", + "@scaleway/use-segment": "1.0.1" } } diff --git a/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx b/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx index 2623e691f..544a203ae 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx +++ b/packages/cookie-consent/src/CookieConsentProvider/CookieConsentProvider.tsx @@ -11,7 +11,8 @@ import { } from 'react' import { uniq } from '../helpers/array' import { stringToHash } from '../helpers/misc' -import type { CategoryKind, Config, Consent, Integrations } from './types' +import { isCategoryKind } from './helpers' +import type { Config, Consent, Integrations } from './types' import { useSegmentIntegrations } from './useSegmentIntegrations' const COOKIE_PREFIX = '_scw_rgpd' @@ -147,8 +148,12 @@ export const CookieConsentProvider = ({ (categoriesConsent: Partial) => { for (const [consentName, consentValue] of Object.entries( categoriesConsent, - ) as [CategoryKind, boolean][]) { - const cookieName = `${cookiePrefix}_${consentName}` + )) { + const consentCategoryName = isCategoryKind(consentName) + ? consentName + : 'unknown' + + const cookieName = `${cookiePrefix}_${consentCategoryName}` if (!consentValue) { // If consent is set to false we have to delete the cookie @@ -158,12 +163,12 @@ export const CookieConsentProvider = ({ }) } else { document.cookie = cookie.serialize( - `${cookiePrefix}_${consentName}`, + `${cookiePrefix}_${consentCategoryName}`, consentValue.toString(), { ...cookiesOptions, maxAge: - consentName === 'advertising' + consentCategoryName === 'advertising' ? consentAdvertisingMaxAge : consentMaxAge, }, diff --git a/packages/cookie-consent/src/CookieConsentProvider/SegmentConsentMiddleware.tsx b/packages/cookie-consent/src/CookieConsentProvider/SegmentConsentMiddleware.tsx new file mode 100644 index 000000000..9e4995e0b --- /dev/null +++ b/packages/cookie-consent/src/CookieConsentProvider/SegmentConsentMiddleware.tsx @@ -0,0 +1,69 @@ +import { useSegment } from '@scaleway/use-segment' +import cookie from 'cookie' +import type { PropsWithChildren } from 'react' +import { useCookieConsent } from './CookieConsentProvider' +import { type CategoryKind, isCategoryKind } from './helpers' + +export const AMPLITUDE_INTEGRATION_NAME = 'Amplitude (Actions)' +const COOKIE_SESSION_ID_NAME = 'analytics_session_id' + +export const getSessionId = () => { + const sessionId = cookie.parse(document.cookie)[COOKIE_SESSION_ID_NAME] + if (sessionId) { + return Number.parseInt(sessionId, 10) + } + + return Date.now() +} + +/** + * inspiration + * https://github.com/segmentio/consent-manager/blob/f9d5166679b3c928b394b8ad50d517fdf43654b1/src/consent-manager-builder/analytics.ts#L20 + */ +export const SegmentConsentMiddleware = ({ + children, + amplitudeIntegrationName = AMPLITUDE_INTEGRATION_NAME, +}: PropsWithChildren<{ + amplitudeIntegrationName: string +}>) => { + const { analytics } = useSegment() + const { categoriesConsent } = useCookieConsent() + + const categoriesPreferencesAccepted: CategoryKind[] = [] + + for (const [key, value] of Object.entries(categoriesConsent)) { + if (value && isCategoryKind(key)) { + categoriesPreferencesAccepted.push(key) + } + } + + analytics + ?.addSourceMiddleware(({ payload, next }) => { + if (payload.obj.context) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, no-param-reassign + payload.obj.context['consent'] = { + ...payload.obj.context['consent'], + defaultDestinationBehavior: null, + // Need to be handle if we let the user choose per destination and not per categories. + destinationPreferences: null, + categoryPreferences: categoriesPreferencesAccepted, + } + } + + // actually there is a bug on the default script. + if (payload.integrations()[amplitudeIntegrationName]) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, no-param-reassign + payload.obj.integrations = { + ...payload.obj.integrations, + [amplitudeIntegrationName]: { + session_id: getSessionId(), + }, + } + } + + return next(payload) + }) + .catch(() => null) + + return children +} diff --git a/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/emptyConfig.tsx b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/emptyConfig.tsx index d470b2971..b1c349b9f 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/emptyConfig.tsx +++ b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/emptyConfig.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from '@jest/globals' import { renderHook, waitFor } from '@testing-library/react' -import { useSegmentIntegrations } from '../..' +import { useSegmentIntegrations } from '../../useSegmentIntegrations' describe('CookieConsent - useSegmentIntegrations', () => { it('should not call segment if config is empty and return empty array', async () => { diff --git a/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/fetchError.tsx b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/fetchError.tsx index ab452e343..79e92bd5c 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/fetchError.tsx +++ b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/fetchError.tsx @@ -1,6 +1,6 @@ import { describe, expect, it, jest } from '@jest/globals' import { renderHook, waitFor } from '@testing-library/react' -import { useSegmentIntegrations } from '../..' +import { useSegmentIntegrations } from '../../useSegmentIntegrations' globalThis.fetch = jest.fn(() => Promise.resolve({ ok: false })) diff --git a/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/networkError.tsx b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/networkError.tsx index 0f90eeb50..412cee30e 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/networkError.tsx +++ b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/networkError.tsx @@ -1,6 +1,6 @@ import { describe, expect, it, jest } from '@jest/globals' import { renderHook, waitFor } from '@testing-library/react' -import { useSegmentIntegrations } from '../..' +import { useSegmentIntegrations } from '../../useSegmentIntegrations' globalThis.fetch = jest.fn(() => Promise.reject(new Error('randomError'))) diff --git a/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/working.tsx b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/working.tsx index 9f061f911..431380fcc 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/working.tsx +++ b/packages/cookie-consent/src/CookieConsentProvider/__tests__/useSegmentIntegrations/working.tsx @@ -1,6 +1,6 @@ import { describe, expect, it, jest } from '@jest/globals' import { renderHook, waitFor } from '@testing-library/react' -import { useSegmentIntegrations } from '../..' +import { useSegmentIntegrations } from '../../useSegmentIntegrations' globalThis.fetch = jest.fn(() => Promise.resolve({ diff --git a/packages/cookie-consent/src/CookieConsentProvider/helpers.ts b/packages/cookie-consent/src/CookieConsentProvider/helpers.ts new file mode 100644 index 000000000..d5e7f32b8 --- /dev/null +++ b/packages/cookie-consent/src/CookieConsentProvider/helpers.ts @@ -0,0 +1,12 @@ +export const categories = [ + 'essential', + 'functional', + 'marketing', + 'analytics', + 'advertising', +] as const + +export type CategoryKind = (typeof categories)[number] + +export const isCategoryKind = (key: string): key is CategoryKind => + categories.includes(key as CategoryKind) diff --git a/packages/cookie-consent/src/CookieConsentProvider/index.tsx b/packages/cookie-consent/src/CookieConsentProvider/index.tsx index 9e2c05d19..ef0f9b607 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/index.tsx +++ b/packages/cookie-consent/src/CookieConsentProvider/index.tsx @@ -2,5 +2,4 @@ export { CookieConsentProvider, useCookieConsent, } from './CookieConsentProvider' -export type { CategoryKind, Consent } from './types' -export { useSegmentIntegrations } from './useSegmentIntegrations' +export { SegmentConsentMiddleware } from './SegmentConsentMiddleware' diff --git a/packages/cookie-consent/src/CookieConsentProvider/types.ts b/packages/cookie-consent/src/CookieConsentProvider/types.ts index 27ec44cc0..98edd9f5c 100644 --- a/packages/cookie-consent/src/CookieConsentProvider/types.ts +++ b/packages/cookie-consent/src/CookieConsentProvider/types.ts @@ -1,10 +1,6 @@ -export type CategoryKind = - | 'essential' - | 'functional' - | 'marketing' - | 'analytics' - | 'advertising' +import type { CategoryKind } from './helpers' +export type { CategoryKind } export type Consent = { [K in CategoryKind]: boolean } type Integration = { category: CategoryKind; name: string } diff --git a/packages/cookie-consent/src/index.ts b/packages/cookie-consent/src/index.ts index 9dda253b2..2b13aeec8 100644 --- a/packages/cookie-consent/src/index.ts +++ b/packages/cookie-consent/src/index.ts @@ -1,5 +1,5 @@ export { CookieConsentProvider, + SegmentConsentMiddleware, useCookieConsent, } from './CookieConsentProvider' -export type { CategoryKind, Consent } from './CookieConsentProvider/types' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 147ff5c68..e18793739 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ importers: specifier: 0.6.0 version: 0.6.0 devDependencies: + '@scaleway/use-segment': + specifier: 1.0.1 + version: link:../use-segment '@types/cookie': specifier: 0.6.0 version: 0.6.0 @@ -4198,12 +4201,16 @@ packages: resolution: {integrity: sha512-XcGdod0Jjv84HOC7N5ziY3x+qL0AfmubvKOZ9hJjJ2yd5EE+KYjWhdOjt387e9HPheHkdggF9atTifMRtyAaRA==} dependencies: '@types/prop-types': 15.7.8 + '@types/scheduler': 0.23.0 csstype: 3.1.2 /@types/resolve@1.20.2: resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} dev: true + /@types/scheduler@0.23.0: + resolution: {integrity: sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==} + /@types/semver@7.5.0: resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==}