Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to detect when a user has changed their consent string via manually triggering CMP #348

Open
rforster-dev opened this issue Oct 6, 2023 · 6 comments
Labels
Under Review Issue is getting reviewed by the MO

Comments

@rforster-dev
Copy link

One thing that would be useful that I haven't seen (maybe i'm not looking hard enough!) is:

  • If a user manually triggers the CMP via a link in the footer for example
  • and they make a change that changes their tcString
  • then return a response/event that acknowledges that.

A combination of cmpuishown and useractioncomplete alone doesn't seem to be enough.

I've done a work around which:

  • checks the current tcString on cmpuishown
  • on useractioncomplete if the tcString is now different, then do something (in our case, reload the page).

Is this something that could be worked in as a useful event listener?

@thereis
Copy link

thereis commented Oct 20, 2023

I've been struggling for quite few hours and I did a React hook to handle this situation. IMHO this whole GDPR standard is very complicated with lot's of things to understand and to realize if the user has consented or not through specific topics. Here's what I've done.

I am using Inmobi Choices as CMP, they follow the TCF standard and their script injects __tcfapi through the window object. My project is using Next.JS 13 with App router.

tcfapi.ts

export type TCData = {
  tcString: string;
  tcfPolicyVersion: number;
  cmpId: number;
  cmpVersion: number;
  gdprApplies: boolean | undefined;
  eventStatus: 'tcloaded' | 'cmpuishown' | 'useractioncomplete';
  cmpStatus: string;
  listenerId: number | undefined;
  isServiceSpecific: boolean;
  useNonStandardTexts: boolean;
  publisherCC: string;
  purposeOneTreatment: boolean;
  purpose: {
    consents: {
      [key: string]: boolean;
    };
    legitimateInterests: {
      [key: string]: boolean;
    };
  };
  vendor: {
    consents: {
      [key: string]: boolean;
    };
    legitimateInterests: {
      [key: string]: boolean;
    };
  };
  specialFeatureOptins: {
    [key: string]: boolean;
  };
  publisher: {
    consents: {
      [key: string]: boolean;
    };
    legitimateInterests: {
      [key: string]: boolean;
    };
    customPurpose: {
      consents: {
        [key: string]: boolean;
      };
      legitimateInterests: {
        [key: string]: boolean;
      };
    };
    restrictions: {
      [key: string]: {
        [key: string]: 0 | 1 | 2;
      };
    };
  };
};

export type NonIABVendorsConsents = {
  gdprApplies: boolean;
  metadata: string;
  nonIabVendorConsents: Record<number, boolean>;
};

consent-parser.ts

import { NonIABVendorsConsents, TCData } from '@/types/tcfapi';

// https://vendor-list.consensu.org/v2/vendor-list.json
export class ConsentParser {
  constructor(
    private consentData: TCData,
    private nonIABVendors: NonIABVendorsConsents['nonIabVendorConsents'],
  ) {}

  private getConsent(purposeId: string): boolean {
    return this.consentData.purpose.consents[purposeId] ?? false;
  }

  private getLegitimateInterest(purposeId: string): boolean {
    return this.consentData.purpose.legitimateInterests[purposeId] ?? false;
  }

  canSaveCookies(): boolean {
    return this.getConsent('1');
  }

  canSelectBasicAds(): boolean {
    return this.getConsent('2');
  }

  canCreatePersonalisedAdsProfile(): boolean {
    return this.getConsent('3');
  }

  canSelectPersonalisedAds(): boolean {
    return this.getConsent('4');
  }

  canCreatePersonalisedContentProfile(): boolean {
    return this.getConsent('5');
  }

  canSelectPersonalisedContent(): boolean {
    return this.getConsent('6');
  }

  canMeasureAdPerformance(): boolean {
    return this.getConsent('7');
  }

  canMeasureContentPerformance(): boolean {
    return this.getConsent('8');
  }

  canApplyMarketResearch(): boolean {
    return this.getConsent('9');
  }

  canDevelopAndImproveProducts(): boolean {
    return this.getConsent('10');
  }

  hasLegitimateInterestForStorageAccess(): boolean {
    return this.getLegitimateInterest('1');
  }

  hasLegitimateInterestForBasicAdSelection(): boolean {
    return this.getLegitimateInterest('2');
  }

  hasLegitimateInterestForCreatingPersonalisedAdsProfile(): boolean {
    return this.getLegitimateInterest('3');
  }

  hasLegitimateInterestForSelectingPersonalisedAds(): boolean {
    return this.getLegitimateInterest('4');
  }

  hasLegitimateInterestForCreatingPersonalisedContentProfile(): boolean {
    return this.getLegitimateInterest('5');
  }

  hasLegitimateInterestForSelectingPersonalisedContent(): boolean {
    return this.getLegitimateInterest('6');
  }

  hasLegitimateInterestForAdMeasurement(): boolean {
    return this.getLegitimateInterest('7');
  }

  hasLegitimateInterestForContentMeasurement(): boolean {
    return this.getLegitimateInterest('8');
  }

  hasLegitimateInterestForMarketResearch(): boolean {
    return this.getLegitimateInterest('9');
  }

  hasLegitimateInterestForProductDevelopment(): boolean {
    return this.getLegitimateInterest('10');
  }

  hasOptedInForPreciseGeolocationData(): boolean {
    return this.consentData.specialFeatureOptins['1'] ?? false;
  }

  hasOptedInForActiveDeviceScanning(): boolean {
    return this.consentData.specialFeatureOptins['2'] ?? false;
  }
}

useTCFAPI.ts

'use client';

import { useCallback, useEffect, useState } from 'react';

import { useInterval } from '@mantine/hooks';

import { NonIABVendorsConsents, TCData } from './tcfapi';
import { ConsentParser } from './consent-parser';

/**
 * It will get the defaults tcData properties
 * @see https://vendor-list.consensu.org/v2/vendor-list.json
 */
const useTCFAPI = () => {
  const [isLoading, setIsLoading] = useState(true);

  const [data, setData] = useState<TCData>();
  const [consentManager, setConsentManager] = useState<ConsentParser>();

  const [tcStatus, setTcStatus] = useState<TCData['eventStatus'] | undefined>();

  const [nonIABVendors, setNonIABVendors] =
    useState<NonIABVendorsConsents['nonIabVendorConsents']>();

  const [tcfAPI, setTcfAPI] = useState<any>();

  const { start, active, stop } = useInterval(() => {
    if (
      typeof window !== 'undefined' ||
      typeof (window as any).__tcfapi === 'function'
    ) {
      setTcfAPI(() => (window as any).__tcfapi);
    }
  }, 100);

  // Check if the window object is present in document
  useEffect(() => {
    start();

    return () => {
      stop();
    };
  }, []);

  const _handleGetNonIABVendorConsents = useCallback(
    (nonIabConsent: NonIABVendorsConsents, nonIabSuccess: boolean) => {
      nonIabSuccess && setNonIABVendors(nonIabConsent.nonIabVendorConsents);
    },
    [],
  );

  /**
   * @see https://help.quantcast.com/hc/en-us/articles/13422592233371-Choice-CMP2-CCPA-API-Index-
   */
  useEffect(() => {
    if (!tcfAPI) return;

    if (active) {
      stop();
    }

    tcfAPI('addEventListener', 2, (tcData: TCData, success: boolean) => {
      success && setData(tcData);

      tcfAPI('getNonIABVendorConsents', 2, _handleGetNonIABVendorConsents);

      setTcStatus(tcData.eventStatus);
      
      setIsLoading(false);
    });
  }, [tcfAPI, active]);

  /**
   * @see https://help.quantcast.com/hc/en-us/articles/13422592233371-Choice-CMP2-CCPA-API-Index-
   */
  useEffect(() => {
    if (!tcStatus) return;

    tcfAPI(
      'addEventListener',
      2,
      ({ eventStatus }: TCData, success: boolean) => {
        if (
          success &&
          (eventStatus === 'useractioncomplete' || eventStatus === 'tcloaded')
        ) {
          tcfAPI('getNonIABVendorConsents', 2, _handleGetNonIABVendorConsents);
        }
      },
    );
  }, [tcStatus]);

  useEffect(() => {
    if (!data || !nonIABVendors) return;

    setConsentManager(new ConsentParser(data, nonIABVendors));
  }, [data, nonIABVendors]);

  return { isLoading, consentManager, nonIABVendors };
};

export default useTCFAPI;

It took me the whole night to understand and to adapt this convention but I am very satisfied with the approach. I will not follow any support by my script because it has some changes to attend my needs, but that's what I've done.

@HeinzBaumann
Copy link
Collaborator

@rforster-dev Is what you are requesting something like an event called tcStringHasChanged? Currently the way to do this is how you described it. We can consider adding a new event into the eventhander if this helps further.

@rforster-dev
Copy link
Author

@HeinzBaumann - Yes sort of - we've got a couple of scenarios that are similar to this.

1: Has the user actually consented yet?
Currently, the tcfstring can contain: {} and empty object of consent preferences. This either means:

  • they have rejected all
  • they have not consented yet

Which makes it difficult to understand when a user has actually consented or not. SourcePoint, a CMP handles this by using localStorage and in their kvp, they have a value of hasConsented: <boolean>

We are using this as as stopgap until we can do this natively via the tcf eventlistener.


2: Whether or not a consent string has changed
As described, it's hard to know when a user has actually changed their consent status - we've mitigated this by forcing a refresh when buttons are clicked within the CMP, but this feels quite dirty.
It would be nice if there was an event listener that could be ran and listened to, when a consent string has actually changed

Hope this helps!

@HeinzBaumann HeinzBaumann added the Under Review Issue is getting reviewed by the MO label Nov 7, 2023
@HeinzBaumann
Copy link
Collaborator

@rforster-dev We discussed this at the TCF Framework Signal Working Group meeting (the tech side of the TCF body). It wasn't clear from reviewing this what use case this addresses. The vendor always has to check the content of the TCString after a notification has been fired e.g. do I as vendor with id xy have consent, what are the values of the purposes flags that I need to be aware of, can I operate? The groups understanding is that this can all be done today with the existing event listener and the different APIs.
We are happy to further review this once we understand your use case. Thanks!

@rforster-dev
Copy link
Author

rforster-dev commented Jan 19, 2024

Thanks for responding - apologies I haven't said anything back since. Appreciate the groups time in reviewing this.

OK, A question that maybe i'm lacking understanding in or guidance; in the scenario of:

  • Has the user consented?

What is the best way to determine this, factoring in:

  • if the CMP is being surfaced then they have not consented
  • if they have consented then they have purpose consents stored in tcfapi
  • if they have rejected all, the {} in the purpose consents stored in tcfapi, is empty and so not useful to ascertain if they have either:
    -- not consented yet at all
    -- they have, but rejected all

Does that make sense? Appreciate I might not be wording it particularly great!

@HeinzBaumann
Copy link
Collaborator

If the event listener eventStatus is equal to "useractioncomplete", and the purpose object is empty the user rejected all.
If the event listener eventStatus is equal to "cmpuishown", and the purpose object is empty, the user have not taken action yet.
I hope this helps.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Under Review Issue is getting reviewed by the MO
Projects
None yet
Development

No branches or pull requests

3 participants