diff --git a/examples/consent-workflow-triggers.yml b/examples/consent-workflow-triggers.yml new file mode 100644 index 00000000..30414f82 --- /dev/null +++ b/examples/consent-workflow-triggers.yml @@ -0,0 +1,19 @@ +consent-workflow-triggers: + - name: Erasure on opt-out + action-type: ERASURE + data-subject-type: Customer + is-silent: true + allow-unauthenticated: false + is-active: true + purposes: + - tracking-type: Advertising + matching-state: false + - name: Access on request + action-type: ACCESS + data-subject-type: Customer + is-silent: false + allow-unauthenticated: false + is-active: true + purposes: + - tracking-type: Analytics + matching-state: true diff --git a/src/codecs.ts b/src/codecs.ts index 3cff03e5..45ae81d4 100644 --- a/src/codecs.ts +++ b/src/codecs.ts @@ -1968,6 +1968,54 @@ export type SiloDiscoveryResultInput = t.TypeOf< typeof SiloDiscoveryResultInput >; +/** + * Input for a purpose associated with a consent workflow trigger + */ +export const ConsentWorkflowTriggerPurposeInput = t.type({ + /** The tracking type slug of the purpose */ + 'tracking-type': t.string, + /** The matching consent state for the purpose */ + 'matching-state': t.boolean, +}); + +/** Type override */ +export type ConsentWorkflowTriggerPurposeInput = t.TypeOf< + typeof ConsentWorkflowTriggerPurposeInput +>; + +/** + * Input to define a consent workflow trigger + */ +export const ConsentWorkflowTriggerInput = t.intersection([ + t.type({ + /** The name of the consent workflow trigger */ + name: t.string, + }), + t.partial({ + /** The trigger condition as a JSON string */ + 'trigger-condition': t.string, + /** The action type (e.g. ERASURE, ACCESS) */ + 'action-type': t.string, + /** The data subject type */ + 'data-subject-type': t.string, + /** Whether the trigger runs silently */ + 'is-silent': t.boolean, + /** Whether unauthenticated requests are allowed */ + 'allow-unauthenticated': t.boolean, + /** Whether the trigger is active */ + 'is-active': t.boolean, + /** Titles of data silos associated with this trigger */ + 'data-silo-titles': t.array(t.string), + /** Purposes and their matching consent states */ + purposes: t.array(ConsentWorkflowTriggerPurposeInput), + }), +]); + +/** Type override */ +export type ConsentWorkflowTriggerInput = t.TypeOf< + typeof ConsentWorkflowTriggerInput +>; + export const TranscendInput = t.partial({ /** * Action items @@ -2097,6 +2145,14 @@ export const TranscendInput = t.partial({ * The full list of silo discovery results */ 'system-discovery': t.array(SiloDiscoveryResultInput), + /** + * Consent workflow trigger definitions + */ + 'consent-workflow-triggers': t.array(ConsentWorkflowTriggerInput), + /** + * Preference management options for multi and single selects + */ + 'preference-options': t.array(ConsentPreferenceTopicOptionValue), }); /** Type override */ diff --git a/src/constants.ts b/src/constants.ts index cddda75a..24816982 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -87,7 +87,15 @@ export const TR_PUSH_RESOURCE_SCOPE_MAP: { ScopeName.ManageConsentManager, ScopeName.ManagePreferenceStoreSettings, ], + [TranscendPullResource.PreferenceOptions]: [ + ScopeName.ManagePreferenceStoreSettings, + ], [TranscendPullResource.SystemDiscovery]: [ScopeName.ManageDataMap], + [TranscendPullResource.ConsentWorkflowTriggers]: [ + ScopeName.ManageConsentManager, + ScopeName.ViewDataSubjectRequestSettings, + ScopeName.ViewConsentManager, + ], }; /** @@ -136,7 +144,13 @@ export const TR_PULL_RESOURCE_SCOPE_MAP: { ScopeName.ViewConsentManager, ScopeName.ViewPreferenceStoreSettings, ], + [TranscendPullResource.PreferenceOptions]: [ + ScopeName.ViewPreferenceStoreSettings, + ], [TranscendPullResource.SystemDiscovery]: [ScopeName.ViewDataMap], + [TranscendPullResource.ConsentWorkflowTriggers]: [ + ScopeName.ViewConsentManager, + ], }; export const TR_YML_RESOURCE_TO_FIELD_NAME: Record< @@ -175,7 +189,9 @@ export const TR_YML_RESOURCE_TO_FIELD_NAME: Record< [TranscendPullResource.Assessments]: 'assessments', [TranscendPullResource.AssessmentTemplates]: 'assessment-templates', [TranscendPullResource.Purposes]: 'purposes', + [TranscendPullResource.PreferenceOptions]: 'preference-options', [TranscendPullResource.SystemDiscovery]: 'system-discovery', + [TranscendPullResource.ConsentWorkflowTriggers]: 'consent-workflow-triggers', }; export const SCOPES_BY_TITLE = keyBy( diff --git a/src/enums.ts b/src/enums.ts index 3f4d5b90..9c8d2ad8 100644 --- a/src/enums.ts +++ b/src/enums.ts @@ -56,7 +56,9 @@ export enum TranscendPullResource { Assessments = 'assessments', AssessmentTemplates = 'assessmentTemplates', Purposes = 'purposes', + PreferenceOptions = 'preferenceOptions', SystemDiscovery = 'systemDiscovery', + ConsentWorkflowTriggers = 'consentWorkflowTriggers', } /** diff --git a/src/lib/docgen/createPullResourceScopesTable.ts b/src/lib/docgen/createPullResourceScopesTable.ts index 79b67b27..a213b975 100644 --- a/src/lib/docgen/createPullResourceScopesTable.ts +++ b/src/lib/docgen/createPullResourceScopesTable.ts @@ -223,6 +223,14 @@ const RESOURCE_DOCUMENTATION: Record< (https://app.transcend.io/consent-manager/regional-experiences/purposes)', ], }, + [TranscendPullResource.PreferenceOptions]: { + description: + 'Preference management options for multi and single select preference topics.', + markdownLinks: [ + '[Preference Management -> Preference Topics -> Options]\ +(https://app.transcend.io/preference-store/preference-topics/preference-options)', + ], + }, [TranscendPullResource.SystemDiscovery]: { description: 'System discovery results', markdownLinks: [ @@ -230,6 +238,14 @@ const RESOURCE_DOCUMENTATION: Record< (https://app.transcend.io/data-map/data-inventory/silo-discovery)', ], }, + [TranscendPullResource.ConsentWorkflowTriggers]: { + description: + 'Consent workflow trigger definitions that automate privacy request workflows based on consent state changes.', + markdownLinks: [ + '[Consent Management -> Consent Workflows]\ +(https://app.transcend.io/consent-manager/consent-workflows)', + ], + }, }; /** diff --git a/src/lib/graphql/fetchAllConsentWorkflowTriggers.ts b/src/lib/graphql/fetchAllConsentWorkflowTriggers.ts new file mode 100644 index 00000000..86a28af5 --- /dev/null +++ b/src/lib/graphql/fetchAllConsentWorkflowTriggers.ts @@ -0,0 +1,71 @@ +import { GraphQLClient } from 'graphql-request'; +import { CONSENT_WORKFLOW_TRIGGERS } from './gqls'; +import { makeGraphQLRequest } from './makeGraphQLRequest'; + +export interface ConsentWorkflowTrigger { + /** ID of the trigger */ + id: string; + /** Name of the trigger */ + name: string; + /** JSON string of the trigger condition */ + triggerCondition: string | null; + /** Whether the trigger runs silently */ + isSilent: boolean; + /** Whether unauthenticated requests are allowed */ + allowUnauthenticated: boolean; + /** Whether the trigger is active */ + isActive: boolean; + /** The workflow config ID */ + workflowConfigId: string | null; + /** The request action associated with the trigger */ + action: { + /** Action type (e.g. ERASURE, ACCESS) */ + type: string; + }; + /** The data subject associated with the trigger */ + subject: { + /** Data subject type */ + type: string; + }; + /** Data silos associated with this trigger */ + dataSilos: { + /** Title of data silo */ + title: string; + }[]; +} + +const PAGE_SIZE = 20; + +/** + * Fetch all consent workflow triggers in the organization + * + * @param client - GraphQL client + * @returns All consent workflow triggers in the organization + */ +export async function fetchAllConsentWorkflowTriggers( + client: GraphQLClient, +): Promise { + const triggers: ConsentWorkflowTrigger[] = []; + let offset = 0; + + let shouldContinue = false; + do { + const { + consentWorkflowTriggers: { nodes }, + } = await makeGraphQLRequest<{ + /** Consent workflow triggers */ + consentWorkflowTriggers: { + /** List */ + nodes: ConsentWorkflowTrigger[]; + }; + }>(client, CONSENT_WORKFLOW_TRIGGERS, { + first: PAGE_SIZE, + offset, + }); + triggers.push(...nodes); + offset += PAGE_SIZE; + shouldContinue = nodes.length === PAGE_SIZE; + } while (shouldContinue); + + return triggers.sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/src/lib/graphql/fetchAllPreferenceOptionValues.ts b/src/lib/graphql/fetchAllPreferenceOptionValues.ts new file mode 100644 index 00000000..5621614a --- /dev/null +++ b/src/lib/graphql/fetchAllPreferenceOptionValues.ts @@ -0,0 +1,53 @@ +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; + + let shouldContinue = false; + do { + const { + preferenceOptionValues: { nodes }, + } = 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/lib/graphql/gqls/consentWorkflowTrigger.ts b/src/lib/graphql/gqls/consentWorkflowTrigger.ts new file mode 100644 index 00000000..6fbe047e --- /dev/null +++ b/src/lib/graphql/gqls/consentWorkflowTrigger.ts @@ -0,0 +1,48 @@ +import { gql } from 'graphql-request'; + +export const CONSENT_WORKFLOW_TRIGGERS = gql` + query TranscendCliConsentWorkflowTriggers($first: Int!, $offset: Int!) { + consentWorkflowTriggers(first: $first, offset: $offset) { + nodes { + id + name + triggerCondition + isSilent + allowUnauthenticated + isActive + workflowConfigId + action { + type + } + subject { + type + } + dataSilos { + title + } + } + totalCount + } + } +`; + +export const CREATE_OR_UPDATE_CONSENT_WORKFLOW_TRIGGER = gql` + mutation TranscendCliCreateOrUpdateConsentWorkflowTrigger( + $input: CreateOrUpdateConsentWorkflowTriggerInput! + ) { + createOrUpdateConsentWorkflowTrigger(input: $input) { + consentWorkflowTrigger { + id + name + } + } + } +`; + +export const DELETE_CONSENT_WORKFLOW_TRIGGERS = gql` + mutation TranscendCliDeleteConsentWorkflowTriggers($ids: [ID!]!) { + deleteConsentWorkflowTriggers(ids: $ids) { + clientMutationId + } + } +`; diff --git a/src/lib/graphql/gqls/index.ts b/src/lib/graphql/gqls/index.ts index dd9d9524..9d6da7fe 100644 --- a/src/lib/graphql/gqls/index.ts +++ b/src/lib/graphql/gqls/index.ts @@ -50,3 +50,4 @@ export * from './processingPurpose'; export * from './processingActivity'; export * from './sombraVersion'; export * from './siloDiscoveryResult'; +export * from './consentWorkflowTrigger'; diff --git a/src/lib/graphql/gqls/preferenceTopic.ts b/src/lib/graphql/gqls/preferenceTopic.ts index 6aa0fcc2..8a20ef8c 100644 --- a/src/lib/graphql/gqls/preferenceTopic.ts +++ b/src/lib/graphql/gqls/preferenceTopic.ts @@ -42,3 +42,44 @@ export const PREFERENCE_TOPICS = gql` } } `; + +export const CREATE_OR_UPDATE_PREFERENCE_OPTION_VALUES = gql` + mutation TranscendCliCreateOrUpdatePreferenceOptionValues( + $input: CreateOrUpdatePreferenceOptionValuesInput! + ) { + createOrUpdatePreferenceOptionValues(input: $input) { + preferenceOptionValues { + id + slug + } + } + } +`; + +export const PREFERENCE_OPTION_VALUES = gql` + query TranscendCliPreferenceOptionValues($first: Int!, $offset: Int!) { + preferenceOptionValues(first: $first, offset: $offset) { + nodes { + id + title { + id + defaultMessage + } + slug + } + } + } +`; + +export const CREATE_OR_UPDATE_PREFERENCE_TOPIC = gql` + mutation TranscendCliCreateOrUpdatePreferenceTopic( + $input: CreateOrUpdatePreferenceTopicInput! + ) { + createOrUpdatePreferenceTopic(input: $input) { + preferenceTopic { + id + slug + } + } + } +`; diff --git a/src/lib/graphql/gqls/purpose.ts b/src/lib/graphql/gqls/purpose.ts index 3bb70aae..bd12375c 100644 --- a/src/lib/graphql/gqls/purpose.ts +++ b/src/lib/graphql/gqls/purpose.ts @@ -40,3 +40,27 @@ export const PURPOSES = gql` } } `; + +export const CREATE_PURPOSE = gql` + mutation TranscendCliCreatePurpose($input: TrackingPurposeCreateInput!) { + createPurpose(input: $input) { + trackingPurpose { + id + name + trackingType + } + } + } +`; + +export const UPDATE_PURPOSE = gql` + mutation TranscendCliUpdatePurpose($input: TrackingPurposeUpdateInput!) { + updatePurpose(input: $input) { + trackingPurpose { + id + name + trackingType + } + } + } +`; diff --git a/src/lib/graphql/index.ts b/src/lib/graphql/index.ts index f0dda726..bb8de0b1 100644 --- a/src/lib/graphql/index.ts +++ b/src/lib/graphql/index.ts @@ -89,3 +89,8 @@ export * from './syncTemplates'; export * from './syncVendors'; export * from './uploadSiloDiscoveryResults'; export * from './fetchAllSiloDiscoveryResults'; +export * from './fetchAllConsentWorkflowTriggers'; +export * from './fetchAllPreferenceOptionValues'; +export * from './syncConsentWorkflowTriggers'; +export * from './syncPreferenceOptionValues'; +export * from './syncPurposes'; diff --git a/src/lib/graphql/pullTranscendConfiguration.ts b/src/lib/graphql/pullTranscendConfiguration.ts index e35d7ec9..14a5159a 100644 --- a/src/lib/graphql/pullTranscendConfiguration.ts +++ b/src/lib/graphql/pullTranscendConfiguration.ts @@ -34,7 +34,9 @@ import { AssessmentSectionQuestionInput, RiskLogicInput, ConsentPurpose, + type ConsentPreferenceTopicOptionValue, type SiloDiscoveryResultInput, + type ConsentWorkflowTriggerInput, } from '../../codecs'; import { RequestAction, @@ -94,7 +96,9 @@ import { } from './parseAssessmentDisplayLogic'; import { parseAssessmentRiskLogic } from './parseAssessmentRiskLogic'; import { fetchAllPurposesAndPreferences } from './fetchAllPurposesAndPreferences'; +import { fetchAllPreferenceOptionValues } from './fetchAllPreferenceOptionValues'; import { fetchAllSiloDiscoveryResults } from './fetchAllSiloDiscoveryResults'; +import { fetchAllConsentWorkflowTriggers } from './fetchAllConsentWorkflowTriggers'; export const DEFAULT_TRANSCEND_PULL_RESOURCES = [ TranscendPullResource.DataSilos, @@ -188,7 +192,9 @@ export async function pullTranscendConfiguration( assessments, assessmentTemplates, purposes, + preferenceOptionValues, siloDiscoveryResults, + consentWorkflowTriggers, ] = await Promise.all([ // Grab all data subjects in the organization resources.includes(TranscendPullResource.DataSilos) || @@ -345,10 +351,18 @@ export async function pullTranscendConfiguration( resources.includes(TranscendPullResource.Purposes) ? fetchAllPurposesAndPreferences(client) : [], + // Fetch preference option values + resources.includes(TranscendPullResource.PreferenceOptions) + ? fetchAllPreferenceOptionValues(client) + : [], // Fetch silo discovery results resources.includes(TranscendPullResource.SystemDiscovery) ? fetchAllSiloDiscoveryResults(client) : [], + // Fetch consent workflow triggers + resources.includes(TranscendPullResource.ConsentWorkflowTriggers) + ? fetchAllConsentWorkflowTriggers(client) + : [], ]); const consentManagerTheme = @@ -1530,6 +1544,41 @@ export async function pullTranscendConfiguration( ); } + // Save preference options + if ( + preferenceOptionValues.length > 0 && + resources.includes(TranscendPullResource.PreferenceOptions) + ) { + result['preference-options'] = preferenceOptionValues.map( + ({ slug, title }): ConsentPreferenceTopicOptionValue => ({ + slug, + title: title.defaultMessage, + }), + ); + } + + // Save consent workflow triggers + if ( + consentWorkflowTriggers.length > 0 && + resources.includes(TranscendPullResource.ConsentWorkflowTriggers) + ) { + result['consent-workflow-triggers'] = consentWorkflowTriggers.map( + (trigger): ConsentWorkflowTriggerInput => ({ + name: trigger.name, + 'trigger-condition': trigger.triggerCondition || undefined, + 'action-type': trigger.action.type, + 'data-subject-type': trigger.subject.type, + 'is-silent': trigger.isSilent, + 'allow-unauthenticated': trigger.allowUnauthenticated, + 'is-active': trigger.isActive, + 'data-silo-titles': + trigger.dataSilos.length > 0 + ? trigger.dataSilos.map((ds) => ds.title) + : undefined, + }), + ); + } + // save email templates if ( dataSiloIds.length === 0 && diff --git a/src/lib/graphql/syncConfigurationToTranscend.ts b/src/lib/graphql/syncConfigurationToTranscend.ts index 5d216809..70441f5b 100644 --- a/src/lib/graphql/syncConfigurationToTranscend.ts +++ b/src/lib/graphql/syncConfigurationToTranscend.ts @@ -43,6 +43,9 @@ import { syncDataCategories } from './syncDataCategories'; import { syncProcessingPurposes } from './syncProcessingPurposes'; import { syncProcessingActivities } from './syncProcessingActivities'; import { syncPartitions } from './syncPartitions'; +import { syncConsentWorkflowTriggers } from './syncConsentWorkflowTriggers'; +import { syncPreferenceOptionValues } from './syncPreferenceOptionValues'; +import { syncPurposes } from './syncPurposes'; const CONCURRENCY = 10; @@ -107,6 +110,8 @@ export async function syncConfigurationToTranscend( messages, policies, partitions, + 'consent-workflow-triggers': consentWorkflowTriggers, + purposes, } = input; const [identifierByName, dataSubjectsByName, apiKeyTitleMap] = @@ -146,6 +151,30 @@ export async function syncConfigurationToTranscend( } } + // Sync preference option values (before purposes, since purposes may reference them) + if (input['preference-options']) { + const preferenceOptionsSuccess = await syncPreferenceOptionValues( + client, + input['preference-options'], + ); + encounteredError = encounteredError || !preferenceOptionsSuccess; + } + + // Sync purposes (and nested preference topics) + if (purposes) { + const purposesSuccess = await syncPurposes(client, purposes); + encounteredError = encounteredError || !purposesSuccess; + } + + // Sync consent workflow triggers + if (consentWorkflowTriggers) { + const consentWorkflowTriggersSuccess = await syncConsentWorkflowTriggers( + client, + consentWorkflowTriggers, + ); + encounteredError = encounteredError || !consentWorkflowTriggersSuccess; + } + // Sync prompts if (prompts) { const promptsSuccess = await syncPrompts(client, prompts); diff --git a/src/lib/graphql/syncConsentWorkflowTriggers.ts b/src/lib/graphql/syncConsentWorkflowTriggers.ts new file mode 100644 index 00000000..cb1a2bae --- /dev/null +++ b/src/lib/graphql/syncConsentWorkflowTriggers.ts @@ -0,0 +1,170 @@ +import { ConsentWorkflowTriggerInput } from '../../codecs'; +import { GraphQLClient } from 'graphql-request'; +import { CREATE_OR_UPDATE_CONSENT_WORKFLOW_TRIGGER } from './gqls'; +import { logger } from '../../logger'; +import { keyBy } from 'lodash-es'; +import { makeGraphQLRequest } from './makeGraphQLRequest'; +import { fetchAllConsentWorkflowTriggers } from './fetchAllConsentWorkflowTriggers'; +import { fetchAllActions, type Action } from './fetchAllActions'; +import { fetchAllDataSubjects, type DataSubject } from './fetchDataSubjects'; +import { fetchAllPurposes, type Purpose } from './fetchAllPurposes'; +import colors from 'colors'; +import { mapSeries } from '../bluebird'; + +/** + * Sync consent workflow triggers to Transcend + * + * @param client - GraphQL client + * @param inputs - Consent workflow trigger inputs from YAML + * @returns True if run without error, returns false if an error occurred + */ +export async function syncConsentWorkflowTriggers( + client: GraphQLClient, + inputs: ConsentWorkflowTriggerInput[], +): Promise { + logger.info( + colors.magenta(`Syncing "${inputs.length}" consent workflow triggers...`), + ); + + let encounteredError = false; + + const needsActions = inputs.some((t) => t['action-type']); + const needsSubjects = inputs.some((t) => t['data-subject-type']); + const needsPurposes = inputs.some((t) => t.purposes?.length); + + const [existingTriggers, actions, dataSubjects, purposes] = await Promise.all( + [ + fetchAllConsentWorkflowTriggers(client), + needsActions ? fetchAllActions(client) : ([] as Action[]), + needsSubjects ? fetchAllDataSubjects(client) : ([] as DataSubject[]), + needsPurposes ? fetchAllPurposes(client) : ([] as Purpose[]), + ], + ); + + const triggerByName = keyBy(existingTriggers, 'name'); + const actionByType = keyBy(actions, 'type') as Record; + const dataSubjectByType = keyBy(dataSubjects, 'type') as Record< + string, + DataSubject + >; + const purposeByTrackingType = keyBy(purposes, 'trackingType') as Record< + string, + Purpose + >; + + await mapSeries(inputs, async (trigger) => { + try { + const existingTrigger = triggerByName[trigger.name]; + + // Resolve action type to ID + let actionId: string | undefined; + if (trigger['action-type']) { + const action = actionByType[trigger['action-type']]; + if (!action) { + throw new Error( + `Failed to find action with type: ${trigger['action-type']}`, + ); + } + actionId = action.id; + } + + // Resolve data subject type to ID + let dataSubjectId: string | undefined; + if (trigger['data-subject-type']) { + const subject = dataSubjectByType[trigger['data-subject-type']]; + if (!subject) { + throw new Error( + `Failed to find data subject with type: ${trigger['data-subject-type']}`, + ); + } + dataSubjectId = subject.id; + } + + // Resolve purpose tracking types to purpose IDs with matching states + const consentWorkflowTriggerPurposes = trigger.purposes?.map( + (purposeInput) => { + const purpose = purposeByTrackingType[purposeInput['tracking-type']]; + if (!purpose) { + throw new Error( + `Failed to find purpose with trackingType: ${purposeInput['tracking-type']}`, + ); + } + return { + purposeId: purpose.id, + matchingState: purposeInput['matching-state'], + }; + }, + ); + + const input: Record = { + name: trigger.name, + ...(existingTrigger ? { id: existingTrigger.id } : {}), + triggerCondition: trigger['trigger-condition'] ?? '{}', + ...(actionId ? { actionId } : {}), + ...(dataSubjectId ? { dataSubjectId } : {}), + ...(trigger['is-silent'] !== undefined + ? { isSilent: trigger['is-silent'] } + : {}), + ...(trigger['allow-unauthenticated'] !== undefined + ? { allowUnauthenticated: trigger['allow-unauthenticated'] } + : {}), + ...(trigger['is-active'] !== undefined + ? { isActive: trigger['is-active'] } + : {}), + ...(existingTrigger && consentWorkflowTriggerPurposes + ? { consentWorkflowTriggerPurposes } + : {}), + }; + + const { + createOrUpdateConsentWorkflowTrigger: { + consentWorkflowTrigger: { id: triggerId }, + }, + } = await makeGraphQLRequest<{ + /** Mutation result */ + createOrUpdateConsentWorkflowTrigger: { + /** Created or updated trigger */ + consentWorkflowTrigger: { + /** Trigger ID */ + id: string; + /** Trigger name */ + name: string; + }; + }; + }>(client, CREATE_OR_UPDATE_CONSENT_WORKFLOW_TRIGGER, { input }); + + // For newly created triggers, purposes must be attached via a follow-up update + if (!existingTrigger && consentWorkflowTriggerPurposes?.length) { + await makeGraphQLRequest( + client, + CREATE_OR_UPDATE_CONSENT_WORKFLOW_TRIGGER, + { + input: { + id: triggerId, + consentWorkflowTriggerPurposes, + }, + }, + ); + } + + logger.info( + colors.green( + `Successfully synced consent workflow trigger "${trigger.name}"!`, + ), + ); + } catch (err) { + encounteredError = true; + logger.info( + colors.red( + `Failed to sync consent workflow trigger "${trigger.name}"! - ${err.message}`, + ), + ); + } + }); + + logger.info( + colors.green(`Synced "${inputs.length}" consent workflow triggers!`), + ); + + return !encounteredError; +} diff --git a/src/lib/graphql/syncPreferenceOptionValues.ts b/src/lib/graphql/syncPreferenceOptionValues.ts new file mode 100644 index 00000000..c9e0926c --- /dev/null +++ b/src/lib/graphql/syncPreferenceOptionValues.ts @@ -0,0 +1,92 @@ +import { ConsentPreferenceTopicOptionValue } 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, + type PreferenceOptionValue, +} from './fetchAllPreferenceOptionValues'; +import { keyBy } from 'lodash-es'; + +/** + * Create or update preference option values + * + * @param client - GraphQL client + * @param optionValues - Preference option values paired with existing IDs + * @returns Created/updated preference option values + */ +export async function createOrUpdatePreferenceOptionValues( + client: GraphQLClient, + optionValues: [ConsentPreferenceTopicOptionValue, 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: ConsentPreferenceTopicOptionValue[], +): Promise { + let encounteredError = false; + logger.info( + colors.magenta( + `Syncing "${optionValues.length}" 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 !encounteredError; +} diff --git a/src/lib/graphql/syncPurposes.ts b/src/lib/graphql/syncPurposes.ts new file mode 100644 index 00000000..29d57575 --- /dev/null +++ b/src/lib/graphql/syncPurposes.ts @@ -0,0 +1,288 @@ +import { ConsentPreferenceTopic, ConsentPurpose } 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-es'; +import { logger } from '../../logger'; +import { + fetchAllPreferenceOptionValues, + type PreferenceOptionValue, +} from './fetchAllPreferenceOptionValues'; +import { PreferenceTopic } from './fetchAllPreferenceTopics'; + +export interface PreferenceTopicSyncOptions { + /** Purpose ID */ + purposeId: string; + /** Preference option values indexed by slug */ + optionValuesBySlug: Record; + /** Existing preference topics indexed 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: ConsentPreferenceTopic[], + { + purposeId, + optionValuesBySlug, + topicsBySlug, + concurrency = 20, + }: PreferenceTopicSyncOptions, +): Promise { + await map( + topics, + async (topic) => { + const existingTopic = topicsBySlug[topic.title]; + await makeGraphQLRequest(client, CREATE_OR_UPDATE_PREFERENCE_TOPIC, { + input: { + type: topic.type, + title: topic.title, + showInPrivacyCenter: topic['show-in-privacy-center'], + purposeId, + ...(topic.options + ? { + preferenceOptionValueIds: topic.options.map((option) => { + const result = optionValuesBySlug[option.slug]; + if (!result) { + throw new Error( + `Preference option value with slug "${option.slug}" 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: ConsentPurpose, + options: Omit, +): Promise { + const { + createPurpose: { trackingPurpose }, + } = await makeGraphQLRequest<{ + /** createPurpose mutation */ + createPurpose: { + /** Purpose */ + trackingPurpose: { + /** ID */ + id: string; + }; + }; + }>(client, CREATE_PURPOSE, { + input: { + trackingType: input.trackingType, + 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}"!`)); + + if (input['preference-topics'] && input['preference-topics'].length > 0) { + await createOrUpdatePreferenceTopics(client, input['preference-topics'], { + ...options, + purposeId: trackingPurpose.id, + topicsBySlug: {}, + }); + logger.info( + colors.green( + `Successfully synced ${input['preference-topics'].length} preference topics for purpose "${input.title}"!`, + ), + ); + } + return trackingPurpose.id; +} + +/** + * Update an existing purpose + * + * @param client - GraphQL client + * @param input - Purpose input + * @param options - Options for syncing preference topics + */ +export async function updatePurpose( + client: GraphQLClient, + input: ConsentPurpose, + options: PreferenceTopicSyncOptions, +): Promise { + await makeGraphQLRequest(client, UPDATE_PURPOSE, { + input: { + id: options.purposeId, + title: input.title, + showInPrivacyCenter: input['show-in-privacy-center'], + 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.trackingType + }!`, + ), + ); + + if (input['preference-topics'] && input['preference-topics'].length > 0) { + await createOrUpdatePreferenceTopics( + client, + input['preference-topics'], + options, + ); + logger.info( + colors.green( + `Successfully synced ${ + input['preference-topics'].length + } preference topics for purpose "${ + input.title || input.trackingType + }"!`, + ), + ); + } +} + +/** + * 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: ConsentPurpose[], + concurrency = 20, +): Promise { + let encounteredError = false; + logger.info(colors.magenta(`Syncing "${purposes.length}" purposes...`)); + + const [existing, existingOptions] = await Promise.all([ + fetchAllPurposesAndPreferences(client), + fetchAllPreferenceOptionValues(client), + ]); + const purposeByTrackingType = keyBy(existing, 'trackingType'); + const optionValuesBySlug = keyBy(existingOptions, 'slug'); + + const mapPurposesToExisting = purposes.map((purposeInput) => [ + purposeInput, + purposeByTrackingType[purposeInput.trackingType], + ]); + + // Create new purposes + const newPurposes = mapPurposesToExisting + .filter(([, existing]) => !existing) + .map(([purposeInput]) => purposeInput as ConsentPurpose); + 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 created ${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 [ConsentPurpose, 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'), + }); + } catch (err) { + encounteredError = true; + logger.info( + colors.red( + `Failed to update purpose "${existingPurpose.id}" (${purposeInput.trackingType})! - ${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 !encounteredError; +} diff --git a/test-pull-triggers.yml b/test-pull-triggers.yml new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/test-pull-triggers.yml @@ -0,0 +1 @@ +{}