From b50664ec1d1a95741f62f4bcda84c7c9b92a0276 Mon Sep 17 00:00:00 2001 From: Allison King Date: Thu, 27 Jul 2023 15:59:44 -0400 Subject: [PATCH] Fides-js consent reporting calls (#3845) --- CHANGELOG.md | 4 + .../src/components/ConsentButtons.tsx | 7 +- clients/fides-js/src/components/Overlay.tsx | 34 ++- clients/fides-js/src/fides.ts | 3 + clients/fides-js/src/integrations/gtm.ts | 6 +- clients/fides-js/src/lib/consent-types.ts | 29 ++ clients/fides-js/src/lib/events.ts | 31 +- clients/fides-js/src/lib/hooks.ts | 68 +++++ clients/fides-js/src/lib/preferences.ts | 20 +- clients/fides-js/src/services/fides/api.ts | 42 ++- .../consent/NoticeDrivenConsent.tsx | 51 +++- .../cypress/e2e/consent-banner.cy.ts | 273 +++++++++++------- .../cypress/e2e/consent-notices.cy.ts | 53 ++++ .../fixtures/consent/notices_served.json | 56 ++++ .../cypress/support/commands.ts | 1 + .../features/consent/consent.slice.ts | 13 + clients/privacy-center/types/api/index.ts | 3 + .../types/api/models/ConsentMethod.ts | 2 +- .../types/api/models/ConsentOptionCreate.ts | 1 + .../api/models/LastServedNoticeSchema.ts | 15 + .../types/api/models/NoticesServedRequest.ts | 19 ++ .../types/api/models/ServingComponent.ts | 13 + 22 files changed, 595 insertions(+), 149 deletions(-) create mode 100644 clients/privacy-center/cypress/fixtures/consent/notices_served.json create mode 100644 clients/privacy-center/types/api/models/LastServedNoticeSchema.ts create mode 100644 clients/privacy-center/types/api/models/NoticesServedRequest.ts create mode 100644 clients/privacy-center/types/api/models/ServingComponent.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 42de4dfec8..e6ad73f31e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ The types of changes are: ## [Unreleased](https://github.com/ethyca/fides/compare/2.17.0...main) +### Added +- Additional consent reporting calls from `fides-js` [#3845](https://github.com/ethyca/fides/pull/3845) +- Additional consent reporting calls from privacy center [#3847](https://github.com/ethyca/fides/pull/3847) + ### Fixed - Fix datamap zoom for low system counts [#3835](https://github.com/ethyca/fides/pull/3835) diff --git a/clients/fides-js/src/components/ConsentButtons.tsx b/clients/fides-js/src/components/ConsentButtons.tsx index 07eb3fc5df..12a52bec1c 100644 --- a/clients/fides-js/src/components/ConsentButtons.tsx +++ b/clients/fides-js/src/components/ConsentButtons.tsx @@ -14,12 +14,14 @@ const ConsentButtons = ({ onManagePreferencesClick, onSave, enabledKeys, + isAcknowledge, isInModal, }: { experience: PrivacyExperience; onSave: (noticeKeys: NoticeKeys) => void; onManagePreferencesClick?: () => void; enabledKeys: NoticeKeys; + isAcknowledge: boolean; isInModal?: boolean; }) => { if (!experience.experience_config || !experience.privacy_notices) { @@ -27,9 +29,6 @@ const ConsentButtons = ({ } const { experience_config: config, privacy_notices: notices } = experience; - const isAllNoticeOnly = notices.every( - (n) => n.consent_mechanism === ConsentMechanism.NOTICE_ONLY - ); const handleAcceptAll = () => { onSave(notices.map((n) => n.notice_key)); @@ -43,7 +42,7 @@ const ConsentButtons = ({ ); }; - if (isAllNoticeOnly) { + if (isAcknowledge) { return (
= ({ const handleOpenModal = useCallback(() => { if (instance) { instance.show(); + dispatchFidesEvent("FidesUIShown", cookie, options.debug, { + servingComponent: ServingComponent.OVERLAY, + }); } - }, [instance]); + }, [instance, cookie, options.debug]); const handleCloseModal = useCallback(() => { if (instance) { @@ -105,11 +111,31 @@ const Overlay: FunctionComponent = ({ [experience] ); + useEffect(() => { + if (showBanner && bannerIsOpen) { + dispatchFidesEvent("FidesUIShown", cookie, options.debug, { + servingComponent: ServingComponent.BANNER, + }); + } + }, [showBanner, cookie, options.debug, bannerIsOpen]); + const privacyNotices = useMemo( () => experience.privacy_notices ?? [], [experience.privacy_notices] ); + const isAllNoticeOnly = privacyNotices.every( + (n) => n.consent_mechanism === ConsentMechanism.NOTICE_ONLY + ); + + const { servedNotices } = useConsentServed({ + notices: privacyNotices, + options, + userGeography: fidesRegionString, + acknowledgeMode: isAllNoticeOnly, + privacyExperienceId: experience.id, + }); + const handleUpdatePreferences = useCallback( (enabledPrivacyNoticeKeys: Array) => { const consentPreferencesToSave = privacyNotices.map((notice) => { @@ -126,6 +152,7 @@ const Overlay: FunctionComponent = ({ consentMethod: ConsentMethod.button, userLocationString: fidesRegionString, cookie, + servedNotices, }); // Make sure our draft state also updates setDraftEnabledNoticeKeys(enabledPrivacyNoticeKeys); @@ -136,6 +163,7 @@ const Overlay: FunctionComponent = ({ fidesRegionString, experience.id, options.fidesApiUrl, + servedNotices, ] ); @@ -171,6 +199,7 @@ const Overlay: FunctionComponent = ({ handleUpdatePreferences(keys); setBannerIsOpen(false); }} + isAcknowledge={isAllNoticeOnly} /> } /> @@ -191,6 +220,7 @@ const Overlay: FunctionComponent = ({ handleUpdatePreferences(keys); handleCloseModal(); }} + isAcknowledge={isAllNoticeOnly} /> } options={options} diff --git a/clients/fides-js/src/fides.ts b/clients/fides-js/src/fides.ts index bd191721d1..2dd1d5dc08 100644 --- a/clients/fides-js/src/fides.ts +++ b/clients/fides-js/src/fides.ts @@ -131,6 +131,7 @@ const automaticallyApplyGPCPreferences = ( cookie: FidesCookie, fidesRegionString: string | null, fidesApiUrl: string, + debug: boolean, effectiveExperience?: PrivacyExperience | null ) => { if (!effectiveExperience || !effectiveExperience.privacy_notices) { @@ -174,6 +175,7 @@ const automaticallyApplyGPCPreferences = ( consentMethod: ConsentMethod.gpc, userLocationString: fidesRegionString || undefined, cookie, + debug, }); } }; @@ -276,6 +278,7 @@ const init = async ({ cookie, fidesRegionString, options.fidesApiUrl, + options.debug, effectiveExperience ); } diff --git a/clients/fides-js/src/integrations/gtm.ts b/clients/fides-js/src/integrations/gtm.ts index 7542578da0..94866f8dfa 100644 --- a/clients/fides-js/src/integrations/gtm.ts +++ b/clients/fides-js/src/integrations/gtm.ts @@ -50,7 +50,11 @@ export const gtm = () => { if (window.Fides?.initialized) { pushFidesVariableToGTM({ type: "FidesInitialized", - detail: { consent: window.Fides.consent }, + detail: { + consent: window.Fides.consent, + fides_meta: window.Fides.fides_meta, + identity: window.Fides.identity, + }, }); } }; diff --git a/clients/fides-js/src/lib/consent-types.ts b/clients/fides-js/src/lib/consent-types.ts index a9dc406292..cb141b4925 100644 --- a/clients/fides-js/src/lib/consent-types.ts +++ b/clients/fides-js/src/lib/consent-types.ts @@ -183,6 +183,7 @@ export type PrivacyPreferencesRequest = { export type ConsentOptionCreate = { privacy_notice_history_id: string; preference: UserConsentPreference; + served_notice_history_id?: string; }; export type Identity = { @@ -208,6 +209,34 @@ export enum GpcStatus { OVERRIDDEN = "overridden", } +// Consent reporting +export enum ServingComponent { + OVERLAY = "overlay", + BANNER = "banner", + PRIVACY_CENTER = "privacy_center", +} +/** + * Request body when indicating that notices were served in the UI + */ +export type NoticesServedRequest = { + browser_identity: Identity; + code?: string; + privacy_notice_history_ids: Array; + privacy_experience_id?: string; + user_geography?: string; + acknowledge_mode?: boolean; + serving_component: ServingComponent; +}; +/** + * Schema that surfaces the last version of a notice that was shown to a user + */ +export type LastServedNoticeSchema = { + id: string; + updated_at: string; + privacy_notice_history: PrivacyNotice; + served_notice_history_id: string; +}; + // ------------------LEGACY TYPES BELOW ------------------- export type ConditionalValue = { diff --git a/clients/fides-js/src/lib/events.ts b/clients/fides-js/src/lib/events.ts index 73059f4071..d4c65b7baa 100644 --- a/clients/fides-js/src/lib/events.ts +++ b/clients/fides-js/src/lib/events.ts @@ -5,23 +5,30 @@ import { debugLog } from "./consent-utils"; * Defines the available event names: * - FidesInitialized: dispatched when initialization is complete, from Fides.init() * - FidesUpdated: dispatched when preferences are updated, from updateConsentPreferences() or Fides.init() + * - FidesUIShown: dispatched when either the banner or modal is shown to the user */ -export type FidesEventType = "FidesInitialized" | "FidesUpdated"; +export type FidesEventType = + | "FidesInitialized" + | "FidesUpdated" + | "FidesUIShown"; // Bonus points: update the WindowEventMap interface with our custom event types declare global { interface WindowEventMap { FidesInitialized: FidesEvent; FidesUpdated: FidesEvent; + FidesUIShown: FidesEvent; } } /** - * Defines the properties available on event.detail. As of now, these are an - * explicit subset of properties from the Fides cookie - * TODO: add identity and meta? + * Defines the properties available on event.detail. Currently the FidesCookie + * and an extra field `meta` for any other details that the event wants to pass + * around. */ -export type FidesEventDetail = Pick; +export type FidesEventDetail = FidesCookie & { + extraDetails?: Record; +}; export type FidesEvent = CustomEvent; @@ -42,18 +49,16 @@ export type FidesEvent = CustomEvent; export const dispatchFidesEvent = ( type: FidesEventType, cookie: FidesCookie, - debug: boolean + debug: boolean, + extraDetails?: Record ) => { if (typeof window !== "undefined" && typeof CustomEvent !== "undefined") { - // Pick a subset of the Fides cookie properties to provide as event detail - const { consent }: FidesEventDetail = cookie; - const detail: FidesEventDetail = { consent }; - const event = new CustomEvent(type, { detail }); + const event = new CustomEvent(type, { + detail: { ...cookie, extraDetails }, + }); debugLog( debug, - `Dispatching event type ${type} with cookie consent ${JSON.stringify( - cookie?.consent - )}` + `Dispatching event type ${type} with cookie ${JSON.stringify(cookie)}` ); window.dispatchEvent(event); } diff --git a/clients/fides-js/src/lib/hooks.ts b/clients/fides-js/src/lib/hooks.ts index 5ecea6b167..ceb0ec02d5 100644 --- a/clients/fides-js/src/lib/hooks.ts +++ b/clients/fides-js/src/lib/hooks.ts @@ -1,4 +1,13 @@ import { useEffect, useState, useCallback } from "preact/hooks"; +import { FidesEvent } from "./events"; +import { + FidesOptions, + LastServedNoticeSchema, + NoticesServedRequest, + PrivacyNotice, + ServingComponent, +} from "./consent-types"; +import { patchNoticesServed } from "../services/fides/api"; /** * Hook which tracks if the app has mounted yet. @@ -54,3 +63,62 @@ export const useDisclosure = ({ id }: { id: string }) => { getDisclosureProps, }; }; + +export const useConsentServed = ({ + notices, + options, + userGeography, + privacyExperienceId, + acknowledgeMode, +}: { + notices: PrivacyNotice[]; + options: FidesOptions; + userGeography?: string; + privacyExperienceId?: string; + acknowledgeMode?: boolean; +}) => { + const [servedNotices, setServedNotices] = useState< + LastServedNoticeSchema[] | undefined + >(undefined); + + const handleUIEvent = useCallback( + async (event: FidesEvent) => { + // Only send notices-served request when we show via the modal since that + // is the only time we show all notices + if ( + !event.detail.extraDetails || + event.detail.extraDetails.servingComponent !== ServingComponent.OVERLAY + ) { + return; + } + const request: NoticesServedRequest = { + browser_identity: event.detail.identity, + privacy_experience_id: privacyExperienceId, + user_geography: userGeography, + acknowledge_mode: acknowledgeMode, + privacy_notice_history_ids: notices.map( + (n) => n.privacy_notice_history_id + ), + serving_component: event.detail.extraDetails.servingComponent, + }; + const result = await patchNoticesServed({ + request, + fidesApiUrl: options.fidesApiUrl, + debug: options.debug, + }); + if (result) { + setServedNotices(result); + } + }, + [notices, options, acknowledgeMode, privacyExperienceId, userGeography] + ); + + useEffect(() => { + window.addEventListener("FidesUIShown", handleUIEvent); + return () => { + window.removeEventListener("FidesUIShown", handleUIEvent); + }; + }, [handleUIEvent]); + + return { servedNotices }; +}; diff --git a/clients/fides-js/src/lib/preferences.ts b/clients/fides-js/src/lib/preferences.ts index 597b1d1eb4..e0908531fa 100644 --- a/clients/fides-js/src/lib/preferences.ts +++ b/clients/fides-js/src/lib/preferences.ts @@ -1,6 +1,7 @@ import { ConsentMethod, ConsentOptionCreate, + LastServedNoticeSchema, PrivacyPreferencesRequest, SaveConsentPreference, UserConsentPreference, @@ -31,6 +32,7 @@ export const updateConsentPreferences = ({ userLocationString, cookie, debug = false, + servedNotices, }: { consentPreferencesToSave: Array; experienceId: string; @@ -39,6 +41,7 @@ export const updateConsentPreferences = ({ userLocationString?: string; cookie: FidesCookie; debug?: boolean; + servedNotices?: Array; }) => { // Derive the CookieKeyConsent object from privacy notices const noticeMap = new Map( @@ -51,10 +54,19 @@ export const updateConsentPreferences = ({ // Derive the Fides user preferences array from privacy notices const fidesUserPreferences: Array = - consentPreferencesToSave.map(({ notice, consentPreference }) => ({ - privacy_notice_history_id: notice.privacy_notice_history_id, - preference: consentPreference, - })); + consentPreferencesToSave.map(({ notice, consentPreference }) => { + const servedNotice = servedNotices + ? servedNotices.find( + (n) => + n.privacy_notice_history.id === notice.privacy_notice_history_id + ) + : undefined; + return { + privacy_notice_history_id: notice.privacy_notice_history_id, + preference: consentPreference, + served_notice_history_id: servedNotice?.served_notice_history_id, + }; + }); // Update the cookie object // eslint-disable-next-line no-param-reassign diff --git a/clients/fides-js/src/services/fides/api.ts b/clients/fides-js/src/services/fides/api.ts index 31e2cb7554..4b30af860c 100644 --- a/clients/fides-js/src/services/fides/api.ts +++ b/clients/fides-js/src/services/fides/api.ts @@ -1,5 +1,7 @@ import { ComponentType, + LastServedNoticeSchema, + NoticesServedRequest, PrivacyExperience, PrivacyPreferencesRequest, } from "../../lib/consent-types"; @@ -8,6 +10,7 @@ import { debugLog } from "../../lib/consent-utils"; export enum FidesEndpointPaths { PRIVACY_EXPERIENCE = "/privacy-experience", PRIVACY_PREFERENCES = "/privacy-preferences", + NOTICES_SERVED = "/notices-served", } /** @@ -77,6 +80,14 @@ export const fetchExperience = async ( } }; +const PATCH_FETCH_OPTIONS: RequestInit = { + method: "PATCH", + mode: "cors", + headers: { + "Content-Type": "application/json", + }, +}; + /** * Sends user consent preference downstream to Fides */ @@ -87,12 +98,8 @@ export const patchUserPreferenceToFidesServer = async ( ): Promise => { debugLog(debug, "Saving user consent preference...", preferences); const fetchOptions: RequestInit = { - method: "PATCH", - mode: "cors", + ...PATCH_FETCH_OPTIONS, body: JSON.stringify(preferences), - headers: { - "Content-Type": "application/json", - }, }; const response = await fetch( `${fidesApiUrl}${FidesEndpointPaths.PRIVACY_PREFERENCES}`, @@ -107,3 +114,28 @@ export const patchUserPreferenceToFidesServer = async ( } return Promise.resolve(); }; + +export const patchNoticesServed = async ({ + request, + fidesApiUrl, + debug, +}: { + request: NoticesServedRequest; + fidesApiUrl: string; + debug: boolean; +}): Promise | null> => { + debugLog(debug, "Saving that notices were served..."); + const fetchOptions: RequestInit = { + ...PATCH_FETCH_OPTIONS, + body: JSON.stringify(request), + }; + const response = await fetch( + `${fidesApiUrl}${FidesEndpointPaths.NOTICES_SERVED}`, + fetchOptions + ); + if (!response.ok) { + debugLog(debug, "Error patching notices served. Response:", response); + return null; + } + return response.json(); +}; diff --git a/clients/privacy-center/components/consent/NoticeDrivenConsent.tsx b/clients/privacy-center/components/consent/NoticeDrivenConsent.tsx index fd3e7eee18..e11bcf798f 100644 --- a/clients/privacy-center/components/consent/NoticeDrivenConsent.tsx +++ b/clients/privacy-center/components/consent/NoticeDrivenConsent.tsx @@ -17,6 +17,7 @@ import { selectUserRegion, selectPrivacyExperience, useUpdatePrivacyPreferencesMutation, + useUpdateNoticesServedMutation, } from "~/features/consent/consent.slice"; import { @@ -25,6 +26,7 @@ import { ConsentOptionCreate, PrivacyNoticeResponseWithUserPreferences, PrivacyPreferencesRequest, + ServingComponent, UserConsentPreference, } from "~/types/api"; import { useRouter } from "next/router"; @@ -63,6 +65,12 @@ const NoticeDrivenConsent = () => { useUpdatePrivacyPreferencesMutation(); const region = useAppSelector(selectUserRegion); + const browserIdentities = useMemo(() => { + const identities = inspectForBrowserIdentities(); + const deviceIdentity = { fides_user_device_id: fidesUserDeviceId }; + return identities ? { ...deviceIdentity, ...identities } : deviceIdentity; + }, [fidesUserDeviceId]); + const initialDraftPreferences = useMemo(() => { const newPreferences = { ...serverPreferences }; Object.entries(serverPreferences).forEach(([key, value]) => { @@ -87,6 +95,30 @@ const NoticeDrivenConsent = () => { setDraftPreferences(initialDraftPreferences); }, [initialDraftPreferences]); + const [updateNoticesServedMutationTrigger, { data: servedNotices }] = + useUpdateNoticesServedMutation(); + + useEffect(() => { + if (experience && experience.privacy_notices) { + updateNoticesServedMutationTrigger({ + id: consentRequestId, + body: { + browser_identity: browserIdentities, + privacy_experience_id: experience?.id, + privacy_notice_history_ids: experience.privacy_notices.map( + (p) => p.privacy_notice_history_id + ), + serving_component: ServingComponent.PRIVACY_CENTER, + }, + }); + } + }, [ + consentRequestId, + updateNoticesServedMutationTrigger, + experience, + browserIdentities, + ]); + const items = useMemo(() => { if (!experience) { return []; @@ -130,11 +162,6 @@ const NoticeDrivenConsent = () => { * 3. Delete any cookies that have been opted out of */ const handleSave = async () => { - const browserIdentities = inspectForBrowserIdentities(); - const deviceIdentity = { fides_user_device_id: fidesUserDeviceId }; - const identities = browserIdentities - ? { ...deviceIdentity, ...browserIdentities } - : deviceIdentity; const notices = experience?.privacy_notices ?? []; // Reconnect preferences to notices @@ -143,27 +170,32 @@ const NoticeDrivenConsent = () => { const notice = notices.find( (n) => n.privacy_notice_history_id === historyKey ); - return { historyKey, preference, notice }; + const servedNotice = servedNotices?.find( + (sn) => sn.privacy_notice_history.id === historyKey + ); + return { historyKey, preference, notice, servedNotice }; } ); const preferences: ConsentOptionCreate[] = noticePreferences.map( - ({ historyKey, preference, notice }) => { + ({ historyKey, preference, notice, servedNotice }) => { if (notice?.consent_mechanism === ConsentMechanism.NOTICE_ONLY) { return { privacy_notice_history_id: historyKey, preference: UserConsentPreference.ACKNOWLEDGE, + served_notice_history_id: servedNotice?.served_notice_history_id, }; } return { privacy_notice_history_id: historyKey, preference: preference ?? UserConsentPreference.OPT_OUT, + served_notice_history_id: servedNotice?.served_notice_history_id, }; } ); const payload: PrivacyPreferencesRequest = { - browser_identity: identities, + browser_identity: browserIdentities, preferences, user_geography: region, privacy_experience_id: experience?.id, @@ -179,7 +211,8 @@ const NoticeDrivenConsent = () => { if ("error" in result) { toast({ title: "An error occurred while saving user consent preferences", - description: result.error, + description: + typeof result.error === "string" ? result.error : undefined, ...ErrorToastOptions, }); return; diff --git a/clients/privacy-center/cypress/e2e/consent-banner.cy.ts b/clients/privacy-center/cypress/e2e/consent-banner.cy.ts index 209d76c444..45cd838164 100644 --- a/clients/privacy-center/cypress/e2e/consent-banner.cy.ts +++ b/clients/privacy-center/cypress/e2e/consent-banner.cy.ts @@ -5,6 +5,8 @@ import { FidesCookie, LegacyConsentConfig, PrivacyNotice, + LastServedNoticeSchema, + ConsentOptionCreate, } from "fides-js"; import { ConsentMechanism, @@ -96,6 +98,11 @@ const stubConfig = ( body: {}, } ).as("patchPrivacyPreference"); + cy.intercept( + "PATCH", + `${updatedConfig.options.fidesApiUrl}${FidesEndpointPaths.NOTICES_SERVED}`, + { fixture: "consent/notices_served.json" } + ).as("patchNoticesServed"); } cy.visitConsentDemo(updatedConfig); }); @@ -1147,21 +1154,17 @@ describe("Consent banner", () => { }); cy.get("@FidesInitialized") .should("have.been.calledOnce") - .its("firstCall.args.0.detail") + .its("firstCall.args.0.detail.consent") .should("deep.equal", { - consent: { - [PRIVACY_NOTICE_KEY_1]: false, - [PRIVACY_NOTICE_KEY_2]: true, - }, + [PRIVACY_NOTICE_KEY_1]: false, + [PRIVACY_NOTICE_KEY_2]: true, }); cy.get("@FidesUpdated") .should("have.been.calledOnce") - .its("firstCall.args.0.detail") + .its("firstCall.args.0.detail.consent") .should("deep.equal", { - consent: { - [PRIVACY_NOTICE_KEY_1]: false, - [PRIVACY_NOTICE_KEY_2]: true, - }, + [PRIVACY_NOTICE_KEY_1]: false, + [PRIVACY_NOTICE_KEY_2]: true, }); }); @@ -1171,21 +1174,17 @@ describe("Consent banner", () => { cy.get("@FidesUpdated") .should("have.been.calledTwice") // First call should be from initialization, before the user rejects all - .its("firstCall.args.0.detail") + .its("firstCall.args.0.detail.consent") .should("deep.equal", { - consent: { - [PRIVACY_NOTICE_KEY_1]: false, - [PRIVACY_NOTICE_KEY_2]: true, - }, + [PRIVACY_NOTICE_KEY_1]: false, + [PRIVACY_NOTICE_KEY_2]: true, }); cy.get("@FidesUpdated") // Second call is when the user rejects all - .its("secondCall.args.0.detail") + .its("secondCall.args.0.detail.consent") .should("deep.equal", { - consent: { - [PRIVACY_NOTICE_KEY_1]: false, - [PRIVACY_NOTICE_KEY_2]: true, - }, + [PRIVACY_NOTICE_KEY_1]: false, + [PRIVACY_NOTICE_KEY_2]: true, }); }); @@ -1194,21 +1193,17 @@ describe("Consent banner", () => { cy.get("@FidesUpdated") .should("have.been.calledTwice") // First call should be from initialization, before the user accepts all - .its("firstCall.args.0.detail") + .its("firstCall.args.0.detail.consent") .should("deep.equal", { - consent: { - [PRIVACY_NOTICE_KEY_1]: false, - [PRIVACY_NOTICE_KEY_2]: true, - }, + [PRIVACY_NOTICE_KEY_1]: false, + [PRIVACY_NOTICE_KEY_2]: true, }); cy.get("@FidesUpdated") // Second call is when the user accepts all - .its("secondCall.args.0.detail") + .its("secondCall.args.0.detail.consent") .should("deep.equal", { - consent: { - [PRIVACY_NOTICE_KEY_1]: true, - [PRIVACY_NOTICE_KEY_2]: true, - }, + [PRIVACY_NOTICE_KEY_1]: true, + [PRIVACY_NOTICE_KEY_2]: true, }); }); @@ -1221,21 +1216,17 @@ describe("Consent banner", () => { cy.get("@FidesUpdated") .should("have.been.calledTwice") // First call should be from initialization, before the user saved preferences - .its("firstCall.args.0.detail") + .its("firstCall.args.0.detail.consent") .should("deep.equal", { - consent: { - [PRIVACY_NOTICE_KEY_1]: false, - [PRIVACY_NOTICE_KEY_2]: true, - }, + [PRIVACY_NOTICE_KEY_1]: false, + [PRIVACY_NOTICE_KEY_2]: true, }); cy.get("@FidesUpdated") // Second call is when the user saved preferences and opted-in to the first notice - .its("secondCall.args.0.detail") + .its("secondCall.args.0.detail.consent") .should("deep.equal", { - consent: { - [PRIVACY_NOTICE_KEY_1]: true, - [PRIVACY_NOTICE_KEY_2]: true, - }, + [PRIVACY_NOTICE_KEY_1]: true, + [PRIVACY_NOTICE_KEY_2]: true, }); }); }); @@ -1313,30 +1304,24 @@ describe("Consent banner", () => { }); cy.get("@FidesInitialized") .should("have.been.calledOnce") - .its("firstCall.args.0.detail") + .its("firstCall.args.0.detail.consent") .should("deep.equal", { - consent: { - data_sales: false, - tracking: false, - analytics: true, - }, + data_sales: false, + tracking: false, + analytics: true, }); cy.get("@FidesUpdated") - .its("firstCall.args.0.detail") + .its("firstCall.args.0.detail.consent") .should("deep.equal", { - consent: { - data_sales: false, - tracking: false, - analytics: true, - }, + data_sales: false, + tracking: false, + analytics: true, }); cy.get("@FidesUpdated") - .its("secondCall.args.0.detail") + .its("secondCall.args.0.detail.consent") .should("deep.equal", { - consent: { - [PRIVACY_NOTICE_KEY_1]: false, - [PRIVACY_NOTICE_KEY_2]: true, - }, + [PRIVACY_NOTICE_KEY_1]: false, + [PRIVACY_NOTICE_KEY_2]: true, }); }); }); @@ -1378,30 +1363,24 @@ describe("Consent banner", () => { }); cy.get("@FidesInitialized") .should("have.been.calledOnce") - .its("firstCall.args.0.detail") + .its("firstCall.args.0.detail.consent") .should("deep.equal", { - consent: { - data_sales: false, - tracking: false, - analytics: true, - }, + data_sales: false, + tracking: false, + analytics: true, }); cy.get("@FidesUpdated") - .its("firstCall.args.0.detail") + .its("firstCall.args.0.detail.consent") .should("deep.equal", { - consent: { - data_sales: false, - tracking: false, - analytics: true, - }, + data_sales: false, + tracking: false, + analytics: true, }); cy.get("@FidesUpdated") - .its("secondCall.args.0.detail") + .its("secondCall.args.0.detail.consent") .should("deep.equal", { - consent: { - [PRIVACY_NOTICE_KEY_1]: false, - [PRIVACY_NOTICE_KEY_2]: true, - }, + [PRIVACY_NOTICE_KEY_1]: false, + [PRIVACY_NOTICE_KEY_2]: true, }); }); }); @@ -1443,31 +1422,25 @@ describe("Consent banner", () => { }); cy.get("@FidesInitialized") .should("have.been.calledOnce") - .its("firstCall.args.0.detail") + .its("firstCall.args.0.detail.consent") .should("deep.equal", { - consent: { - data_sales: false, - tracking: false, - analytics: true, - }, + data_sales: false, + tracking: false, + analytics: true, }); cy.get("@FidesUpdated") - .its("firstCall.args.0.detail") + .its("firstCall.args.0.detail.consent") .should("deep.equal", { - consent: { - data_sales: false, - tracking: false, - analytics: true, - }, + data_sales: false, + tracking: false, + analytics: true, }); cy.get("@FidesUpdated") - .its("secondCall.args.0.detail") + .its("secondCall.args.0.detail.consent") .should("deep.equal", { - consent: { - data_sales: false, - tracking: false, - analytics: true, - }, + data_sales: false, + tracking: false, + analytics: true, }); }); }); @@ -1508,31 +1481,25 @@ describe("Consent banner", () => { }); cy.get("@FidesInitialized") .should("have.been.calledOnce") - .its("firstCall.args.0.detail") + .its("firstCall.args.0.detail.consent") .should("deep.equal", { - consent: { - data_sales: false, - tracking: false, - analytics: true, - }, + data_sales: false, + tracking: false, + analytics: true, }); cy.get("@FidesUpdated") - .its("firstCall.args.0.detail") + .its("firstCall.args.0.detail.consent") .should("deep.equal", { - consent: { - data_sales: false, - tracking: false, - analytics: true, - }, + data_sales: false, + tracking: false, + analytics: true, }); cy.get("@FidesUpdated") - .its("secondCall.args.0.detail") + .its("secondCall.args.0.detail.consent") .should("deep.equal", { - consent: { - data_sales: false, - tracking: false, - analytics: true, - }, + data_sales: false, + tracking: false, + analytics: true, }); }); }); @@ -1599,4 +1566,90 @@ describe("Consent banner", () => { }); }); }); + + describe("consent reporting", () => { + const experienceId = "experience-id"; + const historyId1 = "pri_mock_history_id_1"; + const historyId2 = "pri_mock_history_id_2"; + + it("can go through consent reporting flow", () => { + stubConfig({ + experience: { + id: experienceId, + show_banner: false, + privacy_notices: [ + mockPrivacyNotice({ + name: "Data Sales and Sharing", + notice_key: "data_sales_and_sharing", + privacy_notice_history_id: historyId1, + }), + mockPrivacyNotice({ + name: "Essential", + notice_key: "essential", + privacy_notice_history_id: historyId2, + }), + ], + }, + }); + cy.get("@FidesUIShown").should("not.have.been.called"); + cy.get("#fides-modal-link").click(); + cy.get("@FidesUIShown").should("have.been.calledOnce"); + cy.wait("@patchNoticesServed").then((interception) => { + const { browser_identity: identity, ...body } = + interception.request.body; + expect(identity.fides_user_device_id).to.be.a("string"); + expect(body).to.eql({ + privacy_experience_id: experienceId, + user_geography: "us_ca", + acknowledge_mode: false, + serving_component: "overlay", + privacy_notice_history_ids: [historyId1, historyId2], + }); + // Now opt out of the notices + cy.getByTestId("consent-modal").within(() => { + cy.get("button").contains("Reject Test").click(); + }); + // The patch should include the served notice IDs (response from patchNoticesServed) + cy.wait("@patchPrivacyPreference").then((preferenceInterception) => { + const { preferences } = preferenceInterception.request.body; + const expected = interception.response?.body.map( + (s: LastServedNoticeSchema) => s.served_notice_history_id + ); + expect( + preferences.map( + (p: ConsentOptionCreate) => p.served_notice_history_id + ) + ).to.eql(expected); + }); + }); + }); + + it("can set acknowledge mode to true", () => { + stubConfig({ + experience: { + id: experienceId, + show_banner: true, + privacy_notices: [ + mockPrivacyNotice({ + name: "Data Sales and Sharing", + notice_key: "data_sales_and_sharing", + consent_mechanism: ConsentMechanism.NOTICE_ONLY, + privacy_notice_history_id: historyId1, + }), + mockPrivacyNotice({ + name: "Essential", + notice_key: "essential", + consent_mechanism: ConsentMechanism.NOTICE_ONLY, + privacy_notice_history_id: historyId2, + }), + ], + }, + }); + cy.get("@FidesUIShown").should("have.been.calledOnce"); + cy.get("#fides-modal-link").click(); + cy.wait("@patchNoticesServed").then((interception) => { + expect(interception.request.body.acknowledge_mode).to.eql(true); + }); + }); + }); }); diff --git a/clients/privacy-center/cypress/e2e/consent-notices.cy.ts b/clients/privacy-center/cypress/e2e/consent-notices.cy.ts index ce382b73c6..8cd2e8d243 100644 --- a/clients/privacy-center/cypress/e2e/consent-notices.cy.ts +++ b/clients/privacy-center/cypress/e2e/consent-notices.cy.ts @@ -1,5 +1,6 @@ import { ConsentOptionCreate, + LastServedNoticeSchema, PrivacyNoticeResponseWithUserPreferences, } from "~/types/api"; import { CONSENT_COOKIE_NAME, FidesCookie } from "fides-js"; @@ -9,6 +10,9 @@ const VERIFICATION_CODE = "112358"; const PRIVACY_NOTICE_ID_1 = "pri_b4360591-3cc7-400d-a5ff-a9f095ab3061"; const PRIVACY_NOTICE_ID_2 = "pri_b558ab1f-5367-4f0d-94b1-ec06a81ae821"; const PRIVACY_NOTICE_ID_3 = "pri_4bed96d0-b9e3-4596-a807-26b783836375"; +const PRIVACY_NOTICE_HISTORY_ID_1 = "pri_df14051b-1eaf-4f07-ae63-232bffd2dc3e"; +const PRIVACY_NOTICE_HISTORY_ID_2 = "pri_b2a0a2fa-ef59-4f7d-8e3d-d2e9bd076707"; +const PRIVACY_NOTICE_HISTORY_ID_3 = "pri_b09058a7-9f54-4360-8da5-4521e8975d4e"; const PRIVACY_EXPERIENCE_ID = "pri_041acb07-c99b-4085-a435-c0d6f3a42b6f"; const GEOLOCATION_API_URL = "https://www.example.com/location"; const SETTINGS = { @@ -56,6 +60,12 @@ describe("Privacy notice driven consent", () => { fixture: "consent/privacy_preferences.json", } ).as("patchPrivacyPreference"); + // Consent reporting intercept + cy.intercept( + "PATCH", + `${API_URL}/consent-request/consent-request-id/notices-served`, + { fixture: "consent/notices_served.json" } + ).as("patchNoticesServed"); }); describe("when user has not consented before", () => { @@ -300,4 +310,47 @@ describe("Privacy notice driven consent", () => { }); }); }); + + describe("consent reporting", () => { + beforeEach(() => { + // Make the fixture's privacy notice history Ids match + cy.fixture("consent/notices_served.json").then((fixture) => { + // the fixture only has 2 entries, so add a third to match the experience payload + const body = [...fixture, JSON.parse(JSON.stringify(fixture[0]))]; + body[0].privacy_notice_history.id = PRIVACY_NOTICE_HISTORY_ID_1; + body[1].privacy_notice_history.id = PRIVACY_NOTICE_HISTORY_ID_2; + body[2].privacy_notice_history.id = PRIVACY_NOTICE_HISTORY_ID_3; + cy.intercept( + "PATCH", + `${API_URL}/consent-request/consent-request-id/notices-served`, + { body } + ).as("patchMatchingNoticesServed"); + }); + cy.visit("/consent"); + cy.getByTestId("consent"); + cy.overrideSettings(SETTINGS); + }); + + it("can make calls to consent reporting endpoints", () => { + cy.wait("@patchMatchingNoticesServed").then((interception) => { + expect(interception.request.body.privacy_notice_history_ids).to.eql([ + PRIVACY_NOTICE_HISTORY_ID_1, + PRIVACY_NOTICE_HISTORY_ID_2, + PRIVACY_NOTICE_HISTORY_ID_3, + ]); + cy.getByTestId("save-btn").click(); + cy.wait("@patchPrivacyPreference").then((preferenceInterception) => { + const { preferences } = preferenceInterception.request.body; + const expected = interception.response?.body.map( + (s: LastServedNoticeSchema) => s.served_notice_history_id + ); + expect( + preferences.map( + (p: ConsentOptionCreate) => p.served_notice_history_id + ) + ).to.eql(expected); + }); + }); + }); + }); }); diff --git a/clients/privacy-center/cypress/fixtures/consent/notices_served.json b/clients/privacy-center/cypress/fixtures/consent/notices_served.json new file mode 100644 index 0000000000..d841132dce --- /dev/null +++ b/clients/privacy-center/cypress/fixtures/consent/notices_served.json @@ -0,0 +1,56 @@ +[ + { + "id": "las_7bc77963-2ab1-4af8-8643-803c5c219f1d", + "updated_at": "2023-07-20T17:59:07.114527+00:00", + "privacy_notice_history": { + "name": "Data Sales and Sharing", + "notice_key": "data_sales_and_sharing", + "description": "We may transfer or share your personal information to third parties in exchange for monetary or other valuable consideration or for the purposes of cross-contextual targeted advertising. You can learn more about what information is used for this purpose in our privacy notice.", + "internal_description": "“Sale of personal“ data means the exchange of personal data for monetary or other valuable consideration. Data sharing refers to sharing of data with third parties for the purpose of cross contextual behavioral advertising. This is also closely analogous to “Targeted Advertising” as defined in other U.S. state laws and they have been combined here under one notice.", + "origin": "pri_309d287c-b208-4fd1-93b2-7b2ff13eddat", + "regions": ["us_ca", "us_co", "us_ct", "us_ia", "us_ut", "us_va"], + "consent_mechanism": "opt_out", + "data_uses": [ + "marketing.advertising.first_party.targeted", + "marketing.advertising.third_party.targeted" + ], + "enforcement_level": "frontend", + "disabled": false, + "has_gpc_flag": true, + "displayed_in_privacy_center": true, + "displayed_in_overlay": true, + "displayed_in_api": false, + "id": "pri_mock_history_id_1", + "version": 1.0, + "privacy_notice_id": "pri_mock_id_1" + }, + "served_notice_history_id": "ser_153d302f-4007-4f72-bbcd-2d5b2610c6a6" + }, + { + "id": "las_e6ed634c-3188-4884-a2cf-23ac813bffbb", + "updated_at": "2023-07-20T19:42:45.832742+00:00", + "privacy_notice_history": { + "name": "Essential", + "notice_key": "essential", + "description": "Essential behavior", + "internal_description": "", + "origin": "pri_309d287c-b208-4fd1-93b2-7b2ff13eddat", + "regions": ["us_ca", "us_co", "us_ct", "us_ia", "us_ut", "us_va"], + "consent_mechanism": "opt_out", + "data_uses": [ + "marketing.advertising.first_party.targeted", + "marketing.advertising.third_party.targeted" + ], + "enforcement_level": "frontend", + "disabled": false, + "has_gpc_flag": true, + "displayed_in_privacy_center": true, + "displayed_in_overlay": true, + "displayed_in_api": false, + "id": "pri_mock_history_id_2", + "version": 1.0, + "privacy_notice_id": "pri_mock_id_2" + }, + "served_notice_history_id": "ser_e4c4f2c7-e884-4763-8447-69691890da80" + } +] diff --git a/clients/privacy-center/cypress/support/commands.ts b/clients/privacy-center/cypress/support/commands.ts index fdd4f12b6a..9918944a92 100644 --- a/clients/privacy-center/cypress/support/commands.ts +++ b/clients/privacy-center/cypress/support/commands.ts @@ -67,6 +67,7 @@ Cypress.Commands.add("visitConsentDemo", (options?: FidesConfig) => { cy.stub().as("FidesInitialized") ); win.addEventListener("FidesUpdated", cy.stub().as("FidesUpdated")); + win.addEventListener("FidesUIShown", cy.stub().as("FidesUIShown")); // Add GTM stub // eslint-disable-next-line no-param-reassign diff --git a/clients/privacy-center/features/consent/consent.slice.ts b/clients/privacy-center/features/consent/consent.slice.ts index 8f5d716539..990abc5d89 100644 --- a/clients/privacy-center/features/consent/consent.slice.ts +++ b/clients/privacy-center/features/consent/consent.slice.ts @@ -9,6 +9,8 @@ import { ConsentPreferences, ConsentPreferencesWithVerificationCode, CurrentPrivacyPreferenceSchema, + LastServedNoticeSchema, + NoticesServedRequest, Page_PrivacyExperienceResponse_, PrivacyNoticeRegion, PrivacyPreferencesRequest, @@ -91,6 +93,16 @@ export const consentApi = baseApi.injectEndpoints({ method: "GET", }), }), + updateNoticesServed: build.mutation< + LastServedNoticeSchema[], + { id: string; body: NoticesServedRequest } + >({ + query: ({ id, body }) => ({ + url: `${VerificationType.ConsentRequest}/${id}/notices-served`, + method: "PATCH", + body, + }), + }), }), }); @@ -101,6 +113,7 @@ export const { useGetPrivacyExperienceQuery, useUpdatePrivacyPreferencesMutation, useGetUserGeolocationQuery, + useUpdateNoticesServedMutation, } = consentApi; type State = { diff --git a/clients/privacy-center/types/api/index.ts b/clients/privacy-center/types/api/index.ts index 6262763da9..77ef74f673 100644 --- a/clients/privacy-center/types/api/index.ts +++ b/clients/privacy-center/types/api/index.ts @@ -16,6 +16,8 @@ export { EnforcementLevel } from "./models/EnforcementLevel"; export type { ExperienceConfigResponse } from "./models/ExperienceConfigResponse"; export type { Identity } from "./models/Identity"; export type { IdentityVerificationConfigResponse } from "./models/IdentityVerificationConfigResponse"; +export type { LastServedNoticeSchema } from "./models/LastServedNoticeSchema"; +export type { NoticesServedRequest } from "./models/NoticesServedRequest"; export type { Page_PrivacyExperienceResponse_ } from "./models/Page_PrivacyExperienceResponse_"; export type { PrivacyExperienceResponse } from "./models/PrivacyExperienceResponse"; export type { PrivacyNoticeHistorySchema } from "./models/PrivacyNoticeHistorySchema"; @@ -23,4 +25,5 @@ export { PrivacyNoticeRegion } from "./models/PrivacyNoticeRegion"; export type { PrivacyNoticeResponse } from "./models/PrivacyNoticeResponse"; export type { PrivacyNoticeResponseWithUserPreferences } from "./models/PrivacyNoticeResponseWithUserPreferences"; export type { PrivacyPreferencesRequest } from "./models/PrivacyPreferencesRequest"; +export { ServingComponent } from "./models/ServingComponent"; export { UserConsentPreference } from "./models/UserConsentPreference"; diff --git a/clients/privacy-center/types/api/models/ConsentMethod.ts b/clients/privacy-center/types/api/models/ConsentMethod.ts index 951ebc7a4e..8d0b4dd52c 100644 --- a/clients/privacy-center/types/api/models/ConsentMethod.ts +++ b/clients/privacy-center/types/api/models/ConsentMethod.ts @@ -8,5 +8,5 @@ export enum ConsentMethod { BUTTON = "button", GPC = "gpc", - API = "api", + INDIVIDUAL_NOTICE = "individual_notice", } diff --git a/clients/privacy-center/types/api/models/ConsentOptionCreate.ts b/clients/privacy-center/types/api/models/ConsentOptionCreate.ts index d79b860804..66c42c3ddb 100644 --- a/clients/privacy-center/types/api/models/ConsentOptionCreate.ts +++ b/clients/privacy-center/types/api/models/ConsentOptionCreate.ts @@ -10,4 +10,5 @@ import type { UserConsentPreference } from "./UserConsentPreference"; export type ConsentOptionCreate = { privacy_notice_history_id: string; preference: UserConsentPreference; + served_notice_history_id?: string; }; diff --git a/clients/privacy-center/types/api/models/LastServedNoticeSchema.ts b/clients/privacy-center/types/api/models/LastServedNoticeSchema.ts new file mode 100644 index 0000000000..946448da9a --- /dev/null +++ b/clients/privacy-center/types/api/models/LastServedNoticeSchema.ts @@ -0,0 +1,15 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { PrivacyNoticeHistorySchema } from "./PrivacyNoticeHistorySchema"; + +/** + * Schema that surfaces the last version of a notice that was shown to a user + */ +export type LastServedNoticeSchema = { + id: string; + updated_at: string; + privacy_notice_history: PrivacyNoticeHistorySchema; + served_notice_history_id: string; +}; diff --git a/clients/privacy-center/types/api/models/NoticesServedRequest.ts b/clients/privacy-center/types/api/models/NoticesServedRequest.ts new file mode 100644 index 0000000000..c580c447d2 --- /dev/null +++ b/clients/privacy-center/types/api/models/NoticesServedRequest.ts @@ -0,0 +1,19 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { Identity } from "./Identity"; +import type { ServingComponent } from "./ServingComponent"; + +/** + * Request body when indicating that notices were served in the UI + */ +export type NoticesServedRequest = { + browser_identity: Identity; + code?: string; + privacy_notice_history_ids: Array; + privacy_experience_id?: string; + user_geography?: string; + acknowledge_mode?: boolean; + serving_component: ServingComponent; +}; diff --git a/clients/privacy-center/types/api/models/ServingComponent.ts b/clients/privacy-center/types/api/models/ServingComponent.ts new file mode 100644 index 0000000000..c3625c8261 --- /dev/null +++ b/clients/privacy-center/types/api/models/ServingComponent.ts @@ -0,0 +1,13 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * An enumeration. + */ +export enum ServingComponent { + OVERLAY = "overlay", + BANNER = "banner", + PRIVACY_CENTER = "privacy_center", + GPC = "gpc", +}