diff --git a/.changeset/pull-compliance-reports.md b/.changeset/pull-compliance-reports.md new file mode 100644 index 00000000..891a3d0c --- /dev/null +++ b/.changeset/pull-compliance-reports.md @@ -0,0 +1,6 @@ +--- +'@transcend-io/cli': minor +'@transcend-io/sdk': minor +--- + +Add transcend.yml pull/push for Data Map compliance reports (`compliance-reports`), with Article 30 default columns, title idempotency, and singular `compliance-report` alias migration. diff --git a/packages/cli/README.md b/packages/cli/README.md index 95288150..3ad9f2e7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2431,7 +2431,7 @@ transcend consent delete-preference-records \ ```txt USAGE - transcend inventory pull (--auth value) [--resources all|apiKeys|customFields|templates|dataSilos|enrichers|dataFlows|businessEntities|processingActivities|actions|dataSubjects|identifiers|cookies|consentManager|partitions|prompts|promptPartials|promptGroups|agents|agentFunctions|agentFiles|vendors|dataCategories|processingPurposes|actionItems|actionItemCollections|teams|privacyCenters|policies|messages|assessments|assessmentTemplates|purposes|preferenceOptions|systemDiscovery] [--file value] [--transcendUrl value] [--dataSiloIds value]... [--integrationNames value]... [--trackerStatuses LIVE|NEEDS_REVIEW] [--pageSize value] [--skipDatapoints] [--skipSubDatapoints] [--includeGuessedCategories] [--debug] + transcend inventory pull (--auth value) [--resources all|apiKeys|customFields|templates|dataSilos|enrichers|dataFlows|businessEntities|processingActivities|complianceReports|actions|dataSubjects|identifiers|cookies|consentManager|partitions|prompts|promptPartials|promptGroups|agents|agentFunctions|agentFiles|vendors|dataCategories|processingPurposes|actionItems|actionItemCollections|teams|privacyCenters|policies|messages|assessments|assessmentTemplates|purposes|preferenceOptions|systemDiscovery] [--file value] [--transcendUrl value] [--dataSiloIds value]... [--integrationNames value]... [--trackerStatuses LIVE|NEEDS_REVIEW] [--pageSize value] [--skipDatapoints] [--skipSubDatapoints] [--includeGuessedCategories] [--debug] transcend inventory pull --help Generates a transcend.yml by pulling the configuration from your Transcend instance. @@ -2445,7 +2445,7 @@ This command can be helpful if you are looking to: FLAGS --auth The Transcend API key. Defaults to the TRANSCEND_API_KEY environment variable when set, so --auth may be omitted if it is exported. The scopes required will vary depending on the operation performed. If in doubt, the Full Admin scope will always work. - [--resources] The different resource types to pull in. Defaults to dataSilos,enrichers,templates,apiKeys. [all|apiKeys|customFields|templates|dataSilos|enrichers|dataFlows|businessEntities|processingActivities|actions|dataSubjects|identifiers|cookies|consentManager|partitions|prompts|promptPartials|promptGroups|agents|agentFunctions|agentFiles|vendors|dataCategories|processingPurposes|actionItems|actionItemCollections|teams|privacyCenters|policies|messages|assessments|assessmentTemplates|purposes|preferenceOptions|systemDiscovery, separator = ,] + [--resources] The different resource types to pull in. Defaults to dataSilos,enrichers,templates,apiKeys. [all|apiKeys|customFields|templates|dataSilos|enrichers|dataFlows|businessEntities|processingActivities|complianceReports|actions|dataSubjects|identifiers|cookies|consentManager|partitions|prompts|promptPartials|promptGroups|agents|agentFunctions|agentFiles|vendors|dataCategories|processingPurposes|actionItems|actionItemCollections|teams|privacyCenters|policies|messages|assessments|assessmentTemplates|purposes|preferenceOptions|systemDiscovery, separator = ,] [--file] Path to the YAML file to pull into [default = ./transcend.yml] [--transcendUrl] URL of the Transcend backend. Use https://api.us.transcend.io for US hosting. Defaults to the TRANSCEND_API_URL environment variable when set, so --transcendUrl may be omitted if it is exported. [default = https://api.transcend.io] [--dataSiloIds]... The UUIDs of the data silos that should be pulled into the YAML file [separator = ,] @@ -2473,6 +2473,7 @@ The API key permissions for this command vary based on the `resources` argument: | `dataFlows` | `data-flows` | Consent Manager Data Flow definitions. | View Data Flows | [Consent Management -> Data Flows](https://app.transcend.io/consent-manager/data-flows/approved) | | `businessEntities` | `business-entities` | The business entities in the Data Inventory. | View Data Inventory | [Data Inventory -> Business Entities](https://app.transcend.io/data-map/data-inventory/business-entities) | | `processingActivities` | `processing-activities` | The processing activities in the Data Inventory. | View Data Inventory | [Data Inventory -> Processing Activities](https://app.transcend.io/data-map/data-inventory/processing-activities) | +| `complianceReports` | `compliance-reports` | Compliance reports (RoPA / Article 30) in the Data Map. | View Data Map | [Data Map -> Compliance Reports](https://app.transcend.io/data-map/compliance-reports) | | `actions` | `actions` | The privacy request action settings. | View Data Subject Request Settings | [DSR Automation -> Request Settings -> Data Actions](https://app.transcend.io/privacy-requests/settings/data-actions) | | `dataSubjects` | `data-subjects` | The privacy request data subject settings. | View Data Subject Request Settings | [DSR Automation -> Request Settings -> Data Subjects](https://app.transcend.io/privacy-requests/settings/data-subjects) | | `identifiers` | `identifiers` | The privacy request identifier configurations. | View Identity Verification Settings | [DSR Automation -> Identifiers](https://app.transcend.io/privacy-requests/identifiers) | @@ -2577,6 +2578,12 @@ transcend inventory pull --auth="$TRANSCEND_API_KEY" --resources=businessEntitie transcend inventory pull --auth="$TRANSCEND_API_KEY" --resources=processingActivities ``` +**Pull compliance reports only (see [this example](./examples/compliance-reports.yml))** + +```sh +transcend inventory pull --auth="$TRANSCEND_API_KEY" --resources=complianceReports +``` + **Pull enrichers and identifiers (see [this example](./examples/enrichers.yml))** ```sh @@ -2686,6 +2693,7 @@ The API key permissions for this command vary based on the resources declared as | `dataFlows` | `data-flows` | Consent Manager Data Flow definitions. | Manage Data Flows | [Consent Management -> Data Flows](https://app.transcend.io/consent-manager/data-flows/approved) | | `businessEntities` | `business-entities` | The business entities in the Data Inventory. | Manage Data Inventory | [Data Inventory -> Business Entities](https://app.transcend.io/data-map/data-inventory/business-entities) | | `processingActivities` | `processing-activities` | The processing activities in the Data Inventory. | Manage Data Map | [Data Inventory -> Processing Activities](https://app.transcend.io/data-map/data-inventory/processing-activities) | +| `complianceReports` | `compliance-reports` | Compliance reports (RoPA / Article 30) in the Data Map. | Manage Data Map | [Data Map -> Compliance Reports](https://app.transcend.io/data-map/compliance-reports) | | `actions` | `actions` | The privacy request action settings. | Manage Data Subject Request Settings | [DSR Automation -> Request Settings -> Data Actions](https://app.transcend.io/privacy-requests/settings/data-actions) | | `dataSubjects` | `data-subjects` | The privacy request data subject settings. | Manage Data Subject Request Settings | [DSR Automation -> Request Settings -> Data Subjects](https://app.transcend.io/privacy-requests/settings/data-subjects) | | `identifiers` | `identifiers` | The privacy request identifier configurations. | Manage Request Identity Verification | [DSR Automation -> Identifiers](https://app.transcend.io/privacy-requests/identifiers) | @@ -2801,6 +2809,16 @@ Some things to note about this sync process: - a) Data silo owners: If you assign an email address to a data silo, you must first make sure that user is invited into your Transcend instance (https://app.transcend.io/admin/users). - b) API keys: This CLI will not create new API keys. You will need to first create the new API keys on the Admin Dashboard (https://app.transcend.io/infrastructure/api-keys). You can then list out the titles of the API keys that you generated in your transcend.yml file, after which the CLI is capable of updating that API key to be able to respond to different data silos in your Data Map +#### Compliance reports push notes + +When pushing `compliance-reports`: + +- **Idempotency:** Reports are matched by `title`. Prefer `processing-activities-filter.text` for portable YAML. +- **Columns:** Empty or omitted `columns` uses sensible Article 30 defaults on push. Explicit values are `ProcessingActivitiesColumnName` strings or attribute-key UUIDs. +- **Locale:** `locale` is not on `CreateComplianceReportInput` — Italian (or other) export language follows the Admin user's locale. The CLI warns and strips `locale` if present. +- **Singular alias:** Demo seeds may use `compliance-report`; migrate to plural `compliance-reports` (accepted as an alias on push). +- **Example:** See [compliance-reports.yml](../../../../examples/compliance-reports.yml). + #### Preference management push notes When pushing `purposes`, `preference-options`, or nested `preference-topics`: diff --git a/packages/cli/examples/compliance-reports.yml b/packages/cli/examples/compliance-reports.yml new file mode 100644 index 00000000..c5f89906 --- /dev/null +++ b/packages/cli/examples/compliance-reports.yml @@ -0,0 +1,41 @@ +# Compliance reports (Data Map → Compliance Reports / RoPA) +# +# Idempotent by `title` on push. Prefer `processing-activities-filter.text` for +# portable YAML; ID-based filters are organization-specific. +# +# `columns: []` (or omitted) uses sensible Article 30 defaults on push. +# Explicit columns are ProcessingActivitiesColumnName values or attribute-key UUIDs. +# +# Note: `locale` is NOT supported on CreateComplianceReportInput — Italian (or +# other) export language follows the Admin user's locale, not transcend.yml. +# +# Demo seeds may use singular `compliance-report`; migrate to plural +# `compliance-reports` (the CLI accepts the singular as an alias on push). + +compliance-reports: + - title: Article 30 CCTV RoPA — Italian Export + description: >- + GDPR Article 30 register filtered to CCTV processing activities for Disney + Italia theme park entrances. + processing-activities-filter: + text: CCTV — Disney Italia Theme Park Entrances + columns: [] + - title: Full Article 30 Register + description: Organization-wide RoPA with default Article 30 columns + processing-activities-filter: + text: '' + columns: + - title + - description + - dataSubjects + - processingPurposeSubCategories + - dataSubCategories + - controllerships + - retentionType + - storageRegions + - transferRegions + - securityMeasureDetails + - dataSilos + - owners + - teams + data-protection-officer-email: dpo@acme.com diff --git a/packages/cli/schema/transcend-yml-schema-latest.json b/packages/cli/schema/transcend-yml-schema-latest.json index 026b2d5a..abe52959 100644 --- a/packages/cli/schema/transcend-yml-schema-latest.json +++ b/packages/cli/schema/transcend-yml-schema-latest.json @@ -54356,6 +54356,131 @@ ] } }, + "compliance-reports": { + "type": "array", + "items": { + "allOf": [ + { + "type": "object", + "required": ["title"], + "properties": { + "title": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "processing-activities-filter": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "attributeValueIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "businessEntityIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "dataSiloIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "dataSubjectIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "teamIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "ownerIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "purposes": { + "type": "array", + "items": { + "type": "string" + } + }, + "processingPurposeSubCategoryIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "dataCategories": { + "type": "array", + "items": { + "type": "string" + } + }, + "dataSubCategoryIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "saaSCategoryIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "vendorIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "controllerships": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "columns": { + "type": "array", + "items": { + "type": "string" + } + }, + "data-protection-officer-email": { + "type": "string" + } + } + } + ] + } + }, "purposes": { "type": "array", "items": { diff --git a/packages/cli/schema/transcend-yml-schema-v10.json b/packages/cli/schema/transcend-yml-schema-v10.json index 89d09230..85ce94c2 100644 --- a/packages/cli/schema/transcend-yml-schema-v10.json +++ b/packages/cli/schema/transcend-yml-schema-v10.json @@ -54356,6 +54356,131 @@ ] } }, + "compliance-reports": { + "type": "array", + "items": { + "allOf": [ + { + "type": "object", + "required": ["title"], + "properties": { + "title": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "processing-activities-filter": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "attributeValueIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "businessEntityIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "dataSiloIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "dataSubjectIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "teamIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "ownerIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "purposes": { + "type": "array", + "items": { + "type": "string" + } + }, + "processingPurposeSubCategoryIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "dataCategories": { + "type": "array", + "items": { + "type": "string" + } + }, + "dataSubCategoryIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "saaSCategoryIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "vendorIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "controllerships": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "columns": { + "type": "array", + "items": { + "type": "string" + } + }, + "data-protection-officer-email": { + "type": "string" + } + } + } + ] + } + }, "purposes": { "type": "array", "items": { diff --git a/packages/cli/src/codecs.ts b/packages/cli/src/codecs.ts index c7e748fe..594944e0 100644 --- a/packages/cli/src/codecs.ts +++ b/packages/cli/src/codecs.ts @@ -901,6 +901,88 @@ export const ProcessingActivityInput = t.intersection([ /** Type override */ export type ProcessingActivityInput = t.TypeOf; +/** + * Filter for processing activities included in a compliance report. + * Prefer `text` for portable YAML; ID-based filters are organization-specific. + * + * @see CreateComplianceReportInput.processingActivitiesFilter + */ +export const ComplianceReportProcessingActivitiesFilterInput = t.partial({ + /** Free-text filter over processing activity title/description */ + text: t.string, + /** Processing activity IDs */ + ids: t.array(t.string), + /** Attribute value IDs */ + attributeValueIds: t.array(t.string), + /** Business entity IDs */ + businessEntityIds: t.array(t.string), + /** Data silo IDs */ + dataSiloIds: t.array(t.string), + /** Data subject IDs */ + dataSubjectIds: t.array(t.string), + /** Team IDs */ + teamIds: t.array(t.string), + /** Owner user IDs */ + ownerIds: t.array(t.string), + /** Processing purposes */ + purposes: t.array(t.string), + /** Processing purpose sub-category IDs */ + processingPurposeSubCategoryIds: t.array(t.string), + /** Data category types */ + dataCategories: t.array(t.string), + /** Data sub-category IDs */ + dataSubCategoryIds: t.array(t.string), + /** SaaS category IDs */ + saaSCategoryIds: t.array(t.string), + /** Vendor IDs */ + vendorIds: t.array(t.string), + /** Controllership values */ + controllerships: t.array(t.string), +}); + +/** Type override */ +export type ComplianceReportProcessingActivitiesFilterInput = t.TypeOf< + typeof ComplianceReportProcessingActivitiesFilterInput +>; + +/** + * Input to define a compliance report (Data Map → Compliance Reports / RoPA). + * + * Note: `locale` is NOT part of CreateComplianceReportInput — Italian (or other) + * export language follows the Admin user's locale. Do not put `locale` in YAML. + * + * Demo seeds may use singular `compliance-report`; the codec key is plural + * `compliance-reports` (see validateTranscendInputForPush for the alias). + * + * @see https://app.transcend.io/data-map/compliance-reports + */ +export const ComplianceReportInput = t.intersection([ + t.type({ + /** The title of the compliance report (idempotency key on push) */ + title: t.string, + }), + t.partial({ + /** Description of the compliance report */ + description: t.string, + /** + * Filter for which processing activities to include. + * Prefer `text` for portable configs. + */ + 'processing-activities-filter': ComplianceReportProcessingActivitiesFilterInput, + /** + * Columns of the report in order. + * Each entry is a ProcessingActivitiesColumnName value or an attribute-key UUID. + * Empty / omitted → sensible Article 30 defaults on push. + */ + columns: t.array(t.string), + /** Email of the data protection officer */ + 'data-protection-officer-email': t.string, + }), +]); + +/** Type override */ +export type ComplianceReportInput = t.TypeOf; + /** * Software development kit inputs * @@ -2065,6 +2147,12 @@ export const TranscendInput = t.partial({ * Processing activity definitions */ 'processing-activities': t.array(ProcessingActivityInput), + /** + * Compliance report (RoPA) definitions + * + * @see https://app.transcend.io/data-map/compliance-reports + */ + 'compliance-reports': t.array(ComplianceReportInput), /** * Consent and preference management purposes */ diff --git a/packages/cli/src/commands/inventory/pull/readme.ts b/packages/cli/src/commands/inventory/pull/readme.ts index 9d3f620f..d50be0d5 100644 --- a/packages/cli/src/commands/inventory/pull/readme.ts +++ b/packages/cli/src/commands/inventory/pull/readme.ts @@ -106,6 +106,14 @@ const examples = buildExamples( resources: [TranscendPullResource.ProcessingActivities], }, }, + { + description: + 'Pull compliance reports only (see [this example](./examples/compliance-reports.yml))', + flags: { + auth: '$TRANSCEND_API_KEY', + resources: [TranscendPullResource.ComplianceReports], + }, + }, { description: 'Pull enrichers and identifiers (see [this example](./examples/enrichers.yml))', flags: { diff --git a/packages/cli/src/commands/inventory/push/readme.ts b/packages/cli/src/commands/inventory/push/readme.ts index 002e2815..5eac0c41 100644 --- a/packages/cli/src/commands/inventory/push/readme.ts +++ b/packages/cli/src/commands/inventory/push/readme.ts @@ -146,6 +146,16 @@ Some things to note about this sync process: - a) Data silo owners: If you assign an email address to a data silo, you must first make sure that user is invited into your Transcend instance (https://app.transcend.io/admin/users). - b) API keys: This CLI will not create new API keys. You will need to first create the new API keys on the Admin Dashboard (https://app.transcend.io/infrastructure/api-keys). You can then list out the titles of the API keys that you generated in your transcend.yml file, after which the CLI is capable of updating that API key to be able to respond to different data silos in your Data Map +#### Compliance reports push notes + +When pushing \`compliance-reports\`: + +- **Idempotency:** Reports are matched by \`title\`. Prefer \`processing-activities-filter.text\` for portable YAML. +- **Columns:** Empty or omitted \`columns\` uses sensible Article 30 defaults on push. Explicit values are \`ProcessingActivitiesColumnName\` strings or attribute-key UUIDs. +- **Locale:** \`locale\` is not on \`CreateComplianceReportInput\` — Italian (or other) export language follows the Admin user's locale. The CLI warns and strips \`locale\` if present. +- **Singular alias:** Demo seeds may use \`compliance-report\`; migrate to plural \`compliance-reports\` (accepted as an alias on push). +- **Example:** See [compliance-reports.yml](../../../../examples/compliance-reports.yml). + #### Preference management push notes When pushing \`purposes\`, \`preference-options\`, or nested \`preference-topics\`: diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 4f9735c5..acc9b47b 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -48,6 +48,8 @@ export const TR_PUSH_RESOURCE_SCOPE_MAP: { [TranscendPullResource.Enrichers]: [ScopeName.ManageRequestIdentities], [TranscendPullResource.BusinessEntities]: [ScopeName.ManageDataInventory], [TranscendPullResource.ProcessingActivities]: [ScopeName.ManageDataMap], + // createComplianceReport / update / reprocess / delete live under ManageDataMap + [TranscendPullResource.ComplianceReports]: [ScopeName.ManageDataMap], [TranscendPullResource.Identifiers]: [ScopeName.ManageRequestIdentities], [TranscendPullResource.Attributes]: [ScopeName.ManageGlobalAttributes], [TranscendPullResource.DataFlows]: [ScopeName.ManageDataFlow], @@ -99,6 +101,8 @@ export const TR_PULL_RESOURCE_SCOPE_MAP: { [TranscendPullResource.Enrichers]: [ScopeName.ViewRequestIdentitySettings], [TranscendPullResource.BusinessEntities]: [ScopeName.ViewDataInventory], [TranscendPullResource.ProcessingActivities]: [ScopeName.ViewDataInventory], + // complianceReports query is on ViewDataMap (not ViewDataInventory) + [TranscendPullResource.ComplianceReports]: [ScopeName.ViewDataMap], [TranscendPullResource.Identifiers]: [ScopeName.ViewRequestIdentitySettings], [TranscendPullResource.Attributes]: [ScopeName.ViewGlobalAttributes], [TranscendPullResource.DataFlows]: [ScopeName.ViewDataFlow], @@ -143,6 +147,7 @@ export const TR_YML_RESOURCE_TO_FIELD_NAME: Record Processing Activities](https://app.transcend.io/data-map/data-inventory/processing-activities)', ], }, + [TranscendPullResource.ComplianceReports]: { + description: 'Compliance reports (RoPA / Article 30) in the Data Map.', + markdownLinks: [ + '[Data Map -> Compliance Reports](https://app.transcend.io/data-map/compliance-reports)', + ], + }, [TranscendPullResource.Actions]: { description: 'The privacy request action settings.', markdownLinks: [ diff --git a/packages/cli/src/lib/graphql/pullTranscendConfiguration.ts b/packages/cli/src/lib/graphql/pullTranscendConfiguration.ts index 78fd31f1..1978948c 100644 --- a/packages/cli/src/lib/graphql/pullTranscendConfiguration.ts +++ b/packages/cli/src/lib/graphql/pullTranscendConfiguration.ts @@ -37,6 +37,7 @@ import { fetchAllPromptGroups, fetchAllPromptPartials, fetchAllProcessingActivities, + fetchAllComplianceReports, fetchAllPrompts, fetchAllPurposesAndPreferences, fetchAllPreferenceOptionValues, @@ -77,6 +78,7 @@ import { FieldInput, ProcessingPurposeInput, ProcessingActivityInput, + ComplianceReportInput, DataCategoryInput, VendorInput, AgentFileInput, @@ -170,6 +172,7 @@ export async function pullTranscendConfiguration( actions, businessEntities, processingActivities, + complianceReports, consentManager, consentManagerExperiences, consentVariants, @@ -278,6 +281,10 @@ export async function pullTranscendConfiguration( resources.includes(TranscendPullResource.ProcessingActivities) ? fetchAllProcessingActivities(client, { logger }) : [], + // Fetch compliance reports + resources.includes(TranscendPullResource.ComplianceReports) + ? fetchAllComplianceReports(client, { logger }) + : [], // Fetch consent manager resources.includes(TranscendPullResource.ConsentManager) ? fetchConsentManager(client, { logger }) @@ -1037,6 +1044,28 @@ export async function pullTranscendConfiguration( ); } + // Save compliance reports + if (complianceReports.length > 0 && resources.includes(TranscendPullResource.ComplianceReports)) { + result['compliance-reports'] = complianceReports.map( + ({ + title, + description, + processingActivitiesFilter, + columns, + dataProtectionOfficer, + }): ComplianceReportInput => ({ + title, + description: description ?? undefined, + 'processing-activities-filter': + Object.keys(processingActivitiesFilter).length > 0 + ? processingActivitiesFilter + : undefined, + columns: columns.length > 0 ? columns : undefined, + 'data-protection-officer-email': dataProtectionOfficer?.email, + }), + ); + } + // Save Actions if (actions.length > 0 && resources.includes(TranscendPullResource.Actions)) { result.actions = actions.map( diff --git a/packages/cli/src/lib/graphql/syncConfigurationToTranscend.ts b/packages/cli/src/lib/graphql/syncConfigurationToTranscend.ts index 29c510f6..bf4ae7b8 100644 --- a/packages/cli/src/lib/graphql/syncConfigurationToTranscend.ts +++ b/packages/cli/src/lib/graphql/syncConfigurationToTranscend.ts @@ -25,6 +25,7 @@ import { syncPolicies, syncPrivacyCenter, syncProcessingActivities, + syncComplianceReports, syncProcessingPurposes, syncPromptGroups, syncPromptPartials, @@ -116,6 +117,7 @@ export async function syncConfigurationToTranscend( vendors, 'data-categories': dataCategories, 'processing-activities': processingActivities, + 'compliance-reports': complianceReports, 'processing-purposes': processingPurposes, 'action-items': actionItems, 'action-item-collections': actionItemCollections, @@ -559,6 +561,14 @@ export async function syncConfigurationToTranscend( encounteredError = encounteredError || !processingActivitySuccess; } + // Sync compliance reports after processing activities (reports filter on them) + if (complianceReports) { + const complianceReportSuccess = await syncComplianceReports(client, complianceReports, { + logger, + }); + encounteredError = encounteredError || !complianceReportSuccess; + } + if (publishToPrivacyCenter) { // TODO: https://transcend.height.app/T-23779 } diff --git a/packages/cli/src/lib/readTranscendYaml.ts b/packages/cli/src/lib/readTranscendYaml.ts index 134b61a9..271e8e09 100644 --- a/packages/cli/src/lib/readTranscendYaml.ts +++ b/packages/cli/src/lib/readTranscendYaml.ts @@ -61,7 +61,7 @@ export function readTranscendYaml(filePath: string, variables: ObjByString = {}) `Also check that there are no extra variables defined in your yaml: ${filePath}`, ); - // Normalize aliases / strip unsupported fields, then validate shape + // Migrate singular aliases (e.g. compliance-report → compliance-reports) then validate const { config } = normalizeTranscendInputForPush(yaml.load(replacedVariables)); return decodeCodec(TranscendInput, config); } diff --git a/packages/cli/src/lib/tests/transcendConfigPush.test.ts b/packages/cli/src/lib/tests/transcendConfigPush.test.ts index 8158417f..13529a9e 100644 --- a/packages/cli/src/lib/tests/transcendConfigPush.test.ts +++ b/packages/cli/src/lib/tests/transcendConfigPush.test.ts @@ -48,4 +48,12 @@ describe('transcendConfigPush', () => { } as TranscendInput), ).toBe('purposes: (1), templates: (2)'); }); + + it('derivePushScopesFromTranscendInput requires ManageDataMap for compliance-reports', () => { + const scopes = derivePushScopesFromTranscendInput({ + 'compliance-reports': [{ title: 'Article 30', columns: [] }], + } as TranscendInput); + + expect(scopes).toContain(ScopeName.ManageDataMap); + }); }); diff --git a/packages/cli/src/lib/tests/validateTranscendInputForPush.test.ts b/packages/cli/src/lib/tests/validateTranscendInputForPush.test.ts index 49cab1f6..2ce45341 100644 --- a/packages/cli/src/lib/tests/validateTranscendInputForPush.test.ts +++ b/packages/cli/src/lib/tests/validateTranscendInputForPush.test.ts @@ -52,4 +52,39 @@ describe('validateTranscendInputForPush', () => { expect(result.valid).toBe(false); }); + + it('migrates singular compliance-report to plural compliance-reports', () => { + const result = validateTranscendInputForPush({ + 'compliance-report': [ + { + title: 'Article 30 CCTV RoPA', + 'processing-activities-filter': { text: 'CCTV' }, + columns: [], + }, + ], + }); + + expect(result.valid).toBe(true); + expect(result.normalizedConfig?.['compliance-reports']).toHaveLength(1); + expect(result.normalizedConfig?.['compliance-report']).toBeUndefined(); + expect(result.warnings.some((warning) => warning.includes('compliance-reports'))).toBe(true); + }); + + it('warns and strips locale on compliance-reports (not on CreateComplianceReportInput)', () => { + const result = validateTranscendInputForPush({ + 'compliance-reports': [ + { + title: 'Italian Export', + locale: 'it', + columns: ['title'], + }, + ], + }); + + expect(result.valid).toBe(true); + expect( + (result.normalizedConfig?.['compliance-reports'] as Record[])[0].locale, + ).toBeUndefined(); + expect(result.warnings.some((warning) => warning.includes('locale'))).toBe(true); + }); }); diff --git a/packages/cli/src/lib/validateTranscendInputForPush.ts b/packages/cli/src/lib/validateTranscendInputForPush.ts index eb856d50..2e2c6419 100644 --- a/packages/cli/src/lib/validateTranscendInputForPush.ts +++ b/packages/cli/src/lib/validateTranscendInputForPush.ts @@ -16,17 +16,18 @@ export const TRANSCEND_CONFIG_KEY_HINTS: Record = { 'consent-workflows': 'Use consent-workflow-triggers for consent automation triggers. Use workflow-configs for DSR workflow settings.', 'regional-experiences': 'Configure regional experiences under consent-manager.experiences.', + 'compliance-report': + 'Use plural compliance-reports (demo seeds historically used singular compliance-report).', }; const TRANSCEND_YML_KEYS = new Set( Object.values(TR_YML_RESOURCE_TO_FIELD_NAME) as string[], ); -/** - * Singular → plural (or legacy → canonical) aliases accepted on push. - * Resource-specific PRs can extend this map. - */ -const TOP_LEVEL_KEY_ALIASES: Record = {}; +/** Singular → plural aliases accepted on push (migrated before decode) */ +const TOP_LEVEL_KEY_ALIASES: Record = { + 'compliance-report': 'compliance-reports', +}; export interface PushValidationResult { /** Whether the config is valid for push */ @@ -39,7 +40,7 @@ export interface PushValidationResult { noopKeys: string[]; /** Non-fatal warnings (e.g. unsupported fields that were stripped) */ warnings: string[]; - /** Config after alias migration / field stripping, ready for decodeCodec */ + /** Config after alias migration / locale stripping, ready for decodeCodec */ normalizedConfig?: Record; } @@ -51,10 +52,11 @@ const YML_KEY_TO_RESOURCE = Object.fromEntries( ) as Record; /** - * Migrate known singular/legacy top-level keys before decode. + * Migrate known singular/legacy top-level keys and strip unsupported fields. * - * Resource-specific normalizations (aliases, unsupported field stripping) can - * extend {@link TOP_LEVEL_KEY_ALIASES} or add post-processing here in follow-up PRs. + * - `compliance-report` → `compliance-reports` + * - `locale` on compliance report entries is stripped with a warning + * (CreateComplianceReportInput has no locale; export language = Admin user locale) * * @param config - Raw YAML object * @returns Normalized config and warnings @@ -88,6 +90,25 @@ export function normalizeTranscendInputForPush(config: unknown): { } } + const reports = raw['compliance-reports']; + if (Array.isArray(reports)) { + raw['compliance-reports'] = reports.map((report, index) => { + if (!report || typeof report !== 'object' || Array.isArray(report)) { + return report; + } + const entry = { ...(report as Record) }; + if ('locale' in entry) { + warnings.push( + `compliance-reports[${index}].locale is not supported on CreateComplianceReportInput ` + + `and was ignored. Italian (or other) export language follows the Admin user's locale, ` + + `not transcend.yml.`, + ); + delete entry.locale; + } + return entry; + }); + } + return { config: raw, warnings }; } diff --git a/packages/sdk/src/data-inventory/complianceReportDefaults.ts b/packages/sdk/src/data-inventory/complianceReportDefaults.ts new file mode 100644 index 00000000..bc7182fc --- /dev/null +++ b/packages/sdk/src/data-inventory/complianceReportDefaults.ts @@ -0,0 +1,36 @@ +/** + * Sensible Article 30 / RoPA default columns when `columns` is empty in transcend.yml. + * + * Values are `ProcessingActivitiesColumnName` strings accepted by + * `CreateComplianceReportInput.columns` (or attribute-key UUIDs). + * + * @see https://app.transcend.io/data-map/compliance-reports + */ +export const ARTICLE_30_DEFAULT_COMPLIANCE_REPORT_COLUMNS = [ + 'title', + 'description', + 'dataSubjects', + 'processingPurposeSubCategories', + 'dataSubCategories', + 'controllerships', + 'retentionType', + 'storageRegions', + 'transferRegions', + 'securityMeasureDetails', + 'dataSilos', + 'owners', + 'teams', +] as const; + +/** + * Resolve columns for create/sync. Empty or omitted → Article 30 defaults. + * + * @param columns - Columns from YAML (ProcessingActivitiesColumnName or attribute key UUID) + * @returns Non-empty column list for CreateComplianceReportInput + */ +export function resolveComplianceReportColumns(columns: string[] | undefined): string[] { + if (columns && columns.length > 0) { + return columns; + } + return [...ARTICLE_30_DEFAULT_COMPLIANCE_REPORT_COLUMNS]; +} diff --git a/packages/sdk/src/data-inventory/fetchAllComplianceReports.ts b/packages/sdk/src/data-inventory/fetchAllComplianceReports.ts new file mode 100644 index 00000000..7845a7d5 --- /dev/null +++ b/packages/sdk/src/data-inventory/fetchAllComplianceReports.ts @@ -0,0 +1,216 @@ +import { mapSeries, type Logger } from '@transcend-io/utils'; +import { GraphQLClient } from 'graphql-request'; + +import { makeGraphQLRequest, NOOP_LOGGER } from '../api/makeGraphQLRequest.js'; +import { COMPLIANCE_REPORT_DATA, COMPLIANCE_REPORTS } from './gqls/complianceReport.js'; + +export interface ComplianceReportProcessingActivitiesFilter { + /** Free-text filter over processing activity title/description */ + text?: string; + /** Processing activity IDs */ + ids?: string[]; + /** Attribute value IDs */ + attributeValueIds?: string[]; + /** Business entity IDs */ + businessEntityIds?: string[]; + /** Data silo IDs */ + dataSiloIds?: string[]; + /** Data subject IDs */ + dataSubjectIds?: string[]; + /** Team IDs */ + teamIds?: string[]; + /** Owner user IDs */ + ownerIds?: string[]; + /** Processing purposes */ + purposes?: string[]; + /** Processing purpose sub-category IDs */ + processingPurposeSubCategoryIds?: string[]; + /** Data category types */ + dataCategories?: string[]; + /** Data sub-category IDs */ + dataSubCategoryIds?: string[]; + /** SaaS category IDs */ + saaSCategoryIds?: string[]; + /** Vendor IDs */ + vendorIds?: string[]; + /** Controllership values */ + controllerships?: string[]; +} + +export interface ComplianceReport { + /** ID of compliance report */ + id: string; + /** Title of compliance report */ + title: string; + /** Description of compliance report */ + description?: string | null; + /** JSON filter applied when the report was created (parsed) */ + processingActivitiesFilter: ComplianceReportProcessingActivitiesFilter; + /** Raw JSON string from GraphQL */ + processingActivitiesFilterRaw: string; + /** Data protection officer */ + dataProtectionOfficer?: { + /** User ID */ + id: string; + /** User email */ + email: string; + } | null; + /** + * Column sources used to build the report + * (ProcessingActivitiesColumnName or attribute key UUID). + * Empty when column data could not be loaded. + */ + columns: string[]; +} + +const PAGE_SIZE = 20; + +/** + * Parse the processingActivitiesFilter JSON blob from GraphQL. + * + * @param raw - JSON string from the API + * @returns Parsed filter object (empty object on failure) + */ +export function parseProcessingActivitiesFilter( + raw: string | null | undefined, +): ComplianceReportProcessingActivitiesFilter { + if (!raw) { + return {}; + } + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + return parsed as ComplianceReportProcessingActivitiesFilter; + } catch { + return {}; + } +} + +/** + * Fetch column sources for a single compliance report. + * + * @param client - GraphQL client + * @param complianceReportId - Report ID + * @param options - Options + * @returns Column source identifiers + */ +async function fetchComplianceReportColumns( + client: GraphQLClient, + complianceReportId: string, + options: { + /** Logger instance */ + logger?: Logger; + } = {}, +): Promise { + const { logger = NOOP_LOGGER } = options; + try { + const { + complianceReportData: { data }, + } = await makeGraphQLRequest<{ + /** Report data payload */ + complianceReportData: { + /** Structured report data */ + data: { + /** Columns */ + columns: { + /** Column ID */ + id: string; + /** Display name */ + name: string; + /** Source column / attribute key */ + source?: string | null; + }[]; + }; + }; + }>(client, COMPLIANCE_REPORT_DATA, { + variables: { id: complianceReportId }, + logger, + }); + return data.columns + .map((column) => column.source) + .filter((source): source is string => Boolean(source)); + } catch (err) { + logger.warn( + `Failed to fetch columns for compliance report "${complianceReportId}": ${(err as Error).message}`, + ); + return []; + } +} + +/** + * Fetch all compliance reports in the organization + * + * @param client - GraphQL client + * @param options - Options + * @returns All compliance reports in the organization + */ +export async function fetchAllComplianceReports( + client: GraphQLClient, + options: { + /** Logger instance */ + logger?: Logger; + /** When false, skip per-report column fetches (faster list-only pull) */ + includeColumns?: boolean; + } = {}, +): Promise { + const { logger = NOOP_LOGGER, includeColumns = true } = options; + const reports: Omit[] = []; + let offset = 0; + + let shouldContinue = false; + do { + const { + complianceReports: { nodes }, + } = await makeGraphQLRequest<{ + /** Compliance reports */ + complianceReports: { + /** List */ + nodes: { + /** ID */ + id: string; + /** Title */ + title: string; + /** Description */ + description?: string | null; + /** Filter JSON string */ + processingActivitiesFilter: string; + /** DPO */ + dataProtectionOfficer?: { + /** ID */ + id: string; + /** Email */ + email: string; + } | null; + }[]; + }; + }>(client, COMPLIANCE_REPORTS, { + variables: { first: PAGE_SIZE, offset }, + logger, + }); + reports.push( + ...nodes.map((node) => ({ + id: node.id, + title: node.title, + description: node.description, + processingActivitiesFilterRaw: node.processingActivitiesFilter, + processingActivitiesFilter: parseProcessingActivitiesFilter( + node.processingActivitiesFilter, + ), + dataProtectionOfficer: node.dataProtectionOfficer, + })), + ); + offset += PAGE_SIZE; + shouldContinue = nodes.length === PAGE_SIZE; + } while (shouldContinue); + + const withColumns = await mapSeries(reports, async (report) => { + const columns = includeColumns + ? await fetchComplianceReportColumns(client, report.id, { logger }) + : []; + return { ...report, columns }; + }); + + return withColumns.sort((a, b) => a.title.localeCompare(b.title)); +} diff --git a/packages/sdk/src/data-inventory/gqls/complianceReport.ts b/packages/sdk/src/data-inventory/gqls/complianceReport.ts new file mode 100644 index 00000000..3608ddf8 --- /dev/null +++ b/packages/sdk/src/data-inventory/gqls/complianceReport.ts @@ -0,0 +1,73 @@ +import { gql } from 'graphql-request'; + +export const COMPLIANCE_REPORTS = gql` + query TranscendCliComplianceReports($first: Int!, $offset: Int!) { + complianceReports(first: $first, offset: $offset, useMaster: false) { + nodes { + id + title + description + processingActivitiesFilter + dataProtectionOfficer { + id + email + } + } + } + } +`; + +export const COMPLIANCE_REPORT_DATA = gql` + query TranscendCliComplianceReportData($id: ID!) { + complianceReportData(filterBy: { id: $id }) { + data { + columns { + id + name + source + } + } + } + } +`; + +export const CREATE_COMPLIANCE_REPORT = gql` + mutation TranscendCliCreateComplianceReport($input: CreateComplianceReportInput!) { + createComplianceReport(input: $input) { + complianceReport { + id + title + } + } + } +`; + +export const UPDATE_COMPLIANCE_REPORT = gql` + mutation TranscendCliUpdateComplianceReport($input: UpdateComplianceReportInput!) { + updateComplianceReport(input: $input) { + complianceReport { + id + title + } + } + } +`; + +export const REPROCESS_COMPLIANCE_REPORT = gql` + mutation TranscendCliReprocessComplianceReport($input: ReprocessComplianceReportInput!) { + reprocessComplianceReport(input: $input) { + complianceReport { + id + title + } + } + } +`; + +export const DELETE_COMPLIANCE_REPORTS = gql` + mutation TranscendCliDeleteComplianceReports($input: DeleteComplianceReportsInput!) { + deleteComplianceReports(input: $input) { + clientMutationId + } + } +`; diff --git a/packages/sdk/src/data-inventory/index.ts b/packages/sdk/src/data-inventory/index.ts index 633d8811..ddfbac59 100644 --- a/packages/sdk/src/data-inventory/index.ts +++ b/packages/sdk/src/data-inventory/index.ts @@ -1,4 +1,6 @@ +export * from './complianceReportDefaults.js'; export * from './fetchAllBusinessEntities.js'; +export * from './fetchAllComplianceReports.js'; export * from './fetchAllDataCategories.js'; export * from './fetchAllDataPoints.js'; export * from './fetchAllDataSilos.js'; @@ -9,6 +11,7 @@ export * from './fetchEnrichedDataSilos.js'; export * from './gqls/dataPoint.js'; export * from './gqls/dataSilo.js'; export * from './syncBusinessEntities.js'; +export * from './syncComplianceReports.js'; export * from './syncDataCategories.js'; export * from './syncDataSiloDependencies.js'; export * from './syncProcessingActivities.js'; diff --git a/packages/sdk/src/data-inventory/syncComplianceReports.ts b/packages/sdk/src/data-inventory/syncComplianceReports.ts new file mode 100644 index 00000000..860e9755 --- /dev/null +++ b/packages/sdk/src/data-inventory/syncComplianceReports.ts @@ -0,0 +1,363 @@ +import { mapSeries, type Logger } from '@transcend-io/utils'; +import { GraphQLClient } from 'graphql-request'; +import { isEqual, keyBy } from 'lodash-es'; + +import { fetchAllUsers } from '../administration/fetchAllUsers.js'; +import { makeGraphQLRequest, NOOP_LOGGER } from '../api/makeGraphQLRequest.js'; +import { resolveComplianceReportColumns } from './complianceReportDefaults.js'; +import { + fetchAllComplianceReports, + type ComplianceReport, + type ComplianceReportProcessingActivitiesFilter, +} from './fetchAllComplianceReports.js'; +import { + CREATE_COMPLIANCE_REPORT, + DELETE_COMPLIANCE_REPORTS, + REPROCESS_COMPLIANCE_REPORT, + UPDATE_COMPLIANCE_REPORT, +} from './gqls/complianceReport.js'; + +export interface ComplianceReportInput { + /** The title of the compliance report (idempotency key) */ + title: string; + /** Description of the compliance report */ + description?: string; + /** + * Filter for which processing activities to include. + * Prefer `text` for portable YAML; ID-based filters are org-specific. + */ + 'processing-activities-filter'?: ComplianceReportProcessingActivitiesFilter; + /** + * Columns of the report in order. + * Each entry is a ProcessingActivitiesColumnName value or an attribute-key UUID. + * Empty / omitted → Article 30 defaults (see resolveComplianceReportColumns). + */ + columns?: string[]; + /** Email of the data protection officer (resolved to user ID on push) */ + 'data-protection-officer-email'?: string; +} + +/** + * Convert YAML kebab-case filter to GraphQL camelCase input. + * + * @param filter - YAML filter + * @returns GraphQL ProcessingActivitiesFiltersInput + */ +export function toGraphQLProcessingActivitiesFilter( + filter: ComplianceReportProcessingActivitiesFilter | undefined, +): ComplianceReportProcessingActivitiesFilter | undefined { + if (!filter) { + return undefined; + } + // Strip undefined / empty values so create/update payloads stay clean + return Object.fromEntries( + Object.entries(filter).filter(([, value]) => { + if (value === undefined || value === null) { + return false; + } + if (typeof value === 'string') { + return value.length > 0; + } + if (Array.isArray(value)) { + return value.length > 0; + } + return true; + }), + ) as ComplianceReportProcessingActivitiesFilter; +} + +/** + * Whether an existing report's filter matches the desired YAML filter. + * + * @param existing - Parsed filter on the report + * @param desired - Desired filter from YAML + * @returns True when filters are equivalent + */ +function filtersMatch( + existing: ComplianceReportProcessingActivitiesFilter, + desired: ComplianceReportProcessingActivitiesFilter | undefined, +): boolean { + const normalizedDesired = toGraphQLProcessingActivitiesFilter(desired) ?? {}; + const normalizedExisting = toGraphQLProcessingActivitiesFilter(existing) ?? {}; + return isEqual(normalizedExisting, normalizedDesired); +} + +/** + * Create a compliance report + * + * @param client - GraphQL client + * @param input - Input + * @param options - Options + * @returns Created report id/title + */ +async function createComplianceReport( + client: GraphQLClient, + input: ComplianceReportInput, + options: { + /** Logger instance */ + logger?: Logger; + /** Resolved DPO user ID */ + dataProtectionOfficerId?: string; + } = {}, +): Promise> { + const { logger = NOOP_LOGGER, dataProtectionOfficerId } = options; + const columns = resolveComplianceReportColumns(input.columns); + const processingActivitiesFilter = toGraphQLProcessingActivitiesFilter( + input['processing-activities-filter'], + ); + + const { createComplianceReport } = await makeGraphQLRequest<{ + /** Create mutation */ + createComplianceReport: { + /** Created report */ + complianceReport: Pick; + }; + }>(client, CREATE_COMPLIANCE_REPORT, { + variables: { + input: { + title: input.title, + description: input.description, + columns, + ...(processingActivitiesFilter ? { processingActivitiesFilter } : {}), + ...(dataProtectionOfficerId ? { dataProtectionOfficerId } : {}), + }, + }, + logger, + }); + return createComplianceReport.complianceReport; +} + +/** + * Update compliance report metadata fields that support UpdateComplianceReportInput. + * + * @param client - GraphQL client + * @param reportId - Report ID + * @param fieldName - Meta field name + * @param value - New value(s) + * @param options - Options + */ +async function updateComplianceReportField( + client: GraphQLClient, + reportId: string, + fieldName: 'title' | 'description' | 'dataProtectionOfficer', + value: string[], + options: { + /** Logger instance */ + logger?: Logger; + } = {}, +): Promise { + const { logger = NOOP_LOGGER } = options; + await makeGraphQLRequest(client, UPDATE_COMPLIANCE_REPORT, { + variables: { + input: { + id: reportId, + fieldName, + value, + }, + }, + logger, + }); +} + +/** + * Reprocess (recreate) a compliance report from its stored filter/columns. + * + * @param client - GraphQL client + * @param reportId - Report ID + * @param options - Options + */ +async function reprocessComplianceReport( + client: GraphQLClient, + reportId: string, + options: { + /** Logger instance */ + logger?: Logger; + } = {}, +): Promise { + const { logger = NOOP_LOGGER } = options; + await makeGraphQLRequest(client, REPROCESS_COMPLIANCE_REPORT, { + variables: { + input: { + id: reportId, + recreate: true, + }, + }, + logger, + }); +} + +/** + * Delete compliance reports by ID. + * + * @param client - GraphQL client + * @param ids - Report IDs + * @param options - Options + */ +async function deleteComplianceReports( + client: GraphQLClient, + ids: string[], + options: { + /** Logger instance */ + logger?: Logger; + } = {}, +): Promise { + const { logger = NOOP_LOGGER } = options; + if (ids.length === 0) { + return; + } + await makeGraphQLRequest(client, DELETE_COMPLIANCE_REPORTS, { + variables: { input: { ids } }, + logger, + }); +} + +/** + * Sync compliance reports from transcend.yml (idempotent by title). + * + * Create path: createComplianceReport with columns (Article 30 defaults if empty). + * Update path: update description/DPO via updateComplianceReport; reprocess to refresh rows. + * When filter or columns differ, delete + recreate (those fields are immutable after create). + * + * @param client - GraphQL client + * @param inputs - YAML inputs + * @param options - Options + * @returns True if run without error + */ +export async function syncComplianceReports( + client: GraphQLClient, + inputs: ComplianceReportInput[], + options: { + /** Logger instance */ + logger?: Logger; + } = {}, +): Promise { + const { logger = NOOP_LOGGER } = options; + let encounteredError = false; + + logger.info(`Syncing "${inputs.length}" compliance reports...`); + const [existingReports, users] = await Promise.all([ + fetchAllComplianceReports(client, { logger, includeColumns: true }), + inputs.some((input) => input['data-protection-officer-email']) + ? fetchAllUsers(client, { logger }) + : Promise.resolve([]), + ]); + + const reportByTitle = keyBy(existingReports, 'title') as Record; + const userByEmail = keyBy( + users.map((user) => ({ ...user, email: user.email.toLowerCase() })), + 'email', + ); + + await mapSeries(inputs, async (input) => { + const existing = reportByTitle[input.title]; + const dpoEmail = input['data-protection-officer-email']?.toLowerCase(); + let dataProtectionOfficerId: string | undefined; + if (dpoEmail) { + const user = userByEmail[dpoEmail]; + if (!user) { + encounteredError = true; + logger.error( + `Failed to sync compliance report "${input.title}": ` + + `data-protection-officer-email "${input['data-protection-officer-email']}" not found in organization`, + ); + return; + } + dataProtectionOfficerId = user.id; + } + + const desiredColumns = resolveComplianceReportColumns(input.columns); + const desiredFilter = toGraphQLProcessingActivitiesFilter( + input['processing-activities-filter'], + ); + + if (!existing) { + try { + const created = await createComplianceReport(client, input, { + logger, + dataProtectionOfficerId, + }); + reportByTitle[created.title] = { + ...created, + description: input.description, + processingActivitiesFilter: desiredFilter ?? {}, + processingActivitiesFilterRaw: JSON.stringify(desiredFilter ?? {}), + dataProtectionOfficer: dataProtectionOfficerId + ? { id: dataProtectionOfficerId, email: dpoEmail! } + : null, + columns: desiredColumns, + }; + logger.info(`Successfully created compliance report "${input.title}"!`); + } catch (err) { + encounteredError = true; + logger.error( + `Failed to create compliance report "${input.title}"! - ${(err as Error).message}`, + ); + } + return; + } + + try { + const columnsMatch = + desiredColumns.length === existing.columns.length && + desiredColumns.every((column, index) => column === existing.columns[index]); + const filterOk = filtersMatch(existing.processingActivitiesFilter, desiredFilter); + + if (!columnsMatch || !filterOk) { + logger.info( + `Compliance report "${input.title}" filter/columns changed; deleting and recreating...`, + ); + await deleteComplianceReports(client, [existing.id], { logger }); + const created = await createComplianceReport(client, input, { + logger, + dataProtectionOfficerId, + }); + reportByTitle[created.title] = { + ...created, + description: input.description, + processingActivitiesFilter: desiredFilter ?? {}, + processingActivitiesFilterRaw: JSON.stringify(desiredFilter ?? {}), + dataProtectionOfficer: dataProtectionOfficerId + ? { id: dataProtectionOfficerId, email: dpoEmail! } + : null, + columns: desiredColumns, + }; + logger.info(`Successfully recreated compliance report "${input.title}"!`); + return; + } + + // Update description when changed + if ((input.description ?? '') !== (existing.description ?? '')) { + await updateComplianceReportField( + client, + existing.id, + 'description', + [input.description ?? ''], + { logger }, + ); + } + + // Update DPO when changed + const existingDpoEmail = existing.dataProtectionOfficer?.email?.toLowerCase(); + if (dpoEmail !== existingDpoEmail) { + await updateComplianceReportField( + client, + existing.id, + 'dataProtectionOfficer', + dataProtectionOfficerId ? [dataProtectionOfficerId] : [], + { logger }, + ); + } + + // Refresh row data from live processing activities + await reprocessComplianceReport(client, existing.id, { logger }); + logger.info(`Successfully synced compliance report "${input.title}"!`); + } catch (err) { + encounteredError = true; + logger.error( + `Failed to sync compliance report "${input.title}"! - ${(err as Error).message}`, + ); + } + }); + + return !encounteredError; +} diff --git a/packages/sdk/src/data-inventory/tests/syncComplianceReports.test.ts b/packages/sdk/src/data-inventory/tests/syncComplianceReports.test.ts new file mode 100644 index 00000000..4fb0df1d --- /dev/null +++ b/packages/sdk/src/data-inventory/tests/syncComplianceReports.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { + ARTICLE_30_DEFAULT_COMPLIANCE_REPORT_COLUMNS, + resolveComplianceReportColumns, +} from '../complianceReportDefaults.js'; +import { parseProcessingActivitiesFilter } from '../fetchAllComplianceReports.js'; +import { toGraphQLProcessingActivitiesFilter } from '../syncComplianceReports.js'; + +describe('resolveComplianceReportColumns', () => { + it('returns Article 30 defaults when columns are empty or omitted', () => { + expect(resolveComplianceReportColumns(undefined)).toEqual([ + ...ARTICLE_30_DEFAULT_COMPLIANCE_REPORT_COLUMNS, + ]); + expect(resolveComplianceReportColumns([])).toEqual([ + ...ARTICLE_30_DEFAULT_COMPLIANCE_REPORT_COLUMNS, + ]); + }); + + it('preserves explicit columns', () => { + expect(resolveComplianceReportColumns(['title', 'owners'])).toEqual(['title', 'owners']); + }); +}); + +describe('parseProcessingActivitiesFilter', () => { + it('parses valid JSON', () => { + expect(parseProcessingActivitiesFilter('{"text":"CCTV"}')).toEqual({ text: 'CCTV' }); + }); + + it('returns empty object for invalid input', () => { + expect(parseProcessingActivitiesFilter(undefined)).toEqual({}); + expect(parseProcessingActivitiesFilter('not-json')).toEqual({}); + expect(parseProcessingActivitiesFilter('[]')).toEqual({}); + }); +}); + +describe('toGraphQLProcessingActivitiesFilter', () => { + it('strips empty values', () => { + expect( + toGraphQLProcessingActivitiesFilter({ + text: 'CCTV', + ids: [], + purposes: undefined, + }), + ).toEqual({ text: 'CCTV' }); + }); +});