diff --git a/README.md b/README.md index a852e936..a81e6908 100644 --- a/README.md +++ b/README.md @@ -414,6 +414,7 @@ The API key permissions for this command vary based on the value to the `resourc | cookies | Consent Manager Cookie definitions. | View Data Flows | false | [Consent Management -> Cookies](https://app.transcend.io/consent-manager/cookies/approved) | | consentManager | Consent Manager general settings, including domain list. | View Consent Manager | false | [Consent Management -> Developer Settings](https://app.transcend.io/consent-manager/developer-settings) | | purposes | Consent purposes and related preference management topics. | View Consent Manager,View Preference Store Settings | false | [Consent Management -> Regional Experiences -> Purposes](https://app.transcend.io/consent-manager/regional-experiences/purposes) | +| preferenceOptions | Preference management options for multi and single selects | View Preference Store Settings | false | [Preference Management -> Preference Topics -> Options](https://app.transcend.io/preference-store/preference-topics/preference-options) | | partitions | The partitions in the account (often representative of separate data controllers). | View Consent Manager | false | [Consent Management -> Developer Settings -> Advanced Settings](https://app.transcend.io/consent-manager/developer-settings/advanced-settings) | | prompts | The Transcend AI prompts | View Prompts | false | [Prompt Manager -> Browse](https://app.transcend.io/prompts/browse) | | promptPartials | The Transcend AI prompt partials | View Prompts | false | [Prompt Manager -> Partials](https://app.transcend.io/prompts/partialss) | @@ -547,7 +548,7 @@ tr-pull --auth=$TRANSCEND_API_KEY --resources=actions Pull in consent manager purposes and preference management topics (see [this example](./examples/purposes.yml)): ```sh -tr-pull --auth=$TRANSCEND_API_KEY --resources=purposes +tr-pull --auth=$TRANSCEND_API_KEY --resources=purposes,preferenceOptions ``` Pull in request data subject configurations (see [this example](./examples/data-subjects.yml)): @@ -669,36 +670,38 @@ In order to use this cli, you will first need to generate an API key on the Tran The API key needs the following scopes when pushing the various resource types: -| Key | Description | Scope | Is Default | Link | -| --------------------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| apiKeys | API Key definitions. API keys cannot be created through the cli, but you can map API key usage to Data Silos. | View API Keys | true | [Developer Tools -> API keys](https://app.transcend.io/infrastructure/api-keys) | -| actionItems | Onboarding related action items | Manage Action Item Collections | false | [Action Items](https://app.transcend.io/action-items/all) | -| actionItemCollections | Onboarding related action item group names | Manage All Action Items | false | [Action Items](https://app.transcend.io/action-items/all) | -| templates | Email templates. Only template titles can be created and mapped to other resources. | Manage Email Templates | true | [DSR Automation -> Email Templates](https://app.transcend.io/privacy-requests/email-templates) | -| dataSilos | The Data Silo/Integration definitions. | Manage Data Map,Connect Data Silos | true | [Data Inventory -> Data Silos](https://app.transcend.io/data-map/data-inventory/) and [Infrastucture -> Integrations](https://app.transcend.io/infrastructure/integrationsdata-silos) | -| enrichers | The Privacy Request enricher configurations. | Manage Request Identity Verification | true | [DSR Automation -> Identifiers](https://app.transcend.io/privacy-requests/identifiers) | -| teams | The team definitions containing scopes and users | Manage Access Control | false | [Administration -> Teams](https://app.transcend.io/admin/teams) | -| businessEntities | The business entities in the data inventory. | Manage Data Inventory | false | [Data Inventory -> Business Entities](https://app.transcend.io/data-map/data-inventory/business-entities) | -| identifiers | The Privacy Request identifier configurations. | Manage Request Identity Verification | false | [DSR Automation -> Identifiers](https://app.transcend.io/privacy-requests/identifiers) | -| actions | The Privacy Request action settings. | Manage Data Subject Request Settings | false | [DSR Automation -> Request Settings](https://app.transcend.io/privacy-requests/settings) | -| dataSubjects | The Privacy Request data subject settings. | Manage Data Subject Request Settings | false | [DSR Automation -> Request Settings](https://app.transcend.io/privacy-requests/settings) | -| vendors | The vendors in the data inventory. | Manage Data Inventory | false | [Data Inventory -> Vendors](https://app.transcend.io/data-map/data-inventory/vendors) | -| dataCategories | The data categories in the data inventory. | Manage Data Inventory | false | [Data Inventory -> Data Categories](https://app.transcend.io/data-map/data-inventory/data-categories) | -| processingPurposes | The processing purposes in the data inventory. | Manage Data Inventory | false | [Data Inventory -> Processing Purposes](https://app.transcend.io/data-map/data-inventory/purposes) | -| attributes | Attribute definitions that define extra metadata for each table in the Admin Dashboard. | Manage Global Attributes | false | [Infrastructure -> Attributes](https://app.transcend.io/infrastructure/attributes) | -| dataFlows | Consent Manager Data Flow definitions. | Manage Data Flows | false | [Consent Management -> Data Flows](https://app.transcend.io/consent-manager/data-flows/approved) | -| cookies | Consent Manager Cookie definitions. | Manage Data Flows | false | [Consent Management -> Cookies](https://app.transcend.io/consent-manager/cookies/approved) | -| consentManager | Consent Manager general settings, including domain list. | Manage Consent Manager Developer Settings | false | [Consent Management -> Developer Settings](https://app.transcend.io/consent-manager/developer-settings) | -| partitions | The partitions in the account (often representative of separate data controllers). | Manage Consent Manager Developer Settings | false | [Consent Management -> Developer Settings -> Advanced Settings](https://app.transcend.io/consent-manager/developer-settings/advanced-settings) | -| prompts | The Transcend AI prompts | View Prompts | false | [Prompt Manager -> Browse](https://app.transcend.io/prompts/browse) | -| promptPartials | The Transcend AI prompt partials | Manage Prompts | false | [Prompt Manager -> Partials](https://app.transcend.io/prompts/partialss) | -| promptGroups | The Transcend AI prompt groups | Manage Prompts | false | [Prompt Manager -> Groups](https://app.transcend.io/prompts/groups) | -| agents | The agents in the prompt manager. | Manage Prompts | false | [Prompt Manager -> Agents](https://app.transcend.io/prompts/agents) | -| agentFunctions | The agent functions in the prompt manager. | Manage Prompts | false | [Prompt Manager -> Agent Functions](https://app.transcend.io/prompts/agent-functions) | -| agentFiles | The agent files in the prompt manager. | Manage Prompts | false | [Prompt Manager -> Agent Files](https://app.transcend.io/prompts/agent-files) | -| privacyCenters | The privacy center configurations. | Manage Privacy Center | false | [Privacy Center](https://app.transcend.io/privacy-center/general-settings) | -| policies | The privacy center policies. | Manage Policies | false | [Privacy Center -> Policies](https://app.transcend.io/privacy-center/policies) | -| messages | Message definitions used across consent, privacy center, email templates and more. | Manage Internationalization Messages | false | [Privacy Center -> Messages](https://app.transcend.io/privacy-center/messages-internationalization), [Consent Management -> Display Settings -> Messages](https://app.transcend.io/consent-manager/display-settings/messages) | +| Key | Description | Scope | Is Default | Link | +| --------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| apiKeys | API Key definitions. API keys cannot be created through the cli, but you can map API key usage to Data Silos. | View API Keys | true | [Developer Tools -> API keys](https://app.transcend.io/infrastructure/api-keys) | +| actionItems | Onboarding related action items | Manage Action Item Collections | false | [Action Items](https://app.transcend.io/action-items/all) | +| actionItemCollections | Onboarding related action item group names | Manage All Action Items | false | [Action Items](https://app.transcend.io/action-items/all) | +| templates | Email templates. Only template titles can be created and mapped to other resources. | Manage Email Templates | true | [DSR Automation -> Email Templates](https://app.transcend.io/privacy-requests/email-templates) | +| dataSilos | The Data Silo/Integration definitions. | Manage Data Map,Connect Data Silos | true | [Data Inventory -> Data Silos](https://app.transcend.io/data-map/data-inventory/) and [Infrastucture -> Integrations](https://app.transcend.io/infrastructure/integrationsdata-silos) | +| enrichers | The Privacy Request enricher configurations. | Manage Request Identity Verification | true | [DSR Automation -> Identifiers](https://app.transcend.io/privacy-requests/identifiers) | +| teams | The team definitions containing scopes and users | Manage Access Control | false | [Administration -> Teams](https://app.transcend.io/admin/teams) | +| businessEntities | The business entities in the data inventory. | Manage Data Inventory | false | [Data Inventory -> Business Entities](https://app.transcend.io/data-map/data-inventory/business-entities) | +| identifiers | The Privacy Request identifier configurations. | Manage Request Identity Verification | false | [DSR Automation -> Identifiers](https://app.transcend.io/privacy-requests/identifiers) | +| actions | The Privacy Request action settings. | Manage Data Subject Request Settings | false | [DSR Automation -> Request Settings](https://app.transcend.io/privacy-requests/settings) | +| dataSubjects | The Privacy Request data subject settings. | Manage Data Subject Request Settings | false | [DSR Automation -> Request Settings](https://app.transcend.io/privacy-requests/settings) | +| vendors | The vendors in the data inventory. | Manage Data Inventory | false | [Data Inventory -> Vendors](https://app.transcend.io/data-map/data-inventory/vendors) | +| dataCategories | The data categories in the data inventory. | Manage Data Inventory | false | [Data Inventory -> Data Categories](https://app.transcend.io/data-map/data-inventory/data-categories) | +| processingPurposes | The processing purposes in the data inventory. | Manage Data Inventory | false | [Data Inventory -> Processing Purposes](https://app.transcend.io/data-map/data-inventory/purposes) | +| attributes | Attribute definitions that define extra metadata for each table in the Admin Dashboard. | Manage Global Attributes | false | [Infrastructure -> Attributes](https://app.transcend.io/infrastructure/attributes) | +| dataFlows | Consent Manager Data Flow definitions. | Manage Data Flows | false | [Consent Management -> Data Flows](https://app.transcend.io/consent-manager/data-flows/approved) | +| cookies | Consent Manager Cookie definitions. | Manage Data Flows | false | [Consent Management -> Cookies](https://app.transcend.io/consent-manager/cookies/approved) | +| purposes | Consent and preference management purposes and topics | Manage Consent Manager,ManagePreference Store Settings | false | [Consent Management -> Regional Experienecs -> Purposes](https://app.transcend.io/consent-manager/regional-experiences/purposes) | +| preferenceOptions | Preference management options for multi and single selects | Manage Preference Store Settings | false | [Preference Management -> Preference Topics -> Options](https://app.transcend.io/preference-store/preference-topics/preference-options) | +| consentManager | Consent Manager general settings, including domain list. | Manage Consent Manager Developer Settings | false | [Consent Management -> Developer Settings](https://app.transcend.io/consent-manager/developer-settings) | +| partitions | The partitions in the account (often representative of separate data controllers). | Manage Consent Manager Developer Settings | false | [Consent Management -> Developer Settings -> Advanced Settings](https://app.transcend.io/consent-manager/developer-settings/advanced-settings) | +| prompts | The Transcend AI prompts | View Prompts | false | [Prompt Manager -> Browse](https://app.transcend.io/prompts/browse) | +| promptPartials | The Transcend AI prompt partials | Manage Prompts | false | [Prompt Manager -> Partials](https://app.transcend.io/prompts/partialss) | +| promptGroups | The Transcend AI prompt groups | Manage Prompts | false | [Prompt Manager -> Groups](https://app.transcend.io/prompts/groups) | +| agents | The agents in the prompt manager. | Manage Prompts | false | [Prompt Manager -> Agents](https://app.transcend.io/prompts/agents) | +| agentFunctions | The agent functions in the prompt manager. | Manage Prompts | false | [Prompt Manager -> Agent Functions](https://app.transcend.io/prompts/agent-functions) | +| agentFiles | The agent files in the prompt manager. | Manage Prompts | false | [Prompt Manager -> Agent Files](https://app.transcend.io/prompts/agent-files) | +| privacyCenters | The privacy center configurations. | Manage Privacy Center | false | [Privacy Center](https://app.transcend.io/privacy-center/general-settings) | +| policies | The privacy center policies. | Manage Policies | false | [Privacy Center -> Policies](https://app.transcend.io/privacy-center/policies) | +| messages | Message definitions used across consent, privacy center, email templates and more. | Manage Internationalization Messages | false | [Privacy Center -> Messages](https://app.transcend.io/privacy-center/messages-internationalization), [Consent Management -> Display Settings -> Messages](https://app.transcend.io/consent-manager/display-settings/messages) | #### Arguments diff --git a/examples/purposes.yml b/examples/purposes.yml index 6e256941..714b40aa 100644 --- a/examples/purposes.yml +++ b/examples/purposes.yml @@ -63,8 +63,8 @@ purposes: - DNT auth-level: REQUIRED preference-topics: [] - - name: HealthData - title: HealthData + - name: Health Data + title: Health Data description: Trackers related to selling/sharing health data trackingType: HealthData default-consent: 'off' @@ -194,8 +194,8 @@ purposes: display-order: 10 auth-level: REQUIRED preference-topics: [] - - name: SessionReplay - title: SessionReplay + - name: Session Replay + title: Session Replay description: Session replay data flows and cookies (like FullStory) trackingType: SessionReplay default-consent: 'off' diff --git a/package.json b/package.json index 7d01f7a7..268eec58 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Transcend Inc.", "name": "@transcend-io/cli", "description": "Small package containing useful typescript utilities.", - "version": "6.26.0", + "version": "6.27.0", "homepage": "https://github.com/transcend-io/cli", "repository": { "type": "git", diff --git a/src/codecs.ts b/src/codecs.ts index 47d072ef..0dcca3bb 100644 --- a/src/codecs.ts +++ b/src/codecs.ts @@ -1777,7 +1777,7 @@ export const AssessmentInput = t.intersection([ /** Type override */ export type AssessmentInput = t.TypeOf; -export const ConsentPreferenceTopicOptionValue = t.type({ +export const PreferenceTopicOptionValueInput = t.type({ /** Title of option value */ title: t.string, /** API slug */ @@ -1785,11 +1785,11 @@ export const ConsentPreferenceTopicOptionValue = t.type({ }); /** Type override */ -export type ConsentPreferenceTopicOptionValue = t.TypeOf< - typeof ConsentPreferenceTopicOptionValue +export type PreferenceTopicOptionValueInput = t.TypeOf< + typeof PreferenceTopicOptionValueInput >; -export const ConsentPreferenceTopic = t.intersection([ +export const PreferenceTopicInput = t.intersection([ t.type({ /** The type of the preference topic */ type: valuesOf(PreferenceTopicType), @@ -1797,6 +1797,8 @@ export const ConsentPreferenceTopic = t.intersection([ title: t.string, /** The description of the preference topic */ description: t.string, + /** The slug of the preference topic */ + slug: t.string, }), t.partial({ /** Default value */ @@ -1804,17 +1806,17 @@ export const ConsentPreferenceTopic = t.intersection([ /** Whether the preference topic is shown in privacy center */ 'show-in-privacy-center': t.boolean, /** The options when type is single or multi select */ - options: t.array(ConsentPreferenceTopicOptionValue), + options: t.array(t.string), }), ]); /** Type override */ -export type ConsentPreferenceTopic = t.TypeOf; +export type PreferenceTopicInput = t.TypeOf; -export const ConsentPurpose = t.intersection([ +export const PurposeInput = t.intersection([ t.type({ /** Consent purpose slug */ - trackingType: t.string, + 'tracking-type': t.string, /** The title of the tracking purpose that appears in Consent Management and Privacy Center UIs */ title: t.string, /** The display name of this tracking purpose */ @@ -1834,7 +1836,7 @@ export const ConsentPurpose = t.intersection([ /** Whether purpose is show in consent manger */ 'show-in-consent-manager': t.boolean, /** The preference topics configured for the purpose */ - 'preference-topics': t.array(ConsentPreferenceTopic), + 'preference-topics': t.array(PreferenceTopicInput), /** Authentication level for purpose on privacy center */ 'auth-level': valuesOf(PreferenceStoreAuthLevel), /** Opt out signals that should instantly opt out of this purpose */ @@ -1845,7 +1847,7 @@ export const ConsentPurpose = t.intersection([ ]); /** Type override */ -export type ConsentPurpose = t.TypeOf; +export type PurposeInput = t.TypeOf; export const TranscendInput = t.partial({ /** @@ -1967,7 +1969,11 @@ export const TranscendInput = t.partial({ /** * Consent and preference management purposes */ - purposes: t.array(ConsentPurpose), + purposes: t.array(PurposeInput), + /** + * Preference management options + */ + 'preference-options': t.array(PreferenceTopicOptionValueInput), }); /** Type override */ diff --git a/src/constants.ts b/src/constants.ts index 488875b4..4f39c898 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -75,6 +75,9 @@ export const TR_PUSH_RESOURCE_SCOPE_MAP: { ScopeName.ManageConsentManager, ScopeName.ManagePreferenceStoreSettings, ], + [TranscendPullResource.PreferenceOptions]: [ + ScopeName.ManagePreferenceStoreSettings, + ], }; /** @@ -122,6 +125,9 @@ export const TR_PULL_RESOURCE_SCOPE_MAP: { ScopeName.ViewConsentManager, ScopeName.ViewPreferenceStoreSettings, ], + [TranscendPullResource.PreferenceOptions]: [ + ScopeName.ViewPreferenceStoreSettings, + ], }; export const TR_YML_RESOURCE_TO_FIELD_NAME: Record< @@ -159,4 +165,5 @@ export const TR_YML_RESOURCE_TO_FIELD_NAME: Record< [TranscendPullResource.Assessments]: 'assessments', [TranscendPullResource.AssessmentTemplates]: 'assessment-templates', [TranscendPullResource.Purposes]: 'purposes', + [TranscendPullResource.PreferenceOptions]: 'preference-options', }; diff --git a/src/enums.ts b/src/enums.ts index 2c3d30da..427ad95b 100644 --- a/src/enums.ts +++ b/src/enums.ts @@ -55,6 +55,7 @@ export enum TranscendPullResource { Assessments = 'assessments', AssessmentTemplates = 'assessmentTemplates', Purposes = 'purposes', + PreferenceOptions = 'preferenceOptions', } /** diff --git a/src/graphql/fetchAllPreferenceOptionValues.ts b/src/graphql/fetchAllPreferenceOptionValues.ts new file mode 100644 index 00000000..0037d088 --- /dev/null +++ b/src/graphql/fetchAllPreferenceOptionValues.ts @@ -0,0 +1,55 @@ +import { GraphQLClient } from 'graphql-request'; +import { PREFERENCE_OPTION_VALUES } from './gqls'; +import { makeGraphQLRequest } from './makeGraphQLRequest'; + +export interface PreferenceOptionValue { + /** ID of preference option value */ + id: string; + /** Slug of preference option value */ + slug: string; + /** Title of preference option value */ + title: { + /** ID */ + id: string; + /** Default message */ + defaultMessage: string; + }; +} + +const PAGE_SIZE = 20; + +/** + * Fetch all preference option values in the organization + * + * @param client - GraphQL client + * @returns All preference option values in the organization + */ +export async function fetchAllPreferenceOptionValues( + client: GraphQLClient, +): Promise { + const preferenceOptionValues: PreferenceOptionValue[] = []; + let offset = 0; + + // Whether to continue looping + let shouldContinue = false; + do { + const { + preferenceOptionValues: { nodes }, + // eslint-disable-next-line no-await-in-loop + } = await makeGraphQLRequest<{ + /** Preference option values */ + preferenceOptionValues: { + /** List */ + nodes: PreferenceOptionValue[]; + }; + }>(client, PREFERENCE_OPTION_VALUES, { + first: PAGE_SIZE, + offset, + }); + preferenceOptionValues.push(...nodes); + offset += PAGE_SIZE; + shouldContinue = nodes.length === PAGE_SIZE; + } while (shouldContinue); + + return preferenceOptionValues.sort((a, b) => a.slug.localeCompare(b.slug)); +} diff --git a/src/graphql/gqls/preferenceTopic.ts b/src/graphql/gqls/preferenceTopic.ts index 6aa0fcc2..fb470819 100644 --- a/src/graphql/gqls/preferenceTopic.ts +++ b/src/graphql/gqls/preferenceTopic.ts @@ -42,3 +42,44 @@ export const PREFERENCE_TOPICS = gql` } } `; + +export const CREATE_OR_UPDATE_PREFERENCE_TOPIC = gql` + mutation CreateOrUpdatePreferenceTopic( + $input: CreateOrUpdatePreferenceTopicInput! + ) { + createOrUpdatePreferenceTopic(input: $input) { + preferenceTopic { + id + } + } + } +`; + +export const CREATE_OR_UPDATE_PREFERENCE_OPTION_VALUES = gql` + mutation CreateOrUpdatePreferenceOptionValues( + $input: CreateOrUpdatePreferenceOptionValuesInput! + ) { + createOrUpdatePreferenceOptionValues(input: $input) { + preferenceOptionValues { + id + slug + } + } + } +`; + +export const PREFERENCE_OPTION_VALUES = gql` + query PreferenceOptionValues { + preferenceOptionValues { + clientMutationId + nodes { + id + title { + id + defaultMessage + } + slug + } + } + } +`; diff --git a/src/graphql/gqls/purpose.ts b/src/graphql/gqls/purpose.ts index 3bb70aae..314868da 100644 --- a/src/graphql/gqls/purpose.ts +++ b/src/graphql/gqls/purpose.ts @@ -40,3 +40,25 @@ export const PURPOSES = gql` } } `; + +export const UPDATE_PURPOSE = gql` + mutation TranscendCliUpdatePurpose($input: UpdatePurposeInput!) { + updatePurpose(input: $input) { + clientMutationId + purpose { + id + } + } + } +`; + +export const CREATE_PURPOSE = gql` + mutation TranscendCliCreatePurpose($input: CreatePurposeInput!) { + createPurpose(input: $input) { + clientMutationId + purpose { + id + } + } + } +`; diff --git a/src/graphql/index.ts b/src/graphql/index.ts index 9642164d..1f4192e6 100644 --- a/src/graphql/index.ts +++ b/src/graphql/index.ts @@ -20,6 +20,7 @@ export * from './setResourceAttributes'; export * from './buildTranscendGraphQLClient'; export * from './retryRequestEnricher'; export * from './gqls'; +export * from './syncPreferenceOptionValues'; export * from './fetchAllAssessmentTemplates'; export * from './fetchAllAssessments'; export * from './fetchPromptThreads'; diff --git a/src/graphql/pullTranscendConfiguration.ts b/src/graphql/pullTranscendConfiguration.ts index b13a7bb3..b05bdf21 100644 --- a/src/graphql/pullTranscendConfiguration.ts +++ b/src/graphql/pullTranscendConfiguration.ts @@ -32,7 +32,8 @@ import { AssessmentSectionInput, AssessmentSectionQuestionInput, RiskLogicInput, - ConsentPurpose, + PurposeInput, + PreferenceTopicOptionValueInput, } from '../codecs'; import { RequestAction, @@ -84,6 +85,7 @@ import { fetchAllActionItemCollections } from './fetchAllActionItemCollections'; import { LanguageKey } from '@transcend-io/internationalization'; import { fetchPartitions } from './syncPartitions'; import { fetchAllAssessments } from './fetchAllAssessments'; +import { fetchAllPreferenceOptionValues } from './fetchAllPreferenceOptionValues'; import { fetchAllAssessmentTemplates } from './fetchAllAssessmentTemplates'; import { AssessmentNestedRule, @@ -183,6 +185,7 @@ export async function pullTranscendConfiguration( assessments, assessmentTemplates, purposes, + preferenceOptionValues, ] = await Promise.all([ // Grab all data subjects in the organization resources.includes(TranscendPullResource.DataSilos) || @@ -335,6 +338,10 @@ export async function pullTranscendConfiguration( resources.includes(TranscendPullResource.Purposes) ? fetchAllPurposesAndPreferences(client) : [], + // Fetch preferenceOptionValues + resources.includes(TranscendPullResource.PreferenceOptions) + ? fetchAllPreferenceOptionValues(client) + : [], ]); const consentManagerTheme = @@ -1357,11 +1364,11 @@ export async function pullTranscendConfiguration( topics, showInPrivacyCenter, title, - }): ConsentPurpose => ({ + }): PurposeInput => ({ name, title, description: description || undefined, - trackingType, + 'tracking-type': trackingType, 'default-consent': defaultConsent, configurable, 'show-in-consent-manager': showInConsentManager, @@ -1374,6 +1381,7 @@ export async function pullTranscendConfiguration( ({ title, type, + slug, displayDescription, defaultConfiguration, showInPrivacyCenter, @@ -1381,15 +1389,13 @@ export async function pullTranscendConfiguration( }) => ({ title: title.defaultMessage, type, + slug, description: displayDescription.defaultMessage, 'default-configuration': defaultConfiguration, 'show-in-privacy-center': showInPrivacyCenter, ...(preferenceOptionValues.length > 0 ? { - options: preferenceOptionValues.map(({ title, slug }) => ({ - title: title.defaultMessage, - slug, - })), + options: preferenceOptionValues.map(({ slug }) => slug), } : {}), }), @@ -1398,6 +1404,19 @@ export async function pullTranscendConfiguration( ); } + // save preference options + if ( + preferenceOptionValues.length > 0 && + resources.includes(TranscendPullResource.PreferenceOptions) + ) { + result['preference-options'] = preferenceOptionValues.map( + ({ slug, title }): PreferenceTopicOptionValueInput => ({ + slug, + title: title.defaultMessage, + }), + ); + } + // save email templates if ( dataSiloIds.length === 0 && diff --git a/src/graphql/syncConfigurationToTranscend.ts b/src/graphql/syncConfigurationToTranscend.ts index 9a67c281..595f7d1d 100644 --- a/src/graphql/syncConfigurationToTranscend.ts +++ b/src/graphql/syncConfigurationToTranscend.ts @@ -33,11 +33,13 @@ import { syncTemplate } from './syncTemplates'; import { fetchAllActions } from './fetchAllActions'; import { syncPromptPartials } from './syncPromptPartials'; import { syncPromptGroups } from './syncPromptGroups'; +import { syncPurposes } from './syncPurposes'; import { syncAgents } from './syncAgents'; import { syncActionItemCollections } from './syncActionItemCollections'; import { syncActionItems } from './syncActionItems'; import { syncAgentFunctions } from './syncAgentFunctions'; import { syncAgentFiles } from './syncAgentFiles'; +import { syncPreferenceOptionValues } from './syncPreferenceOptionValues'; import { syncVendors } from './syncVendors'; import { syncDataCategories } from './syncDataCategories'; import { syncProcessingPurposes } from './syncProcessingPurposes'; @@ -105,6 +107,7 @@ export async function syncConfigurationToTranscend( messages, policies, partitions, + purposes, } = input; const [identifierByName, dataSubjectsByName, apiKeyTitleMap] = @@ -259,6 +262,21 @@ export async function syncConfigurationToTranscend( encounteredError = encounteredError || !cookiesSuccess; } + // Sync preference topic values + if (input['preference-options']) { + const preferenceTopicValuesSuccess = await syncPreferenceOptionValues( + client, + input['preference-options'], + ); + encounteredError = encounteredError || !preferenceTopicValuesSuccess; + } + + // Sync purposes + if (purposes) { + const purposesSuccess = await syncPurposes(client, purposes); + encounteredError = encounteredError || !purposesSuccess; + } + // Sync action item collections if (actionItemCollections) { const actionItemCollectionsSuccess = await syncActionItemCollections( diff --git a/src/graphql/syncPreferenceOptionValues.ts b/src/graphql/syncPreferenceOptionValues.ts new file mode 100644 index 00000000..ab844c04 --- /dev/null +++ b/src/graphql/syncPreferenceOptionValues.ts @@ -0,0 +1,100 @@ +import { PreferenceTopicOptionValueInput } from '../codecs'; +import colors from 'colors'; +import { GraphQLClient } from 'graphql-request'; +import { CREATE_OR_UPDATE_PREFERENCE_OPTION_VALUES } from './gqls'; +import { makeGraphQLRequest } from './makeGraphQLRequest'; +import { logger } from '../logger'; +import { fetchAllPreferenceOptionValues } from './fetchAllPreferenceOptionValues'; +import keyBy from 'lodash/keyBy'; + +/** + * Response type for fetching all purposes and preferences. + */ +export type PreferenceOptionValue = { + /** ID */ + id: string; + /** Slug */ + slug: string; +}; + +/** + * Create or update preference option values for a topic. + * + * @param client - GraphQL client + * @param optionValues - Preference option values to create or update, alongside their IDs if they exist + */ +export async function createOrUpdatePreferenceOptionValues( + client: GraphQLClient, + optionValues: [PreferenceTopicOptionValueInput, string | undefined][], +): Promise { + const result = await makeGraphQLRequest<{ + /** createOrUpdatePreferenceOptionValues mutation */ + createOrUpdatePreferenceOptionValues: { + /** Preference option values */ + preferenceOptionValues: PreferenceOptionValue[]; + }; + }>(client, CREATE_OR_UPDATE_PREFERENCE_OPTION_VALUES, { + input: { + input: { + preferenceOptionValues: optionValues.map(([optionValue, id]) => ({ + ...optionValue, + id, + })), + }, + }, + }); + return result.createOrUpdatePreferenceOptionValues.preferenceOptionValues; +} + +/** + * Sync the preference option values + * + * @param client - GraphQL client + * @param optionValues - Preference option values + * @returns True if synced successfully + */ +export async function syncPreferenceOptionValues( + client: GraphQLClient, + optionValues: PreferenceTopicOptionValueInput[], +): Promise { + let encounteredError = false; + logger.info( + colors.magenta( + `Syncing "${optionValues.length}" preference option values...`, + ), + ); + + // Index existing preference option values + const existing = await fetchAllPreferenceOptionValues(client); + const optionValueBySlug = keyBy(existing, 'slug'); + + try { + logger.info( + colors.magenta( + `Performing bulk create or update for "${optionValues.length}" preference option values...`, + ), + ); + + await createOrUpdatePreferenceOptionValues( + client, + optionValues.map((optionValueInput) => [ + optionValueInput, + optionValueBySlug[optionValueInput.slug]?.id, + ]), + ); + + logger.info( + colors.green( + `Successfully synced "${optionValues.length}" preference option values!`, + ), + ); + } catch (err) { + encounteredError = true; + logger.info( + colors.red(`Failed to sync preference option values! - ${err.message}`), + ); + } + + // Return true upon success + return !encounteredError; +} diff --git a/src/graphql/syncPurposes.ts b/src/graphql/syncPurposes.ts new file mode 100644 index 00000000..8cfacc40 --- /dev/null +++ b/src/graphql/syncPurposes.ts @@ -0,0 +1,321 @@ +import { PreferenceTopicInput, PurposeInput } from '../codecs'; +import colors from 'colors'; +import { GraphQLClient } from 'graphql-request'; +import { + UPDATE_PURPOSE, + CREATE_PURPOSE, + CREATE_OR_UPDATE_PREFERENCE_TOPIC, +} from './gqls'; +import { makeGraphQLRequest } from './makeGraphQLRequest'; +import { map } from 'bluebird'; +import { + PurposeWithPreferences, + fetchAllPurposesAndPreferences, +} from './fetchAllPurposesAndPreferences'; +import keyBy from 'lodash/keyBy'; +import { logger } from '../logger'; +import { KnownDefaultPurpose } from '@transcend-io/airgap.js-types'; +import { fetchAllPreferenceOptionValues } from './fetchAllPreferenceOptionValues'; +import { PreferenceOptionValue } from './syncPreferenceOptionValues'; +import { PreferenceTopic } from './fetchAllPreferenceTopics'; + +export interface PreferenceTopicInputOptions { + /** Purpose ID */ + purposeId: string; + /** Preference topics to create or update */ + optionValuesBySlug: Record; + /** Preference topics by slug */ + topicsBySlug: Record; + /** Concurrency for upload */ + concurrency: number; +} + +/** + * Create or update preference topics for a purpose. + * + * @param client - GraphQL client + * @param topics - Preference topics to create or update + * @param options - Options + */ +export async function createOrUpdatePreferenceTopics( + client: GraphQLClient, + topics: PreferenceTopicInput[], + { + purposeId, + optionValuesBySlug, + topicsBySlug, + concurrency = 20, + }: PreferenceTopicInputOptions, +): Promise { + await map( + topics, + async (topic) => { + const existingTopic = topicsBySlug[topic.slug]; + await makeGraphQLRequest(client, CREATE_OR_UPDATE_PREFERENCE_TOPIC, { + input: { + input: { + type: topic.type, + title: topic.title, + slug: topic.slug, + showInPrivacyCenter: topic['show-in-privacy-center'], + purposeId, + ...(topic.options + ? { + preferenceOptionValueIds: topic.options.map((option) => { + const result = optionValuesBySlug[option]; + if (!result) { + throw new Error( + `Preference option value with slug "${option}" not found.`, + ); + } + return result.id; + }), + } + : {}), + ...(existingTopic + ? { + id: existingTopic.id, + } + : {}), + displayDescription: topic.description, + defaultConfiguration: topic['default-configuration'], + }, + }, + }); + }, + { concurrency }, + ); +} + +/** + * Create a new purpose + * + * @param client - GraphQL client + * @param input - Purpose input + * @param options - Options for syncing preference topics + * @returns Purpose ID + */ +export async function createPurpose( + client: GraphQLClient, + input: PurposeInput, + options: Omit, +): Promise { + const { + createPurpose: { purpose }, + } = await makeGraphQLRequest<{ + /** createPurpose mutation */ + createPurpose: { + /** Purpose */ + purpose: { + /** ID */ + id: string; + }; + }; + }>(client, CREATE_PURPOSE, { + // TODO: https://transcend.height.app/T-31994 - include models and groups, teams, users + input: { + trackingType: input['tracking-type'], + showInPrivacyCenter: input['show-in-privacy-center'], + showInConsentManager: input['show-in-consent-manager'], + optOutSignals: input['opt-out-signals'], + name: input.title, + isActive: input['is-active'], + description: input.description, + displayOrder: input['display-order'], + configurable: input.configurable, + authLevel: input['auth-level'], + }, + }); + logger.info(colors.green(`Successfully created purpose "${input.title}"!`)); + + // then upsert preference topics + if (input['preference-topics'] && input['preference-topics'].length > 0) { + await createOrUpdatePreferenceTopics(client, input['preference-topics'], { + ...options, + purposeId: purpose.id, + topicsBySlug: {}, // none exist at this point + }); + logger.info( + colors.green( + `Successfully updated ${ + input['preference-topics'].length + } preferences for purpose: ${purpose.id}:${ + input.title || input['tracking-type'] + }!`, + ), + ); + } + return purpose.id; +} + +/** + * Update a purpose + * + * @param client - GraphQL client + * @param input - Purpose input + * @param options - Options for syncing preference topics + */ +export async function updatePurpose( + client: GraphQLClient, + input: PurposeInput, + options: PreferenceTopicInputOptions, +): Promise { + // First update the purpose + await makeGraphQLRequest(client, UPDATE_PURPOSE, { + input: { + id: options.purposeId, + title: input.title, + showInPrivacyCenter: input['show-in-privacy-center'], + ...(!Object.values(KnownDefaultPurpose).includes( + input['tracking-type'] as KnownDefaultPurpose, + ) + ? { + showInConsentManager: input['show-in-consent-manager'], + configurable: input.configurable, + } + : {}), + optOutSignals: input['opt-out-signals'], + name: input.title, + isActive: input['is-active'], + displayOrder: input['display-order'], + description: input.description, + authLevel: input['auth-level'], + }, + }); + logger.info( + colors.green( + `Successfully updated purpose: ${options.purposeId}:${ + input.title || input['tracking-type'] + }!`, + ), + ); + + // then upsert preference topics + if (input['preference-topics'] && input['preference-topics'].length > 0) { + await createOrUpdatePreferenceTopics( + client, + input['preference-topics'], + options, + ); + logger.info( + colors.green( + `Successfully updated ${ + input['preference-topics'].length + } preferences for purpose: ${options.purposeId}:${ + input.title || input['tracking-type'] + }!`, + ), + ); + } +} + +/** + * Sync the purposes + * + * @param client - GraphQL client + * @param purposes - Purposes + * @param concurrency - Concurrency + * @returns True if synced successfully + */ +export async function syncPurposes( + client: GraphQLClient, + purposes: PurposeInput[], + concurrency = 20, +): Promise { + let encounteredError = false; + logger.info(colors.magenta(`Syncing "${purposes.length}" purposes...`)); + + // Index existing purposes + const [existing, existingOptions] = await Promise.all([ + fetchAllPurposesAndPreferences(client), + fetchAllPreferenceOptionValues(client), + ]); + const purposeByTrackingType = keyBy(existing, 'trackingType'); + const optionValuesBySlug = keyBy(existingOptions, 'slug'); + + // Determine which purposes are new vs existing + const mapPurposesToExisting = purposes.map((purposeInput) => [ + purposeInput, + purposeByTrackingType[purposeInput['tracking-type']], + ]); + + // Create the new purposes + const newPurposes = mapPurposesToExisting + .filter(([, existing]) => !existing) + .map(([purposeInput]) => purposeInput as PurposeInput); + try { + logger.info( + colors.magenta(`Creating "${newPurposes.length}" new purposes...`), + ); + await map( + newPurposes, + async (purpose) => { + await createPurpose(client, purpose, { + concurrency, + optionValuesBySlug, + }); + }, + { + concurrency, + }, + ); + logger.info( + colors.green(`Successfully synced ${newPurposes.length} purposes!`), + ); + } catch (err) { + encounteredError = true; + logger.info(colors.red(`Failed to create purposes! - ${err.message}`)); + } + + // Update existing purposes + const existingPurposes = mapPurposesToExisting.filter( + (x): x is [PurposeInput, PurposeWithPreferences] => !!x[1], + ); + try { + logger.info( + colors.magenta(`Updating "${existingPurposes.length}" purposes...`), + ); + await map( + existingPurposes, + async ([purposeInput, existingPurpose]) => { + try { + await updatePurpose(client, purposeInput, { + concurrency, + optionValuesBySlug, + purposeId: existingPurpose.id, + topicsBySlug: keyBy(existingPurpose.topics, 'slug'), + }); + logger.info( + colors.green( + `Successfully updated purpose with ID "${existingPurpose.id}", slug: ${purposeInput['tracking-type']}!`, + ), + ); + } catch (err) { + encounteredError = true; + logger.info( + colors.red( + `Failed to update purpose with ID "${existingPurpose.id}", ` + + `slug: ${purposeInput['tracking-type']} ! - ${err.message}`, + ), + ); + } + }, + { + concurrency, + }, + ); + logger.info( + colors.green( + `Successfully updated "${existingPurposes.length}" purposes!`, + ), + ); + } catch (err) { + encounteredError = true; + logger.info(colors.red(`Failed to update purposes! - ${err.message}`)); + } + + logger.info(colors.green(`Synced "${purposes.length}" purposes!`)); + + // Return true upon success + return !encounteredError; +}