From 10a7dfb08b40c24204e64102ce8e3629584f02c1 Mon Sep 17 00:00:00 2001 From: michaelfarrell76 Date: Tue, 24 Feb 2026 18:06:31 -0800 Subject: [PATCH 1/5] feat: add sync support for purposes, preferences, and consent workflow triggers Adds full pull/push CLI support for: - Consent Workflow Triggers (new resource): pull, push with name-based matching, resolves action-type/data-subject-type/purposes to IDs - Purposes (previously pull-only): push via createPurpose/updatePurpose mutations, matched by trackingType, with nested preference topic sync - Preference Topics: synced as nested children of purposes via createOrUpdatePreferenceTopic Closes EPD-11849 Co-authored-by: Cursor --- examples/consent-workflow-triggers.yml | 22 +++ src/codecs.ts | 52 ++++++ src/constants.ts | 7 + src/enums.ts | 1 + .../docgen/createPullResourceScopesTable.ts | 8 + .../fetchAllConsentWorkflowTriggers.ts | 83 +++++++++ .../graphql/gqls/consentWorkflowTrigger.ts | 54 ++++++ src/lib/graphql/gqls/index.ts | 1 + src/lib/graphql/gqls/preferenceTopic.ts | 13 ++ src/lib/graphql/gqls/purpose.ts | 24 +++ src/lib/graphql/index.ts | 3 + src/lib/graphql/pullTranscendConfiguration.ts | 36 ++++ .../graphql/syncConfigurationToTranscend.ts | 19 ++ .../graphql/syncConsentWorkflowTriggers.ts | 143 ++++++++++++++++ src/lib/graphql/syncPurposes.ts | 162 ++++++++++++++++++ 15 files changed, 628 insertions(+) create mode 100644 examples/consent-workflow-triggers.yml create mode 100644 src/lib/graphql/fetchAllConsentWorkflowTriggers.ts create mode 100644 src/lib/graphql/gqls/consentWorkflowTrigger.ts create mode 100644 src/lib/graphql/syncConsentWorkflowTriggers.ts create mode 100644 src/lib/graphql/syncPurposes.ts diff --git a/examples/consent-workflow-triggers.yml b/examples/consent-workflow-triggers.yml new file mode 100644 index 00000000..52493c5f --- /dev/null +++ b/examples/consent-workflow-triggers.yml @@ -0,0 +1,22 @@ +consent-workflow-triggers: + - name: Erasure on opt-out + action-type: ERASURE + data-subject-type: customer + is-silent: true + allow-unauthenticated: false + is-active: true + data-silo-titles: + - Salesforce + - HubSpot + 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..a21df338 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,10 @@ 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), }); /** Type override */ diff --git a/src/constants.ts b/src/constants.ts index cddda75a..24a43872 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -88,6 +88,9 @@ export const TR_PUSH_RESOURCE_SCOPE_MAP: { ScopeName.ManagePreferenceStoreSettings, ], [TranscendPullResource.SystemDiscovery]: [ScopeName.ManageDataMap], + [TranscendPullResource.ConsentWorkflowTriggers]: [ + ScopeName.ManageConsentManager, + ], }; /** @@ -137,6 +140,9 @@ export const TR_PULL_RESOURCE_SCOPE_MAP: { ScopeName.ViewPreferenceStoreSettings, ], [TranscendPullResource.SystemDiscovery]: [ScopeName.ViewDataMap], + [TranscendPullResource.ConsentWorkflowTriggers]: [ + ScopeName.ViewConsentManager, + ], }; export const TR_YML_RESOURCE_TO_FIELD_NAME: Record< @@ -176,6 +182,7 @@ export const TR_YML_RESOURCE_TO_FIELD_NAME: Record< [TranscendPullResource.AssessmentTemplates]: 'assessment-templates', [TranscendPullResource.Purposes]: 'purposes', [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..b37e47f0 100644 --- a/src/enums.ts +++ b/src/enums.ts @@ -57,6 +57,7 @@ export enum TranscendPullResource { AssessmentTemplates = 'assessmentTemplates', Purposes = 'purposes', SystemDiscovery = 'systemDiscovery', + ConsentWorkflowTriggers = 'consentWorkflowTriggers', } /** diff --git a/src/lib/docgen/createPullResourceScopesTable.ts b/src/lib/docgen/createPullResourceScopesTable.ts index 79b67b27..221bc799 100644 --- a/src/lib/docgen/createPullResourceScopesTable.ts +++ b/src/lib/docgen/createPullResourceScopesTable.ts @@ -230,6 +230,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..c85d1d3f --- /dev/null +++ b/src/lib/graphql/fetchAllConsentWorkflowTriggers.ts @@ -0,0 +1,83 @@ +import { GraphQLClient } from 'graphql-request'; +import { CONSENT_WORKFLOW_TRIGGERS } from './gqls'; +import { makeGraphQLRequest } from './makeGraphQLRequest'; + +export interface ConsentWorkflowTriggerPurpose { + /** Whether this purpose must match opted-in (true) or opted-out (false) */ + matchingState: boolean; + /** The associated purpose */ + purpose: { + /** Slug of purpose */ + trackingType: string; + }; +} + +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; + }[]; + /** Purposes and their matching consent states */ + consentWorkflowTriggerPurposes: ConsentWorkflowTriggerPurpose[]; +} + +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/gqls/consentWorkflowTrigger.ts b/src/lib/graphql/gqls/consentWorkflowTrigger.ts new file mode 100644 index 00000000..b7c02fc9 --- /dev/null +++ b/src/lib/graphql/gqls/consentWorkflowTrigger.ts @@ -0,0 +1,54 @@ +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 + } + consentWorkflowTriggerPurposes { + matchingState + purpose { + trackingType + } + } + } + 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..fb089e14 100644 --- a/src/lib/graphql/gqls/preferenceTopic.ts +++ b/src/lib/graphql/gqls/preferenceTopic.ts @@ -42,3 +42,16 @@ export const PREFERENCE_TOPICS = gql` } } `; + +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..5fbd26aa 100644 --- a/src/lib/graphql/index.ts +++ b/src/lib/graphql/index.ts @@ -89,3 +89,6 @@ export * from './syncTemplates'; export * from './syncVendors'; export * from './uploadSiloDiscoveryResults'; export * from './fetchAllSiloDiscoveryResults'; +export * from './fetchAllConsentWorkflowTriggers'; +export * from './syncConsentWorkflowTriggers'; +export * from './syncPurposes'; diff --git a/src/lib/graphql/pullTranscendConfiguration.ts b/src/lib/graphql/pullTranscendConfiguration.ts index e35d7ec9..0db959c8 100644 --- a/src/lib/graphql/pullTranscendConfiguration.ts +++ b/src/lib/graphql/pullTranscendConfiguration.ts @@ -35,6 +35,7 @@ import { RiskLogicInput, ConsentPurpose, type SiloDiscoveryResultInput, + type ConsentWorkflowTriggerInput, } from '../../codecs'; import { RequestAction, @@ -95,6 +96,7 @@ import { import { parseAssessmentRiskLogic } from './parseAssessmentRiskLogic'; import { fetchAllPurposesAndPreferences } from './fetchAllPurposesAndPreferences'; import { fetchAllSiloDiscoveryResults } from './fetchAllSiloDiscoveryResults'; +import { fetchAllConsentWorkflowTriggers } from './fetchAllConsentWorkflowTriggers'; export const DEFAULT_TRANSCEND_PULL_RESOURCES = [ TranscendPullResource.DataSilos, @@ -189,6 +191,7 @@ export async function pullTranscendConfiguration( assessmentTemplates, purposes, siloDiscoveryResults, + consentWorkflowTriggers, ] = await Promise.all([ // Grab all data subjects in the organization resources.includes(TranscendPullResource.DataSilos) || @@ -349,6 +352,10 @@ export async function pullTranscendConfiguration( resources.includes(TranscendPullResource.SystemDiscovery) ? fetchAllSiloDiscoveryResults(client) : [], + // Fetch consent workflow triggers + resources.includes(TranscendPullResource.ConsentWorkflowTriggers) + ? fetchAllConsentWorkflowTriggers(client) + : [], ]); const consentManagerTheme = @@ -1530,6 +1537,35 @@ export async function pullTranscendConfiguration( ); } + // 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, + purposes: + trigger.consentWorkflowTriggerPurposes.length > 0 + ? trigger.consentWorkflowTriggerPurposes.map((p) => ({ + 'tracking-type': p.purpose.trackingType, + 'matching-state': p.matchingState, + })) + : undefined, + }), + ); + } + // save email templates if ( dataSiloIds.length === 0 && diff --git a/src/lib/graphql/syncConfigurationToTranscend.ts b/src/lib/graphql/syncConfigurationToTranscend.ts index 5d216809..1ff5efea 100644 --- a/src/lib/graphql/syncConfigurationToTranscend.ts +++ b/src/lib/graphql/syncConfigurationToTranscend.ts @@ -43,6 +43,8 @@ import { syncDataCategories } from './syncDataCategories'; import { syncProcessingPurposes } from './syncProcessingPurposes'; import { syncProcessingActivities } from './syncProcessingActivities'; import { syncPartitions } from './syncPartitions'; +import { syncConsentWorkflowTriggers } from './syncConsentWorkflowTriggers'; +import { syncPurposes } from './syncPurposes'; const CONCURRENCY = 10; @@ -107,6 +109,8 @@ export async function syncConfigurationToTranscend( messages, policies, partitions, + 'consent-workflow-triggers': consentWorkflowTriggers, + purposes, } = input; const [identifierByName, dataSubjectsByName, apiKeyTitleMap] = @@ -146,6 +150,21 @@ export async function syncConfigurationToTranscend( } } + // 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..7c17354e --- /dev/null +++ b/src/lib/graphql/syncConsentWorkflowTriggers.ts @@ -0,0 +1,143 @@ +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 [existingTriggers, actions, dataSubjects, purposes] = await Promise.all( + [ + fetchAllConsentWorkflowTriggers(client), + fetchAllActions(client), + fetchAllDataSubjects(client), + fetchAllPurposes(client), + ], + ); + + 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 } : {}), + ...(trigger['trigger-condition'] !== undefined + ? { 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'] } + : {}), + ...(consentWorkflowTriggerPurposes + ? { consentWorkflowTriggerPurposes } + : {}), + }; + + await makeGraphQLRequest( + client, + CREATE_OR_UPDATE_CONSENT_WORKFLOW_TRIGGER, + { input }, + ); + + 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/syncPurposes.ts b/src/lib/graphql/syncPurposes.ts new file mode 100644 index 00000000..e1f5d1b1 --- /dev/null +++ b/src/lib/graphql/syncPurposes.ts @@ -0,0 +1,162 @@ +import { ConsentPurpose } from '../../codecs'; +import { GraphQLClient } from 'graphql-request'; +import { + CREATE_PURPOSE, + UPDATE_PURPOSE, + CREATE_OR_UPDATE_PREFERENCE_TOPIC, +} from './gqls'; +import { logger } from '../../logger'; +import { keyBy } from 'lodash-es'; +import { makeGraphQLRequest } from './makeGraphQLRequest'; +import { fetchAllPurposes, type Purpose } from './fetchAllPurposes'; +import colors from 'colors'; +import { mapSeries } from '../bluebird'; + +/** + * Sync consent purposes (and nested preference topics) to Transcend + * + * @param client - GraphQL client + * @param inputs - Purpose inputs from YAML + * @returns True if run without error, returns false if an error occurred + */ +export async function syncPurposes( + client: GraphQLClient, + inputs: ConsentPurpose[], +): Promise { + logger.info(colors.magenta(`Syncing "${inputs.length}" purposes...`)); + + let encounteredError = false; + + const existingPurposes = await fetchAllPurposes(client); + const purposeByTrackingType = keyBy(existingPurposes, 'trackingType'); + + await mapSeries(inputs, async (purpose) => { + try { + const existing = purposeByTrackingType[purpose.trackingType]; + + const purposeFields = { + name: purpose.name, + trackingType: purpose.trackingType, + ...(purpose.description !== undefined + ? { description: purpose.description } + : {}), + ...(purpose['default-consent'] !== undefined + ? { defaultConsent: purpose['default-consent'] } + : {}), + ...(purpose.configurable !== undefined + ? { configurable: purpose.configurable } + : {}), + ...(purpose['show-in-consent-manager'] !== undefined + ? { showInConsentManager: purpose['show-in-consent-manager'] } + : {}), + ...(purpose['show-in-privacy-center'] !== undefined + ? { showInPrivacyCenter: purpose['show-in-privacy-center'] } + : {}), + ...(purpose['is-active'] !== undefined + ? { isActive: purpose['is-active'] } + : {}), + ...(purpose['display-order'] !== undefined + ? { displayOrder: purpose['display-order'] } + : {}), + ...(purpose['opt-out-signals'] !== undefined + ? { optOutSignals: purpose['opt-out-signals'] } + : {}), + ...(purpose['auth-level'] !== undefined + ? { authLevel: purpose['auth-level'] } + : {}), + }; + + let purposeId: string; + + if (existing) { + const { updatePurpose } = await makeGraphQLRequest<{ + /** Update purpose mutation result */ + updatePurpose: { + /** Updated purpose */ + trackingPurpose: Purpose; + }; + }>(client, UPDATE_PURPOSE, { + input: { + id: existing.id, + ...purposeFields, + }, + }); + purposeId = updatePurpose.trackingPurpose.id; + logger.info( + colors.green( + `Successfully updated purpose "${purpose.trackingType}"!`, + ), + ); + } else { + const { createPurpose } = await makeGraphQLRequest<{ + /** Create purpose mutation result */ + createPurpose: { + /** Created purpose */ + trackingPurpose: Purpose; + }; + }>(client, CREATE_PURPOSE, { + input: purposeFields, + }); + purposeId = createPurpose.trackingPurpose.id; + logger.info( + colors.green( + `Successfully created purpose "${purpose.trackingType}"!`, + ), + ); + } + + // Sync nested preference topics + if (purpose['preference-topics']?.length) { + for (const topic of purpose['preference-topics']) { + try { + await makeGraphQLRequest( + client, + CREATE_OR_UPDATE_PREFERENCE_TOPIC, + { + input: { + title: topic.title, + type: topic.type, + description: topic.description, + purposeId, + showInPrivacyCenter: topic['show-in-privacy-center'], + defaultConfiguration: topic['default-configuration'], + ...(topic.options?.length + ? { + options: topic.options.map((opt) => ({ + title: opt.title, + slug: opt.slug, + })), + } + : {}), + }, + }, + ); + logger.info( + colors.green( + `Successfully synced preference topic "${topic.title}" for purpose "${purpose.trackingType}"!`, + ), + ); + } catch (err) { + encounteredError = true; + logger.info( + colors.red( + `Failed to sync preference topic "${topic.title}" for purpose "${purpose.trackingType}"! - ${err.message}`, + ), + ); + } + } + } + } catch (err) { + encounteredError = true; + logger.info( + colors.red( + `Failed to sync purpose "${purpose.trackingType}"! - ${err.message}`, + ), + ); + } + }); + + logger.info(colors.green(`Synced "${inputs.length}" purposes!`)); + + return !encounteredError; +} From 24450d10b56d10c884a5e62a8cf6d0eaddeb9568 Mon Sep 17 00:00:00 2001 From: michaelfarrell76 Date: Tue, 24 Feb 2026 20:45:55 -0800 Subject: [PATCH 2/5] fix: remove consentWorkflowTriggerPurposes from query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend schema doesn't expose consentWorkflowTriggerPurposes as a queryable field on ConsentWorkflowTrigger — it is only available as a mutation input. Purposes can still be pushed but are not pulled. Co-authored-by: Cursor --- src/lib/graphql/fetchAllConsentWorkflowTriggers.ts | 12 ------------ src/lib/graphql/gqls/consentWorkflowTrigger.ts | 6 ------ src/lib/graphql/pullTranscendConfiguration.ts | 7 ------- 3 files changed, 25 deletions(-) diff --git a/src/lib/graphql/fetchAllConsentWorkflowTriggers.ts b/src/lib/graphql/fetchAllConsentWorkflowTriggers.ts index c85d1d3f..86a28af5 100644 --- a/src/lib/graphql/fetchAllConsentWorkflowTriggers.ts +++ b/src/lib/graphql/fetchAllConsentWorkflowTriggers.ts @@ -2,16 +2,6 @@ import { GraphQLClient } from 'graphql-request'; import { CONSENT_WORKFLOW_TRIGGERS } from './gqls'; import { makeGraphQLRequest } from './makeGraphQLRequest'; -export interface ConsentWorkflowTriggerPurpose { - /** Whether this purpose must match opted-in (true) or opted-out (false) */ - matchingState: boolean; - /** The associated purpose */ - purpose: { - /** Slug of purpose */ - trackingType: string; - }; -} - export interface ConsentWorkflowTrigger { /** ID of the trigger */ id: string; @@ -42,8 +32,6 @@ export interface ConsentWorkflowTrigger { /** Title of data silo */ title: string; }[]; - /** Purposes and their matching consent states */ - consentWorkflowTriggerPurposes: ConsentWorkflowTriggerPurpose[]; } const PAGE_SIZE = 20; diff --git a/src/lib/graphql/gqls/consentWorkflowTrigger.ts b/src/lib/graphql/gqls/consentWorkflowTrigger.ts index b7c02fc9..6fbe047e 100644 --- a/src/lib/graphql/gqls/consentWorkflowTrigger.ts +++ b/src/lib/graphql/gqls/consentWorkflowTrigger.ts @@ -20,12 +20,6 @@ export const CONSENT_WORKFLOW_TRIGGERS = gql` dataSilos { title } - consentWorkflowTriggerPurposes { - matchingState - purpose { - trackingType - } - } } totalCount } diff --git a/src/lib/graphql/pullTranscendConfiguration.ts b/src/lib/graphql/pullTranscendConfiguration.ts index 0db959c8..826ce771 100644 --- a/src/lib/graphql/pullTranscendConfiguration.ts +++ b/src/lib/graphql/pullTranscendConfiguration.ts @@ -1555,13 +1555,6 @@ export async function pullTranscendConfiguration( trigger.dataSilos.length > 0 ? trigger.dataSilos.map((ds) => ds.title) : undefined, - purposes: - trigger.consentWorkflowTriggerPurposes.length > 0 - ? trigger.consentWorkflowTriggerPurposes.map((p) => ({ - 'tracking-type': p.purpose.trackingType, - 'matching-state': p.matchingState, - })) - : undefined, }), ); } From 8ecffd08d939feecf03e08fde6567d5a8a972f1e Mon Sep 17 00:00:00 2001 From: michaelfarrell76 Date: Tue, 24 Feb 2026 21:30:52 -0800 Subject: [PATCH 3/5] fix: resolve consent workflow trigger push issues - Default triggerCondition to '{}' when not specified (fixes notNull violation) - Two-step create for new triggers: create first, then attach purposes - Add ViewDataSubjectRequestSettings and ViewConsentManager to push scopes - Update example YAML with correct action-type, data-subject-type, and purposes Co-authored-by: Cursor --- examples/consent-workflow-triggers.yml | 7 +-- src/constants.ts | 2 + .../graphql/syncConsentWorkflowTriggers.ts | 51 ++++++++++++++----- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/examples/consent-workflow-triggers.yml b/examples/consent-workflow-triggers.yml index 52493c5f..30414f82 100644 --- a/examples/consent-workflow-triggers.yml +++ b/examples/consent-workflow-triggers.yml @@ -1,19 +1,16 @@ consent-workflow-triggers: - name: Erasure on opt-out action-type: ERASURE - data-subject-type: customer + data-subject-type: Customer is-silent: true allow-unauthenticated: false is-active: true - data-silo-titles: - - Salesforce - - HubSpot purposes: - tracking-type: Advertising matching-state: false - name: Access on request action-type: ACCESS - data-subject-type: customer + data-subject-type: Customer is-silent: false allow-unauthenticated: false is-active: true diff --git a/src/constants.ts b/src/constants.ts index 24a43872..0dc7ee42 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -90,6 +90,8 @@ export const TR_PUSH_RESOURCE_SCOPE_MAP: { [TranscendPullResource.SystemDiscovery]: [ScopeName.ManageDataMap], [TranscendPullResource.ConsentWorkflowTriggers]: [ ScopeName.ManageConsentManager, + ScopeName.ViewDataSubjectRequestSettings, + ScopeName.ViewConsentManager, ], }; diff --git a/src/lib/graphql/syncConsentWorkflowTriggers.ts b/src/lib/graphql/syncConsentWorkflowTriggers.ts index 7c17354e..cb1a2bae 100644 --- a/src/lib/graphql/syncConsentWorkflowTriggers.ts +++ b/src/lib/graphql/syncConsentWorkflowTriggers.ts @@ -28,12 +28,16 @@ export async function syncConsentWorkflowTriggers( 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), - fetchAllActions(client), - fetchAllDataSubjects(client), - fetchAllPurposes(client), + needsActions ? fetchAllActions(client) : ([] as Action[]), + needsSubjects ? fetchAllDataSubjects(client) : ([] as DataSubject[]), + needsPurposes ? fetchAllPurposes(client) : ([] as Purpose[]), ], ); @@ -95,9 +99,7 @@ export async function syncConsentWorkflowTriggers( const input: Record = { name: trigger.name, ...(existingTrigger ? { id: existingTrigger.id } : {}), - ...(trigger['trigger-condition'] !== undefined - ? { triggerCondition: trigger['trigger-condition'] } - : {}), + triggerCondition: trigger['trigger-condition'] ?? '{}', ...(actionId ? { actionId } : {}), ...(dataSubjectId ? { dataSubjectId } : {}), ...(trigger['is-silent'] !== undefined @@ -109,16 +111,41 @@ export async function syncConsentWorkflowTriggers( ...(trigger['is-active'] !== undefined ? { isActive: trigger['is-active'] } : {}), - ...(consentWorkflowTriggerPurposes + ...(existingTrigger && consentWorkflowTriggerPurposes ? { consentWorkflowTriggerPurposes } : {}), }; - await makeGraphQLRequest( - client, - CREATE_OR_UPDATE_CONSENT_WORKFLOW_TRIGGER, - { input }, - ); + 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( From 50aba545075c32ed3216d1cfc63847a47513835d Mon Sep 17 00:00:00 2001 From: michaelfarrell76 Date: Tue, 24 Feb 2026 21:41:12 -0800 Subject: [PATCH 4/5] WIP --- test-pull-triggers.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 test-pull-triggers.yml 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 @@ +{} From 800bbedbcd5a80c31700c040214e62a6308953dc Mon Sep 17 00:00:00 2001 From: michaelfarrell76 Date: Wed, 18 Mar 2026 14:51:08 -0700 Subject: [PATCH 5/5] feat: add preferenceOptions resource and enhance syncPurposes - Add preferenceOptions as standalone pull/push resource with paginated fetch, bulk create/update sync, GQL queries, scope maps, and docgen - Replace simple syncPurposes with concurrent version that resolves preference option value slugs to IDs and extracts reusable createOrUpdatePreferenceTopics helper Made-with: Cursor --- src/codecs.ts | 4 + src/constants.ts | 7 + src/enums.ts | 1 + .../docgen/createPullResourceScopesTable.ts | 8 + .../graphql/fetchAllPreferenceOptionValues.ts | 53 +++ src/lib/graphql/gqls/preferenceTopic.ts | 28 ++ src/lib/graphql/index.ts | 2 + src/lib/graphql/pullTranscendConfiguration.ts | 20 + .../graphql/syncConfigurationToTranscend.ts | 10 + src/lib/graphql/syncPreferenceOptionValues.ts | 92 ++++ src/lib/graphql/syncPurposes.ts | 400 ++++++++++++------ 11 files changed, 488 insertions(+), 137 deletions(-) create mode 100644 src/lib/graphql/fetchAllPreferenceOptionValues.ts create mode 100644 src/lib/graphql/syncPreferenceOptionValues.ts diff --git a/src/codecs.ts b/src/codecs.ts index a21df338..45ae81d4 100644 --- a/src/codecs.ts +++ b/src/codecs.ts @@ -2149,6 +2149,10 @@ export const TranscendInput = t.partial({ * 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 0dc7ee42..24816982 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -87,6 +87,9 @@ export const TR_PUSH_RESOURCE_SCOPE_MAP: { ScopeName.ManageConsentManager, ScopeName.ManagePreferenceStoreSettings, ], + [TranscendPullResource.PreferenceOptions]: [ + ScopeName.ManagePreferenceStoreSettings, + ], [TranscendPullResource.SystemDiscovery]: [ScopeName.ManageDataMap], [TranscendPullResource.ConsentWorkflowTriggers]: [ ScopeName.ManageConsentManager, @@ -141,6 +144,9 @@ export const TR_PULL_RESOURCE_SCOPE_MAP: { ScopeName.ViewConsentManager, ScopeName.ViewPreferenceStoreSettings, ], + [TranscendPullResource.PreferenceOptions]: [ + ScopeName.ViewPreferenceStoreSettings, + ], [TranscendPullResource.SystemDiscovery]: [ScopeName.ViewDataMap], [TranscendPullResource.ConsentWorkflowTriggers]: [ ScopeName.ViewConsentManager, @@ -183,6 +189,7 @@ 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', }; diff --git a/src/enums.ts b/src/enums.ts index b37e47f0..9c8d2ad8 100644 --- a/src/enums.ts +++ b/src/enums.ts @@ -56,6 +56,7 @@ 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 221bc799..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: [ 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/preferenceTopic.ts b/src/lib/graphql/gqls/preferenceTopic.ts index fb089e14..8a20ef8c 100644 --- a/src/lib/graphql/gqls/preferenceTopic.ts +++ b/src/lib/graphql/gqls/preferenceTopic.ts @@ -43,6 +43,34 @@ 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! diff --git a/src/lib/graphql/index.ts b/src/lib/graphql/index.ts index 5fbd26aa..bb8de0b1 100644 --- a/src/lib/graphql/index.ts +++ b/src/lib/graphql/index.ts @@ -90,5 +90,7 @@ 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 826ce771..14a5159a 100644 --- a/src/lib/graphql/pullTranscendConfiguration.ts +++ b/src/lib/graphql/pullTranscendConfiguration.ts @@ -34,6 +34,7 @@ import { AssessmentSectionQuestionInput, RiskLogicInput, ConsentPurpose, + type ConsentPreferenceTopicOptionValue, type SiloDiscoveryResultInput, type ConsentWorkflowTriggerInput, } from '../../codecs'; @@ -95,6 +96,7 @@ import { } from './parseAssessmentDisplayLogic'; import { parseAssessmentRiskLogic } from './parseAssessmentRiskLogic'; import { fetchAllPurposesAndPreferences } from './fetchAllPurposesAndPreferences'; +import { fetchAllPreferenceOptionValues } from './fetchAllPreferenceOptionValues'; import { fetchAllSiloDiscoveryResults } from './fetchAllSiloDiscoveryResults'; import { fetchAllConsentWorkflowTriggers } from './fetchAllConsentWorkflowTriggers'; @@ -190,6 +192,7 @@ export async function pullTranscendConfiguration( assessments, assessmentTemplates, purposes, + preferenceOptionValues, siloDiscoveryResults, consentWorkflowTriggers, ] = await Promise.all([ @@ -348,6 +351,10 @@ 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) @@ -1537,6 +1544,19 @@ 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 && diff --git a/src/lib/graphql/syncConfigurationToTranscend.ts b/src/lib/graphql/syncConfigurationToTranscend.ts index 1ff5efea..70441f5b 100644 --- a/src/lib/graphql/syncConfigurationToTranscend.ts +++ b/src/lib/graphql/syncConfigurationToTranscend.ts @@ -44,6 +44,7 @@ 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; @@ -150,6 +151,15 @@ 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); 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 index e1f5d1b1..29d57575 100644 --- a/src/lib/graphql/syncPurposes.ts +++ b/src/lib/graphql/syncPurposes.ts @@ -1,162 +1,288 @@ -import { ConsentPurpose } from '../../codecs'; +import { ConsentPreferenceTopic, ConsentPurpose } from '../../codecs'; +import colors from 'colors'; import { GraphQLClient } from 'graphql-request'; import { - CREATE_PURPOSE, UPDATE_PURPOSE, + CREATE_PURPOSE, CREATE_OR_UPDATE_PREFERENCE_TOPIC, } from './gqls'; -import { logger } from '../../logger'; -import { keyBy } from 'lodash-es'; import { makeGraphQLRequest } from './makeGraphQLRequest'; -import { fetchAllPurposes, type Purpose } from './fetchAllPurposes'; -import colors from 'colors'; -import { mapSeries } from '../bluebird'; +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; +} /** - * Sync consent purposes (and nested preference topics) to Transcend + * Create or update preference topics for a purpose * * @param client - GraphQL client - * @param inputs - Purpose inputs from YAML - * @returns True if run without error, returns false if an error occurred + * @param topics - Preference topics to create or update + * @param options - Options */ -export async function syncPurposes( +export async function createOrUpdatePreferenceTopics( client: GraphQLClient, - inputs: ConsentPurpose[], -): Promise { - logger.info(colors.magenta(`Syncing "${inputs.length}" purposes...`)); + 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 }, + ); +} - let encounteredError = false; +/** + * 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}"!`)); - const existingPurposes = await fetchAllPurposes(client); - const purposeByTrackingType = keyBy(existingPurposes, 'trackingType'); + 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; +} - await mapSeries(inputs, async (purpose) => { - try { - const existing = purposeByTrackingType[purpose.trackingType]; +/** + * 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 + }!`, + ), + ); - const purposeFields = { - name: purpose.name, - trackingType: purpose.trackingType, - ...(purpose.description !== undefined - ? { description: purpose.description } - : {}), - ...(purpose['default-consent'] !== undefined - ? { defaultConsent: purpose['default-consent'] } - : {}), - ...(purpose.configurable !== undefined - ? { configurable: purpose.configurable } - : {}), - ...(purpose['show-in-consent-manager'] !== undefined - ? { showInConsentManager: purpose['show-in-consent-manager'] } - : {}), - ...(purpose['show-in-privacy-center'] !== undefined - ? { showInPrivacyCenter: purpose['show-in-privacy-center'] } - : {}), - ...(purpose['is-active'] !== undefined - ? { isActive: purpose['is-active'] } - : {}), - ...(purpose['display-order'] !== undefined - ? { displayOrder: purpose['display-order'] } - : {}), - ...(purpose['opt-out-signals'] !== undefined - ? { optOutSignals: purpose['opt-out-signals'] } - : {}), - ...(purpose['auth-level'] !== undefined - ? { authLevel: purpose['auth-level'] } - : {}), - }; + 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...`)); - let purposeId: string; + const [existing, existingOptions] = await Promise.all([ + fetchAllPurposesAndPreferences(client), + fetchAllPreferenceOptionValues(client), + ]); + const purposeByTrackingType = keyBy(existing, 'trackingType'); + const optionValuesBySlug = keyBy(existingOptions, 'slug'); - if (existing) { - const { updatePurpose } = await makeGraphQLRequest<{ - /** Update purpose mutation result */ - updatePurpose: { - /** Updated purpose */ - trackingPurpose: Purpose; - }; - }>(client, UPDATE_PURPOSE, { - input: { - id: existing.id, - ...purposeFields, - }, - }); - purposeId = updatePurpose.trackingPurpose.id; - logger.info( - colors.green( - `Successfully updated purpose "${purpose.trackingType}"!`, - ), - ); - } else { - const { createPurpose } = await makeGraphQLRequest<{ - /** Create purpose mutation result */ - createPurpose: { - /** Created purpose */ - trackingPurpose: Purpose; - }; - }>(client, CREATE_PURPOSE, { - input: purposeFields, + 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, }); - purposeId = createPurpose.trackingPurpose.id; - logger.info( - colors.green( - `Successfully created purpose "${purpose.trackingType}"!`, - ), - ); - } + }, + { concurrency }, + ); + logger.info( + colors.green(`Successfully created ${newPurposes.length} purposes!`), + ); + } catch (err) { + encounteredError = true; + logger.info(colors.red(`Failed to create purposes! - ${err.message}`)); + } - // Sync nested preference topics - if (purpose['preference-topics']?.length) { - for (const topic of purpose['preference-topics']) { - try { - await makeGraphQLRequest( - client, - CREATE_OR_UPDATE_PREFERENCE_TOPIC, - { - input: { - title: topic.title, - type: topic.type, - description: topic.description, - purposeId, - showInPrivacyCenter: topic['show-in-privacy-center'], - defaultConfiguration: topic['default-configuration'], - ...(topic.options?.length - ? { - options: topic.options.map((opt) => ({ - title: opt.title, - slug: opt.slug, - })), - } - : {}), - }, - }, - ); - logger.info( - colors.green( - `Successfully synced preference topic "${topic.title}" for purpose "${purpose.trackingType}"!`, - ), - ); - } catch (err) { - encounteredError = true; - logger.info( - colors.red( - `Failed to sync preference topic "${topic.title}" for purpose "${purpose.trackingType}"! - ${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}`, + ), + ); } - } - } catch (err) { - encounteredError = true; - logger.info( - colors.red( - `Failed to sync purpose "${purpose.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 "${inputs.length}" purposes!`)); + logger.info(colors.green(`Synced "${purposes.length}" purposes!`)); return !encounteredError; }