From ba8bce2ad3c37b6f71d4e27fb088df97d3240066 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Tue, 28 Apr 2026 16:07:18 +0200 Subject: [PATCH] Add processing activity, DPIA and TIA publish to document system Replace the old PDF/snapshot-based exports for processing activities, Data Protection Impact Assessments and Transfer Impact Assessments with the publish document system. Includes GraphQL mutations, MCP tools, CLI commands, n8n operations, frontend publish dialogs, e2e tests, and prosemirror register templates that mirror the previous PDF layouts. Each register lives as a generated DocumentTypeRegister document on the organization, reused across publishes (the major version bumps on every republish). Approvers can be passed in to create a draft pending approval; otherwise the version is published immediately. The frontend ProcessingActivities page exposes a Publish dropdown per register and a Document link button per active tab, pre-fills the previous default approvers, and navigates to the published document on success. Remove snapshot mode entirely from these three entities: drop snapshotId and sourceId from GraphQL schemas, types, filters, resolvers, MCP spec, frontend routes and pages; remove SnapshotsTypeProcessingActivities from the snapshot registry and delete the ProcessingActivities.Snapshot, ProcessingActivitySnapshotter interface and *.InsertProcessingActivitySnapshots methods. The snapshot_id columns remain in the database but are now filtered out with snapshot_id IS NULL. Add Get/Upsert/Clear GeneratedDocumentID methods on each entity type (ProcessingActivity, DataProtectionImpactAssessment, TransferImpactAssessment) backed by new columns in the generated_documents table, matching the Finding/Obligation pattern. Signed-off-by: Sacha Al Himdani --- .../hooks/graph/ProcessingActivityGraph.ts | 36 +- .../ProcessingActivitiesPage.tsx | 360 +++---- .../ProcessingActivityDetailsPage.tsx | 233 ++--- ...taProtectionImpactAssessmentListDialog.tsx | 159 +++ .../PublishProcessingActivityListDialog.tsx | 159 +++ ...lishTransferImpactAssessmentListDialog.tsx | 159 +++ .../src/routes/processingActivityRoutes.ts | 40 - .../main.go | 758 ++++++++++++++ e2e/console/dpia_publish_test.go | 305 ++++++ .../processing_activity_publish_test.go | 348 +++++++ e2e/console/processing_activity_test.go | 677 ------------- e2e/console/tia_publish_test.go | 304 ++++++ .../nodes/Probo/actions/dpia/index.ts | 17 +- .../Probo/actions/dpia/publish.operation.ts | 101 ++ .../Probo/actions/processingActivity/index.ts | 17 +- .../processingActivity/publish.operation.ts | 101 ++ .../n8n-node/nodes/Probo/actions/tia/index.ts | 17 +- .../Probo/actions/tia/publish.operation.ts | 101 ++ pkg/cmd/dpia/dpia.go | 2 + pkg/cmd/dpia/publish/publish.go | 148 +++ .../processing_activity.go | 2 + .../processing-activity/publish/publish.go | 148 +++ pkg/cmd/tia/publish/publish.go | 148 +++ pkg/cmd/tia/tia.go | 2 + .../data_protection_impact_assessment.go | 183 ++-- ...ata_protection_impact_assessment_filter.go | 61 -- pkg/coredata/migrations/20260428T115900Z.sql | 18 + pkg/coredata/processing_activities.go | 292 ++++-- pkg/coredata/processing_activity_filter.go | 61 -- pkg/coredata/processing_activity_vendor.go | 63 -- pkg/coredata/snapshots_type.go | 1 - pkg/coredata/snapshottable.go | 2 - pkg/coredata/transfer_impact_assessment.go | 183 ++-- .../transfer_impact_assessment_filter.go | 61 -- pkg/coredata/vendor.go | 107 +- ...rotection_impact_assessments_template.html | 316 ------ pkg/docgen/generator.go | 172 ++-- .../processing_activities_template.html | 536 ---------- .../transfer_impact_assessments_template.html | 289 ------ pkg/probo/actions.go | 36 +- ...ta_protection_impact_assessment_service.go | 138 +-- pkg/probo/generated_document_service.go | 947 ++++++++++++++++++ pkg/probo/policies.go | 6 - pkg/probo/processing_activity_service.go | 167 +-- pkg/probo/service.go | 9 +- ...rotection_impact_assessment_list.json.tmpl | 80 ++ .../processing_activity_list.json.tmpl | 281 ++++++ .../transfer_impact_assessment_list.json.tmpl | 77 ++ .../transfer_impact_assessment_service.go | 138 +-- ..._protection_impact_assessment_resolvers.go | 57 +- .../data_protection_impact_assessment.graphql | 38 +- .../console/v1/graphql/organization.graphql | 10 +- .../v1/graphql/processing_activity.graphql | 21 +- .../api/console/v1/organization_resolvers.go | 120 ++- .../v1/processing_activity_resolvers.go | 35 +- .../data_protection_impact_assessment.go | 3 - .../console/v1/types/processing_activity.go | 5 - .../v1/types/transfer_impact_assessment.go | 3 - pkg/server/api/mcp/v1/schema.resolvers.go | 72 +- pkg/server/api/mcp/v1/specification.yaml | 132 ++- 60 files changed, 5461 insertions(+), 3601 deletions(-) create mode 100644 apps/console/src/pages/organizations/processingActivities/dialogs/PublishDataProtectionImpactAssessmentListDialog.tsx create mode 100644 apps/console/src/pages/organizations/processingActivities/dialogs/PublishProcessingActivityListDialog.tsx create mode 100644 apps/console/src/pages/organizations/processingActivities/dialogs/PublishTransferImpactAssessmentListDialog.tsx create mode 100644 cmd/migrate-processing-activity-snapshots-to-documents/main.go create mode 100644 e2e/console/dpia_publish_test.go create mode 100644 e2e/console/processing_activity_publish_test.go create mode 100644 e2e/console/tia_publish_test.go create mode 100644 packages/n8n-node/nodes/Probo/actions/dpia/publish.operation.ts create mode 100644 packages/n8n-node/nodes/Probo/actions/processingActivity/publish.operation.ts create mode 100644 packages/n8n-node/nodes/Probo/actions/tia/publish.operation.ts create mode 100644 pkg/cmd/dpia/publish/publish.go create mode 100644 pkg/cmd/processing-activity/publish/publish.go create mode 100644 pkg/cmd/tia/publish/publish.go delete mode 100644 pkg/coredata/data_protection_impact_assessment_filter.go create mode 100644 pkg/coredata/migrations/20260428T115900Z.sql delete mode 100644 pkg/coredata/processing_activity_filter.go delete mode 100644 pkg/coredata/transfer_impact_assessment_filter.go delete mode 100644 pkg/docgen/data_protection_impact_assessments_template.html delete mode 100644 pkg/docgen/processing_activities_template.html delete mode 100644 pkg/docgen/transfer_impact_assessments_template.html create mode 100644 pkg/probo/templates/data_protection_impact_assessment_list.json.tmpl create mode 100644 pkg/probo/templates/processing_activity_list.json.tmpl create mode 100644 pkg/probo/templates/transfer_impact_assessment_list.json.tmpl diff --git a/apps/console/src/hooks/graph/ProcessingActivityGraph.ts b/apps/console/src/hooks/graph/ProcessingActivityGraph.ts index 0bd31083f..dd499dbc9 100644 --- a/apps/console/src/hooks/graph/ProcessingActivityGraph.ts +++ b/apps/console/src/hooks/graph/ProcessingActivityGraph.ts @@ -29,27 +29,42 @@ export type ProcessingActivityDPIAResidualRisk = "LOW" | "MEDIUM" | "HIGH"; export const processingActivitiesQuery = graphql` query ProcessingActivityGraphListQuery( $organizationId: ID! - $snapshotId: ID ) { node(id: $organizationId) { ... on Organization { canCreateProcessingActivity: permission( action: "core:processing-activity:create" ) - canExportProcessingActivities: permission( - action: "core:processing-activity:export" + canPublishProcessingActivities: permission( + action: "core:processing-activity:publish" ) - canExportDataProtectionImpactAssessments: permission( - action: "core:data-protection-impact-assessment:export" + canPublishDataProtectionImpactAssessments: permission( + action: "core:data-protection-impact-assessment:publish" ) - canExportTransferImpactAssessments: permission( - action: "core:transfer-impact-assessment:export" + canPublishTransferImpactAssessments: permission( + action: "core:transfer-impact-assessment:publish" ) - ...ProcessingActivitiesPageFragment @arguments(snapshotId: $snapshotId) + processingActivitiesDocument { + id + defaultApprovers { + id + } + } + dataProtectionImpactAssessmentsDocument { + id + defaultApprovers { + id + } + } + transferImpactAssessmentsDocument { + id + defaultApprovers { + id + } + } + ...ProcessingActivitiesPageFragment ...ProcessingActivitiesPageDPIAFragment - @arguments(snapshotId: $snapshotId) ...ProcessingActivitiesPageTIAFragment - @arguments(snapshotId: $snapshotId) } } } @@ -60,7 +75,6 @@ export const processingActivityNodeQuery = graphql` node(id: $processingActivityId) { ... on ProcessingActivity { id - snapshotId name purpose dataSubjectCategory diff --git a/apps/console/src/pages/organizations/processingActivities/ProcessingActivitiesPage.tsx b/apps/console/src/pages/organizations/processingActivities/ProcessingActivitiesPage.tsx index 3433d73ad..5f5b3f47c 100644 --- a/apps/console/src/pages/organizations/processingActivities/ProcessingActivitiesPage.tsx +++ b/apps/console/src/pages/organizations/processingActivities/ProcessingActivitiesPage.tsx @@ -12,12 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -import { - downloadFile, - promisifyMutation, - sprintf, - toDateInput, -} from "@probo/helpers"; +import { promisifyMutation, sprintf } from "@probo/helpers"; import { usePageTitle } from "@probo/hooks"; import { useTranslate } from "@probo/i18n"; import { @@ -25,14 +20,12 @@ import { Badge, Button, Card, - Dropdown, DropdownItem, - IconArrowDown, - IconChevronDown, + IconPageTextLine, IconPlusLarge, IconTrashCan, + IconUpload, PageHeader, - Spinner, TabItem, Table, Tabs, @@ -52,7 +45,7 @@ import { usePaginationFragment, usePreloadedQuery, } from "react-relay"; -import { useParams } from "react-router"; +import { Link, useNavigate } from "react-router"; import type { ProcessingActivitiesPageDPIAFragment$data, @@ -68,8 +61,6 @@ import type { } from "#/__generated__/core/ProcessingActivitiesPageTIAFragment.graphql"; import type { ProcessingActivityGraphDeleteMutation } from "#/__generated__/core/ProcessingActivityGraphDeleteMutation.graphql"; import type { ProcessingActivityGraphListQuery } from "#/__generated__/core/ProcessingActivityGraphListQuery.graphql"; -import { SnapshotBanner } from "#/components/SnapshotBanner"; -import { useMutationWithToasts } from "#/hooks/useMutationWithToasts"; import { useOrganizationId } from "#/hooks/useOrganizationId"; import type { NodeOf } from "#/types"; @@ -84,6 +75,9 @@ import { } from "../../../hooks/graph/ProcessingActivityGraph"; import { CreateProcessingActivityDialog } from "./dialogs/CreateProcessingActivityDialog"; +import { PublishDataProtectionImpactAssessmentListDialog } from "./dialogs/PublishDataProtectionImpactAssessmentListDialog"; +import { PublishProcessingActivityListDialog } from "./dialogs/PublishProcessingActivityListDialog"; +import { PublishTransferImpactAssessmentListDialog } from "./dialogs/PublishTransferImpactAssessmentListDialog"; interface ProcessingActivitiesPageProps { queryRef: PreloadedQuery; @@ -95,22 +89,15 @@ const processingActivitiesPageFragment = graphql` @argumentDefinitions( first: { type: "Int", defaultValue: 10 } after: { type: "CursorKey" } - snapshotId: { type: "ID", defaultValue: null } ) { id - processingActivities( - first: $first - after: $after - filter: { snapshotId: $snapshotId } - ) + processingActivities(first: $first, after: $after) @connection( key: "ProcessingActivitiesPage_processingActivities" - filters: ["filter"] ) { edges { node { id - snapshotId name purpose dataSubjectCategory @@ -139,17 +126,11 @@ const dpiaListPageFragment = graphql` @argumentDefinitions( first: { type: "Int", defaultValue: 10 } after: { type: "CursorKey" } - snapshotId: { type: "ID", defaultValue: null } ) { id - dataProtectionImpactAssessments( - first: $first - after: $after - filter: { snapshotId: $snapshotId } - ) + dataProtectionImpactAssessments(first: $first, after: $after) @connection( key: "ProcessingActivitiesPage_dataProtectionImpactAssessments" - filters: ["filter"] ) { edges { node { @@ -177,17 +158,11 @@ const tiaListPageFragment = graphql` @argumentDefinitions( first: { type: "Int", defaultValue: 10 } after: { type: "CursorKey" } - snapshotId: { type: "ID", defaultValue: null } ) { id - transferImpactAssessments( - first: $first - after: $after - filter: { snapshotId: $snapshotId } - ) + transferImpactAssessments(first: $first, after: $after) @connection( key: "ProcessingActivitiesPage_transferImpactAssessments" - filters: ["filter"] ) { edges { node { @@ -209,43 +184,12 @@ const tiaListPageFragment = graphql` } `; -const exportProcessingActivitiesPDFMutation = graphql` - mutation ProcessingActivitiesPageExportPDFMutation( - $input: ExportProcessingActivitiesPDFInput! - ) { - exportProcessingActivitiesPDF(input: $input) { - data - } - } -`; - -const exportDataProtectionImpactAssessmentsPDFMutation = graphql` - mutation ProcessingActivitiesPageExportDPIAPDFMutation( - $input: ExportDataProtectionImpactAssessmentsPDFInput! - ) { - exportDataProtectionImpactAssessmentsPDF(input: $input) { - data - } - } -`; - -const exportTransferImpactAssessmentsPDFMutation = graphql` - mutation ProcessingActivitiesPageExportTIAPDFMutation( - $input: ExportTransferImpactAssessmentsPDFInput! - ) { - exportTransferImpactAssessmentsPDF(input: $input) { - data - } - } -`; - export default function ProcessingActivitiesPage({ queryRef, }: ProcessingActivitiesPageProps) { const { __ } = useTranslate(); const organizationId = useOrganizationId(); - const { snapshotId } = useParams<{ snapshotId?: string }>(); - const isSnapshotMode = Boolean(snapshotId); + const navigate = useNavigate(); const [activeTab, setActiveTab] = useState<"activities" | "dpia" | "tia">( "activities", ); @@ -254,6 +198,17 @@ export default function ProcessingActivitiesPage({ const organization = usePreloadedQuery(processingActivitiesQuery, queryRef); + const paDocument = organization.node.processingActivitiesDocument; + const dpiaDocument = organization.node.dataProtectionImpactAssessmentsDocument; + const tiaDocument = organization.node.transferImpactAssessmentsDocument; + const paDefaultApproverIds = (paDocument?.defaultApprovers ?? []).map(a => a.id); + const dpiaDefaultApproverIds = (dpiaDocument?.defaultApprovers ?? []).map(a => a.id); + const tiaDefaultApproverIds = (tiaDocument?.defaultApprovers ?? []).map(a => a.id); + + const goToDocument = (documentId: string) => { + void navigate(`/organizations/${organizationId}/documents/${documentId}`); + }; + const { data: activitiesData, loadNext: loadNextActivities, @@ -287,7 +242,6 @@ export default function ProcessingActivitiesPage({ const connectionId = ConnectionHandler.getConnectionID( organizationId, ProcessingActivitiesConnectionKey, - { filter: { snapshotId: snapshotId || null } }, ); const activities = activitiesData?.processingActivities?.edges?.map(edge => edge.node) @@ -300,178 +254,24 @@ export default function ProcessingActivitiesPage({ = tiaData?.transferImpactAssessments?.edges?.map(edge => edge.node) ?? []; - const hasAnyAction - = !isSnapshotMode - && activities.some(({ canUpdate, canDelete }) => canUpdate || canDelete); + const hasAnyAction = activities.some( + ({ canUpdate, canDelete }) => canUpdate || canDelete, + ); - const canExportPDF = organization.node.canExportProcessingActivities; - - const [exportPDF, isExportingPDF] = useMutationWithToasts<{ - response: { - exportProcessingActivitiesPDF?: { - data: string; - }; - }; - variables: { - input: { - organizationId: string; - filter: { snapshotId: string } | null; - }; - }; - }>(exportProcessingActivitiesPDFMutation, { - successMessage: __("PDF download started."), - errorMessage: __("Failed to generate PDF"), - }); - - const handleExportPDF = async () => { - await exportPDF({ - variables: { - input: { - organizationId: organizationId, - filter: snapshotId ? { snapshotId } : null, - }, - }, - onCompleted: (data) => { - if (data.exportProcessingActivitiesPDF?.data) { - downloadFile( - data.exportProcessingActivitiesPDF.data, - `processing-activities-${toDateInput(new Date().toISOString())}.pdf`, - ); - } - }, - }); - }; - - const canExportDPIAPDF - = organization.node.canExportDataProtectionImpactAssessments; - - const [exportDPIAPDF, isExportingDPIAPDF] = useMutationWithToasts<{ - response: { - exportDataProtectionImpactAssessmentsPDF?: { - data: string; - }; - }; - variables: { - input: { - organizationId: string; - filter: { snapshotId: string } | null; - }; - }; - }>(exportDataProtectionImpactAssessmentsPDFMutation, { - successMessage: __("PDF download started."), - errorMessage: __("Failed to generate PDF"), - }); - - const handleExportDPIAPDF = async () => { - await exportDPIAPDF({ - variables: { - input: { - organizationId: organizationId, - filter: snapshotId ? { snapshotId } : null, - }, - }, - onCompleted: (data) => { - if (data.exportDataProtectionImpactAssessmentsPDF?.data) { - downloadFile( - data.exportDataProtectionImpactAssessmentsPDF.data, - `data-protection-impact-assessments-${toDateInput(new Date().toISOString())}.pdf`, - ); - } - }, - }); - }; - - const canExportTIAPDF - = organization.node.canExportTransferImpactAssessments; - - const [exportTIAPDF, isExportingTIAPDF] = useMutationWithToasts<{ - response: { - exportTransferImpactAssessmentsPDF?: { - data: string; - }; - }; - variables: { - input: { - organizationId: string; - filter: { snapshotId: string } | null; - }; - }; - }>(exportTransferImpactAssessmentsPDFMutation, { - successMessage: __("PDF download started."), - errorMessage: __("Failed to generate PDF"), - }); - - const handleExportTIAPDF = async () => { - await exportTIAPDF({ - variables: { - input: { - organizationId: organizationId, - filter: snapshotId ? { snapshotId } : null, - }, - }, - onCompleted: (data) => { - if (data.exportTransferImpactAssessmentsPDF?.data) { - downloadFile( - data.exportTransferImpactAssessmentsPDF.data, - `transfer-impact-assessments-${toDateInput(new Date().toISOString())}.pdf`, - ); - } - }, - }); - }; + const canPublishProcessingActivities + = organization.node.canPublishProcessingActivities; + const canPublishDPIA + = organization.node.canPublishDataProtectionImpactAssessments; + const canPublishTIA + = organization.node.canPublishTransferImpactAssessments; return (
- {isSnapshotMode && snapshotId && ( - - )} - {(canExportPDF || canExportDPIAPDF || canExportTIAPDF) && ( - - {__("Export")} - - )} - > - {canExportPDF && ( - void handleExportPDF()} - disabled={isExportingPDF} - icon={isExportingPDF ? Spinner : undefined} - > - {__("Processing Activities")} - - )} - {canExportDPIAPDF && ( - void handleExportDPIAPDF()} - disabled={isExportingDPIAPDF} - icon={isExportingDPIAPDF ? Spinner : undefined} - > - {__("Data Protection Impact Assessments")} - - )} - {canExportTIAPDF && ( - void handleExportTIAPDF()} - disabled={isExportingTIAPDF} - icon={isExportingTIAPDF ? Spinner : undefined} - > - {__("Transfer Impact Assessments")} - - )} - - )} - {!isSnapshotMode - && activeTab === "activities" + {activeTab === "activities" && organization.node.canCreateProcessingActivity && ( +
+ {activeTab === "activities" && ( + <> + {paDocument?.id && ( + + )} + {canPublishProcessingActivities && ( + + + + )} + + )} + {activeTab === "dpia" && ( + <> + {dpiaDocument?.id && ( + + )} + {canPublishDPIA && ( + + + + )} + + )} + {activeTab === "tia" && ( + <> + {tiaDocument?.id && ( + + )} + {canPublishTIA && ( + + + + )} + + )} +
+ {activeTab === "activities" && ( <> {activities.length > 0 @@ -706,8 +584,6 @@ function ActivityRow({ }) { const organizationId = useOrganizationId(); const { __ } = useTranslate(); - const { snapshotId } = useParams<{ snapshotId?: string }>(); - const isSnapshotMode = Boolean(snapshotId); const [deleteActivity] = useMutation(deleteProcessingActivityMutation); const confirm = useConfirm(); @@ -734,9 +610,7 @@ function ActivityRow({ }; const activityUrl - = isSnapshotMode && snapshotId - ? `/organizations/${organizationId}/snapshots/${snapshotId}/processing-activities/${activity.id}` - : `/organizations/${organizationId}/processing-activities/${activity.id}`; + = `/organizations/${organizationId}/processing-activities/${activity.id}`; return ( @@ -790,13 +664,9 @@ function DPIARow({ }) { const organizationId = useOrganizationId(); const { __ } = useTranslate(); - const { snapshotId } = useParams<{ snapshotId?: string }>(); - const isSnapshotMode = Boolean(snapshotId); const activityUrl - = isSnapshotMode && snapshotId - ? `/organizations/${organizationId}/snapshots/${snapshotId}/processing-activities/${dpia.processingActivity.id}#dpia` - : `/organizations/${organizationId}/processing-activities/${dpia.processingActivity.id}#dpia`; + = `/organizations/${organizationId}/processing-activities/${dpia.processingActivity.id}#dpia`; return ( @@ -848,13 +718,9 @@ function TIARow({ >; }) { const organizationId = useOrganizationId(); - const { snapshotId } = useParams<{ snapshotId?: string }>(); - const isSnapshotMode = Boolean(snapshotId); const activityUrl - = isSnapshotMode && snapshotId - ? `/organizations/${organizationId}/snapshots/${snapshotId}/processing-activities/${tia.processingActivity.id}#tia` - : `/organizations/${organizationId}/processing-activities/${tia.processingActivity.id}#tia`; + = `/organizations/${organizationId}/processing-activities/${tia.processingActivity.id}#tia`; return ( diff --git a/apps/console/src/pages/organizations/processingActivities/ProcessingActivityDetailsPage.tsx b/apps/console/src/pages/organizations/processingActivities/ProcessingActivityDetailsPage.tsx index 931feddad..4ed0ef99b 100644 --- a/apps/console/src/pages/organizations/processingActivities/ProcessingActivityDetailsPage.tsx +++ b/apps/console/src/pages/organizations/processingActivities/ProcessingActivityDetailsPage.tsx @@ -16,7 +16,6 @@ import { formatError, type GraphQLError } from "@probo/helpers"; import { formatDatetime, toDateInput, - validateSnapshotConsistency, } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; import { @@ -43,13 +42,11 @@ import { type PreloadedQuery, usePreloadedQuery, } from "react-relay"; -import { useParams } from "react-router"; import { z } from "zod"; import type { ProcessingActivityGraphNodeQuery } from "#/__generated__/core/ProcessingActivityGraphNodeQuery.graphql"; import { PeopleSelectField } from "#/components/form/PeopleSelectField"; import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField"; -import { SnapshotBanner } from "#/components/SnapshotBanner"; import { useFormWithSchema } from "#/hooks/useFormWithSchema"; import { useOrganizationId } from "#/hooks/useOrganizationId"; @@ -121,8 +118,6 @@ export default function ProcessingActivityDetailsPage(props: Props) { const { __ } = useTranslate(); const { toast } = useToast(); const organizationId = useOrganizationId(); - const { snapshotId } = useParams<{ snapshotId?: string }>(); - const isSnapshotMode = Boolean(snapshotId); // Get initial tab from URL hash const getInitialTab = (): "overview" | "dpia" | "tia" => { @@ -156,8 +151,6 @@ export default function ProcessingActivityDetailsPage(props: Props) { window.location.hash = activeTab === "overview" ? "" : activeTab; }, [activeTab]); - validateSnapshotConsistency(activity, snapshotId); - const updateActivity = useUpdateProcessingActivity(); const createDPIA = useCreateDataProtectionImpactAssessment(); const updateDPIA = useUpdateDataProtectionImpactAssessment(); @@ -199,7 +192,6 @@ export default function ProcessingActivityDetailsPage(props: Props) { const connectionId = ConnectionHandler.getConnectionID( organizationId, ProcessingActivitiesConnectionKey, - { filter: { snapshotId: snapshotId || null } }, ); const deleteActivity = useDeleteProcessingActivity( @@ -425,15 +417,10 @@ export default function ProcessingActivityDetailsPage(props: Props) { }); const breadcrumbProcessingActivitiesUrl - = isSnapshotMode && snapshotId - ? `/organizations/${organizationId}/snapshots/${snapshotId}/processing-activities` - : `/organizations/${organizationId}/processing-activities`; + = `/organizations/${organizationId}/processing-activities`; return (
- {isSnapshotMode && snapshotId && ( - - )}
- {!isSnapshotMode && activity.canDelete && ( + {activity.canDelete && ( {__("Delete")} @@ -489,7 +476,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { {...register("name")} error={formState.errors.name?.message} required - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} />
@@ -508,7 +495,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { onValueChange={field.onChange} value={field.value} className="w-full" - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} > @@ -527,7 +514,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { {...register("purpose")} placeholder={__("Describe the purpose of processing")} rows={3} - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} />
@@ -535,14 +522,14 @@ export default function ProcessingActivityDetailsPage(props: Props) { label={__("Data Subject Category")} {...register("dataSubjectCategory")} placeholder={__("e.g., employees, customers, prospects")} - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} />
@@ -563,7 +550,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { onValueChange={field.onChange} value={field.value} className="w-full" - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} > @@ -580,7 +567,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { label={__("Consent Evidence Link")} {...register("consentEvidenceLink")} placeholder={__("Link to consent evidence if applicable")} - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} />
@@ -599,7 +586,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { onValueChange={field.onChange} value={field.value} className="w-full" - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} > @@ -620,7 +607,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { id="lastReviewDate" type="date" {...register("lastReviewDate")} - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} />
@@ -632,7 +619,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { id="nextReviewDate" type="date" {...register("nextReviewDate")} - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} />
@@ -641,7 +628,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { control={control} name="dataProtectionOfficerId" label={__("Data Protection Officer")} - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} />
@@ -650,14 +637,14 @@ export default function ProcessingActivityDetailsPage(props: Props) { label={__("Recipients")} {...register("recipients")} placeholder={__("Who receives the data")} - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} /> {__("Data is transferred internationally")} @@ -694,7 +681,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { onValueChange={field.onChange} value={field.value} className="w-full" - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} > @@ -711,7 +698,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { label={__("Retention Period")} {...register("retentionPeriod")} placeholder={__("How long is data retained")} - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} />
@@ -720,7 +707,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { {...register("securityMeasures")} placeholder={__("Technical and organizational measures")} rows={3} - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} />
@@ -740,7 +727,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { onValueChange={field.onChange} value={field.value} className="w-full" - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} > @@ -772,7 +759,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { onValueChange={field.onChange} value={field.value} className="w-full" - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} > @@ -796,24 +783,22 @@ export default function ProcessingActivityDetailsPage(props: Props) { name="vendorIds" selectedVendors={vendors} label={__("Vendors")} - disabled={isSnapshotMode || !activity.canUpdate} + disabled={!activity.canUpdate} /> - {!isSnapshotMode && ( -
- {activity.canUpdate && ( - - )} -
- )} +
+ {activity.canUpdate && ( + + )} +
@@ -829,7 +814,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {

{__("Data Protection Impact Assessment")}

- {!isSnapshotMode && activity.canCreateDPIA && ( + {activity.canCreateDPIA && (
@@ -882,7 +866,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { "Explain why the processing is necessary and proportionate", )} rows={4} - disabled={isSnapshotMode || !canCreateOrUpdateDPIA} + disabled={!canCreateOrUpdateDPIA} /> @@ -897,7 +881,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { "Describe the potential risks to data subjects", )} rows={4} - disabled={isSnapshotMode || !canCreateOrUpdateDPIA} + disabled={!canCreateOrUpdateDPIA} /> @@ -912,7 +896,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { "Describe measures to mitigate the identified risks", )} rows={4} - disabled={isSnapshotMode || !canCreateOrUpdateDPIA} + disabled={!canCreateOrUpdateDPIA} /> @@ -930,7 +914,7 @@ export default function ProcessingActivityDetailsPage(props: Props) { onValueChange={field.onChange} value={field.value} className="w-full" - disabled={isSnapshotMode || !canCreateOrUpdateDPIA} + disabled={!canCreateOrUpdateDPIA} > @@ -940,37 +924,35 @@ export default function ProcessingActivityDetailsPage(props: Props) { /> - {!isSnapshotMode && ( -
- {(!activity?.dataProtectionImpactAssessment?.id - || dpiaDeleted) && ( - - )} - {(activity?.dataProtectionImpactAssessment?.id - && !dpiaDeleted - ? activity.dataProtectionImpactAssessment.canUpdate - : activity.canCreateDPIA) && ( - - )} -
- )} +
+ {(!activity?.dataProtectionImpactAssessment?.id + || dpiaDeleted) && ( + + )} + {(activity?.dataProtectionImpactAssessment?.id + && !dpiaDeleted + ? activity.dataProtectionImpactAssessment.canUpdate + : activity.canCreateDPIA) && ( + + )} +
)} @@ -988,7 +970,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {

{__("Transfer Impact Assessment")}

- {!isSnapshotMode && activity.canCreateTIA && ( + {activity.canCreateTIA && ( - )} - {(activity?.transferImpactAssessment?.id && !tiaDeleted - ? activity.transferImpactAssessment.canUpdate - : activity.canCreateTIA) && ( - - )} - - )} +
+ {(!activity?.transferImpactAssessment?.id + || tiaDeleted) && ( + + )} + {(activity?.transferImpactAssessment?.id && !tiaDeleted + ? activity.transferImpactAssessment.canUpdate + : activity.canCreateTIA) && ( + + )} +
)} diff --git a/apps/console/src/pages/organizations/processingActivities/dialogs/PublishDataProtectionImpactAssessmentListDialog.tsx b/apps/console/src/pages/organizations/processingActivities/dialogs/PublishDataProtectionImpactAssessmentListDialog.tsx new file mode 100644 index 000000000..80b562f3a --- /dev/null +++ b/apps/console/src/pages/organizations/processingActivities/dialogs/PublishDataProtectionImpactAssessmentListDialog.tsx @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { formatError, type GraphQLError } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + IconSend, + IconUpload, + useDialogRef, + useToast, +} from "@probo/ui"; +import type { ReactNode } from "react"; +import { useMemo } from "react"; +import { useMutation } from "react-relay"; +import { graphql } from "relay-runtime"; +import { z } from "zod"; + +import type { PublishDataProtectionImpactAssessmentListDialogMutation } from "#/__generated__/core/PublishDataProtectionImpactAssessmentListDialogMutation.graphql"; +import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField"; +import { useFormWithSchema } from "#/hooks/useFormWithSchema"; + +const publishMutation = graphql` + mutation PublishDataProtectionImpactAssessmentListDialogMutation( + $input: PublishDataProtectionImpactAssessmentListInput! + ) { + publishDataProtectionImpactAssessmentList(input: $input) { + documentEdge { + node { + id + } + } + } + } +`; + +type Props = { + children: ReactNode; + organizationId: string; + defaultApproverIds?: string[]; + onPublished?: (documentId: string) => void; +}; + +export function PublishDataProtectionImpactAssessmentListDialog({ + children, + organizationId, + defaultApproverIds, + onPublished, +}: Props) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const dialogRef = useDialogRef(); + + const schema = useMemo(() => z.object({ + approverIds: z.array(z.string()), + }), []); + + const { + control, + handleSubmit, + reset, + watch, + } = useFormWithSchema(schema, { + defaultValues: { + approverIds: defaultApproverIds ?? [], + }, + }); + + const [publish, isPublishing] + = useMutation(publishMutation); + + const approverIds = watch("approverIds"); + const hasApprovers = approverIds.length > 0; + + const onSubmit = (data: z.infer) => { + publish({ + variables: { + input: { + organizationId, + approverIds: data.approverIds.length > 0 ? data.approverIds : undefined, + }, + }, + onCompleted(response) { + const documentId = response.publishDataProtectionImpactAssessmentList?.documentEdge?.node?.id; + if (documentId) { + toast({ + title: __("Success"), + description: hasApprovers + ? __("Approval requested successfully.") + : __("Data Protection Impact Assessments published successfully."), + variant: "success", + }); + dialogRef.current?.close(); + reset(); + onPublished?.(documentId); + } + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to publish Data Protection Impact Assessments"), + error as GraphQLError, + ), + variant: "error", + }); + }, + }); + }; + + return ( + +
void handleSubmit(onSubmit)(e)}> + +
+

+ {__("Select approvers to request approval before publishing, or publish directly without approvers.")} +

+ +
+
+ + + +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/processingActivities/dialogs/PublishProcessingActivityListDialog.tsx b/apps/console/src/pages/organizations/processingActivities/dialogs/PublishProcessingActivityListDialog.tsx new file mode 100644 index 000000000..e43c37531 --- /dev/null +++ b/apps/console/src/pages/organizations/processingActivities/dialogs/PublishProcessingActivityListDialog.tsx @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { formatError, type GraphQLError } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + IconSend, + IconUpload, + useDialogRef, + useToast, +} from "@probo/ui"; +import type { ReactNode } from "react"; +import { useMemo } from "react"; +import { useMutation } from "react-relay"; +import { graphql } from "relay-runtime"; +import { z } from "zod"; + +import type { PublishProcessingActivityListDialogMutation } from "#/__generated__/core/PublishProcessingActivityListDialogMutation.graphql"; +import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField"; +import { useFormWithSchema } from "#/hooks/useFormWithSchema"; + +const publishMutation = graphql` + mutation PublishProcessingActivityListDialogMutation( + $input: PublishProcessingActivityListInput! + ) { + publishProcessingActivityList(input: $input) { + documentEdge { + node { + id + } + } + } + } +`; + +type Props = { + children: ReactNode; + organizationId: string; + defaultApproverIds?: string[]; + onPublished?: (documentId: string) => void; +}; + +export function PublishProcessingActivityListDialog({ + children, + organizationId, + defaultApproverIds, + onPublished, +}: Props) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const dialogRef = useDialogRef(); + + const schema = useMemo(() => z.object({ + approverIds: z.array(z.string()), + }), []); + + const { + control, + handleSubmit, + reset, + watch, + } = useFormWithSchema(schema, { + defaultValues: { + approverIds: defaultApproverIds ?? [], + }, + }); + + const [publish, isPublishing] + = useMutation(publishMutation); + + const approverIds = watch("approverIds"); + const hasApprovers = approverIds.length > 0; + + const onSubmit = (data: z.infer) => { + publish({ + variables: { + input: { + organizationId, + approverIds: data.approverIds.length > 0 ? data.approverIds : undefined, + }, + }, + onCompleted(response) { + const documentId = response.publishProcessingActivityList?.documentEdge?.node?.id; + if (documentId) { + toast({ + title: __("Success"), + description: hasApprovers + ? __("Approval requested successfully.") + : __("Processing activities published successfully."), + variant: "success", + }); + dialogRef.current?.close(); + reset(); + onPublished?.(documentId); + } + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to publish processing activities"), + error as GraphQLError, + ), + variant: "error", + }); + }, + }); + }; + + return ( + +
void handleSubmit(onSubmit)(e)}> + +
+

+ {__("Select approvers to request approval before publishing, or publish directly without approvers.")} +

+ +
+
+ + + +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/processingActivities/dialogs/PublishTransferImpactAssessmentListDialog.tsx b/apps/console/src/pages/organizations/processingActivities/dialogs/PublishTransferImpactAssessmentListDialog.tsx new file mode 100644 index 000000000..5d8e49664 --- /dev/null +++ b/apps/console/src/pages/organizations/processingActivities/dialogs/PublishTransferImpactAssessmentListDialog.tsx @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { formatError, type GraphQLError } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + IconSend, + IconUpload, + useDialogRef, + useToast, +} from "@probo/ui"; +import type { ReactNode } from "react"; +import { useMemo } from "react"; +import { useMutation } from "react-relay"; +import { graphql } from "relay-runtime"; +import { z } from "zod"; + +import type { PublishTransferImpactAssessmentListDialogMutation } from "#/__generated__/core/PublishTransferImpactAssessmentListDialogMutation.graphql"; +import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField"; +import { useFormWithSchema } from "#/hooks/useFormWithSchema"; + +const publishMutation = graphql` + mutation PublishTransferImpactAssessmentListDialogMutation( + $input: PublishTransferImpactAssessmentListInput! + ) { + publishTransferImpactAssessmentList(input: $input) { + documentEdge { + node { + id + } + } + } + } +`; + +type Props = { + children: ReactNode; + organizationId: string; + defaultApproverIds?: string[]; + onPublished?: (documentId: string) => void; +}; + +export function PublishTransferImpactAssessmentListDialog({ + children, + organizationId, + defaultApproverIds, + onPublished, +}: Props) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const dialogRef = useDialogRef(); + + const schema = useMemo(() => z.object({ + approverIds: z.array(z.string()), + }), []); + + const { + control, + handleSubmit, + reset, + watch, + } = useFormWithSchema(schema, { + defaultValues: { + approverIds: defaultApproverIds ?? [], + }, + }); + + const [publish, isPublishing] + = useMutation(publishMutation); + + const approverIds = watch("approverIds"); + const hasApprovers = approverIds.length > 0; + + const onSubmit = (data: z.infer) => { + publish({ + variables: { + input: { + organizationId, + approverIds: data.approverIds.length > 0 ? data.approverIds : undefined, + }, + }, + onCompleted(response) { + const documentId = response.publishTransferImpactAssessmentList?.documentEdge?.node?.id; + if (documentId) { + toast({ + title: __("Success"), + description: hasApprovers + ? __("Approval requested successfully.") + : __("Transfer Impact Assessments published successfully."), + variant: "success", + }); + dialogRef.current?.close(); + reset(); + onPublished?.(documentId); + } + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to publish Transfer Impact Assessments"), + error as GraphQLError, + ), + variant: "error", + }); + }, + }); + }; + + return ( + +
void handleSubmit(onSubmit)(e)}> + +
+

+ {__("Select approvers to request approval before publishing, or publish directly without approvers.")} +

+ +
+
+ + + +
+
+ ); +} diff --git a/apps/console/src/routes/processingActivityRoutes.ts b/apps/console/src/routes/processingActivityRoutes.ts index c4bdb627a..386d164a1 100644 --- a/apps/console/src/routes/processingActivityRoutes.ts +++ b/apps/console/src/routes/processingActivityRoutes.ts @@ -39,27 +39,6 @@ export const processingActivityRoutes = [ processingActivitiesQuery, { organizationId: organizationId, - snapshotId: null, - }, - ), - ), - Component: withQueryRef( - lazy( - () => - import("#/pages/organizations/processingActivities/ProcessingActivitiesPage"), - ), - ), - }, - { - path: "snapshots/:snapshotId/processing-activities", - Fallback: PageSkeleton, - loader: loaderFromQueryLoader(({ organizationId, snapshotId }) => - loadQuery( - coreEnvironment, - processingActivitiesQuery, - { - organizationId: organizationId, - snapshotId, }, ), ), @@ -89,23 +68,4 @@ export const processingActivityRoutes = [ ), ), }, - { - path: "snapshots/:snapshotId/processing-activities/:activityId", - Fallback: PageSkeleton, - loader: loaderFromQueryLoader(({ activityId }) => - loadQuery( - coreEnvironment, - processingActivityNodeQuery, - { - processingActivityId: activityId, - }, - ), - ), - Component: withQueryRef( - lazy( - () => - import("#/pages/organizations/processingActivities/ProcessingActivityDetailsPage"), - ), - ), - }, ] satisfies AppRoute[]; diff --git a/cmd/migrate-processing-activity-snapshots-to-documents/main.go b/cmd/migrate-processing-activity-snapshots-to-documents/main.go new file mode 100644 index 000000000..40c58f518 --- /dev/null +++ b/cmd/migrate-processing-activity-snapshots-to-documents/main.go @@ -0,0 +1,758 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +// Command migrate-processing-activity-snapshots-to-documents creates documents +// and document versions from existing processing activity snapshots. For each +// organization that has PROCESSING_ACTIVITIES snapshots, it produces three +// register documents — Processing Activities, Data Protection Impact +// Assessments, and Transfer Impact Assessments — using the same ProseMirror +// builders as the publish flow, with one version per snapshot ordered by date. +package main + +import ( + "context" + "flag" + "fmt" + "net/url" + "os" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/docgen" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/probo" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + var ( + pgDSN string + dryRun bool + ) + + flag.StringVar( + &pgDSN, + "pg-dsn", + os.Getenv("DATABASE_URL"), + "PostgreSQL connection URL (default: DATABASE_URL env)", + ) + flag.BoolVar(&dryRun, "dry-run", false, "show what would be done without writing") + flag.Parse() + + if pgDSN == "" { + return fmt.Errorf("set -pg-dsn or DATABASE_URL") + } + + ctx := context.Background() + + pgClient, err := newPgClientFromDSN(pgDSN) + if err != nil { + return fmt.Errorf("cannot create pg client: %w", err) + } + + return pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return migrate(ctx, tx, dryRun) + }) +} + +type orgWithSnapshots struct { + organizationID gid.GID + tenantID gid.TenantID + organizationName string +} + +type processingActivitySnapshot struct { + snapshotID string + publishedAt time.Time +} + +type kind struct { + name string + title string + column string + buildFn func(ctx context.Context, tx pg.Tx, snapshotID string, orgName string, publishedAt time.Time) (string, int, error) +} + +func migrate(ctx context.Context, tx pg.Tx, dryRun bool) error { + orgs, err := loadOrgsWithSnapshots(ctx, tx) + if err != nil { + return err + } + + if len(orgs) == 0 { + fmt.Println("no organizations with processing activity snapshots to migrate") + return nil + } + + kinds := []kind{ + {name: "processing-activity", title: "Processing Activities", column: "processing_activities_document_id", buildFn: buildProcessingActivityContent}, + {name: "dpia", title: "Data Protection Impact Assessments", column: "data_protection_impact_assessments_document_id", buildFn: buildDPIAContent}, + {name: "tia", title: "Transfer Impact Assessments", column: "transfer_impact_assessments_document_id", buildFn: buildTIAContent}, + } + + var stats struct { + documents, versions int + } + + for _, org := range orgs { + snapshots, err := loadSnapshots(ctx, tx, org.organizationID) + if err != nil { + return err + } + + if dryRun { + fmt.Printf("would migrate org %s (%s) — %d snapshot(s) × %d kind(s)\n", + org.organizationID, org.organizationName, len(snapshots), len(kinds)) + continue + } + + for _, k := range kinds { + documentID := gid.New(org.tenantID, coredata.DocumentEntityType) + now := time.Now() + + versionsInserted := 0 + for major, snap := range snapshots { + content, count, err := k.buildFn(ctx, tx, snap.snapshotID, org.organizationName, snap.publishedAt) + if err != nil { + return fmt.Errorf("cannot build %s content for snapshot %s of org %s: %w", + k.name, snap.snapshotID, org.organizationID, err) + } + if count == 0 { + continue + } + + versionID := gid.New(org.tenantID, coredata.DocumentVersionEntityType) + + _, err = tx.Exec( + ctx, + ` +INSERT INTO document_versions ( + id, tenant_id, organization_id, document_id, + title, major, minor, classification, document_type, + content, changelog, status, orientation, + published_at, created_at, updated_at +) VALUES ( + @id, @tenant_id, @organization_id, @document_id, + @title, @major, 0, + 'CONFIDENTIAL'::document_classification, + 'REGISTER'::document_type, + @content, '', + 'PUBLISHED'::document_version_status, + 'PORTRAIT'::document_version_orientation, + @published_at, @published_at, @published_at +)`, + pgx.NamedArgs{ + "id": versionID, + "tenant_id": org.tenantID, + "organization_id": org.organizationID, + "document_id": documentID, + "title": k.title, + "major": major + 1, + "content": content, + "published_at": snap.publishedAt, + }, + ) + if err != nil { + return fmt.Errorf("cannot insert %s version for snapshot %s: %w", k.name, snap.snapshotID, err) + } + versionsInserted++ + } + + if versionsInserted == 0 { + continue + } + + _, err := tx.Exec( + ctx, + ` +INSERT INTO documents ( + id, tenant_id, organization_id, write_mode, + current_published_major, current_published_minor, + trust_center_visibility, status, created_at, updated_at +) VALUES ( + @id, @tenant_id, @organization_id, + 'GENERATED'::document_write_mode, + @current_published_major, 0, + 'NONE'::trust_center_visibility, + 'ACTIVE'::document_status, + @created_at, @updated_at +)`, + pgx.NamedArgs{ + "id": documentID, + "tenant_id": org.tenantID, + "organization_id": org.organizationID, + "current_published_major": versionsInserted, + "created_at": now, + "updated_at": now, + }, + ) + if err != nil { + return fmt.Errorf("cannot insert %s document for org %s: %w", k.name, org.organizationID, err) + } + stats.documents++ + stats.versions += versionsInserted + + _, err = tx.Exec( + ctx, + fmt.Sprintf(` +INSERT INTO generated_documents (organization_id, tenant_id, %s, created_at, updated_at) +VALUES (@organization_id, @tenant_id, @document_id, @created_at, @updated_at) +ON CONFLICT (organization_id) DO UPDATE SET %s = @document_id, updated_at = @updated_at`, k.column, k.column), + pgx.NamedArgs{ + "organization_id": org.organizationID, + "tenant_id": org.tenantID, + "document_id": documentID, + "created_at": now, + "updated_at": now, + }, + ) + if err != nil { + return fmt.Errorf("cannot link %s document to org %s: %w", k.name, org.organizationID, err) + } + } + + fmt.Printf("migrated org %s (%s) — %d snapshot(s)\n", + org.organizationID, org.organizationName, len(snapshots)) + } + + if dryRun { + fmt.Printf("\n%d organization(s) would be migrated\n", len(orgs)) + return nil + } + + fmt.Printf("\ncreated %d document(s), %d version(s)\n", stats.documents, stats.versions) + return nil +} + +func loadOrgsWithSnapshots(ctx context.Context, tx pg.Tx) ([]orgWithSnapshots, error) { + rows, err := tx.Query( + ctx, + ` +SELECT DISTINCT + o.id, + o.tenant_id, + o.name, + o.created_at +FROM organizations o +WHERE NOT EXISTS ( + SELECT 1 FROM generated_documents gd + WHERE gd.organization_id = o.id + AND gd.processing_activities_document_id IS NOT NULL + ) + AND EXISTS ( + SELECT 1 FROM snapshots s + WHERE s.organization_id = o.id AND s.type = 'PROCESSING_ACTIVITIES' + ) +ORDER BY o.created_at; +`, + ) + if err != nil { + return nil, fmt.Errorf("cannot query organizations with snapshots: %w", err) + } + defer rows.Close() + + var result []orgWithSnapshots + for rows.Next() { + var o orgWithSnapshots + var createdAt time.Time + if err := rows.Scan(&o.organizationID, &o.tenantID, &o.organizationName, &createdAt); err != nil { + return nil, fmt.Errorf("cannot scan organization: %w", err) + } + result = append(result, o) + } + return result, rows.Err() +} + +func loadSnapshots(ctx context.Context, tx pg.Tx, organizationID gid.GID) ([]processingActivitySnapshot, error) { + rows, err := tx.Query( + ctx, + ` +SELECT + s.id, + s.created_at +FROM snapshots s +WHERE s.organization_id = @organization_id + AND s.type = 'PROCESSING_ACTIVITIES' +ORDER BY s.created_at ASC; +`, + pgx.NamedArgs{"organization_id": organizationID}, + ) + if err != nil { + return nil, fmt.Errorf("cannot query snapshots for org %s: %w", organizationID, err) + } + defer rows.Close() + + var result []processingActivitySnapshot + for rows.Next() { + var s processingActivitySnapshot + if err := rows.Scan(&s.snapshotID, &s.publishedAt); err != nil { + return nil, fmt.Errorf("cannot scan snapshot: %w", err) + } + result = append(result, s) + } + return result, rows.Err() +} + +func buildProcessingActivityContent( + ctx context.Context, + tx pg.Tx, + snapshotID string, + orgName string, + publishedAt time.Time, +) (string, int, error) { + rows, err := tx.Query( + ctx, + ` +SELECT + pa.id, + pa.name, + pa.purpose, + pa.data_subject_category, + pa.personal_data_category, + pa.special_or_criminal_data, + pa.consent_evidence_link, + pa.lawful_basis, + pa.recipients, + pa.location, + pa.international_transfers, + pa.transfer_safeguards, + pa.retention_period, + pa.security_measures, + pa.data_protection_impact_assessment_needed, + pa.transfer_impact_assessment_needed, + pa.last_review_date, + pa.next_review_date, + pa.role, + COALESCE(p.full_name, '') +FROM processing_activities pa +LEFT JOIN iam_membership_profiles p ON p.id = pa.dpo_profile_id +WHERE pa.snapshot_id = @snapshot_id +ORDER BY pa.name ASC; +`, + pgx.NamedArgs{"snapshot_id": snapshotID}, + ) + if err != nil { + return "", 0, fmt.Errorf("cannot load snapshot processing activities: %w", err) + } + defer rows.Close() + + type paRow struct { + id gid.GID + name string + purpose *string + dataSubjectCategory *string + personalDataCategory *string + specialOrCriminalData string + consentEvidenceLink *string + lawfulBasis string + recipients *string + location *string + internationalTransfers bool + transferSafeguards *string + retentionPeriod *string + securityMeasures *string + dataProtectionImpactAssessmentNeeded string + transferImpactAssessmentNeeded string + lastReviewDate *time.Time + nextReviewDate *time.Time + role string + dpoName string + } + + var pas []paRow + for rows.Next() { + var p paRow + if err := rows.Scan( + &p.id, &p.name, &p.purpose, &p.dataSubjectCategory, &p.personalDataCategory, + &p.specialOrCriminalData, &p.consentEvidenceLink, &p.lawfulBasis, + &p.recipients, &p.location, &p.internationalTransfers, &p.transferSafeguards, + &p.retentionPeriod, &p.securityMeasures, + &p.dataProtectionImpactAssessmentNeeded, &p.transferImpactAssessmentNeeded, + &p.lastReviewDate, &p.nextReviewDate, &p.role, &p.dpoName, + ); err != nil { + return "", 0, fmt.Errorf("cannot scan PA: %w", err) + } + pas = append(pas, p) + } + if err := rows.Err(); err != nil { + return "", 0, err + } + + if len(pas) == 0 { + return "", 0, nil + } + + vendorMap, err := loadVendorsForSnapshot(ctx, tx, snapshotID) + if err != nil { + return "", 0, err + } + + listRows := make([]docgen.ProcessingActivityListRow, len(pas)) + for i, p := range pas { + dpo := "Not assigned" + if p.dpoName != "" { + dpo = p.dpoName + } + + vendors := "None" + if v, ok := vendorMap[p.id]; ok && len(v) > 0 { + vendors = strings.Join(v, ", ") + } + + listRows[i] = docgen.ProcessingActivityListRow{ + Name: p.name, + Purpose: derefOrNotSpecified(p.purpose), + Role: formatRoleString(p.role), + DataSubjectCategory: derefOrNotSpecified(p.dataSubjectCategory), + PersonalDataCategory: derefOrNotSpecified(p.personalDataCategory), + SpecialOrCriminalData: formatSpecialOrCriminalDataString(p.specialOrCriminalData), + LawfulBasis: formatLawfulBasisString(p.lawfulBasis), + ConsentEvidenceLink: derefOrNotSpecified(p.consentEvidenceLink), + Recipients: derefOrNotSpecified(p.recipients), + Location: derefOrNotSpecified(p.location), + InternationalTransfers: yesNoLabel(p.internationalTransfers), + TransferSafeguards: formatTransferSafeguardString(p.transferSafeguards), + RetentionPeriod: derefOrNotSpecified(p.retentionPeriod), + SecurityMeasures: derefOrNotSpecified(p.securityMeasures), + DataProtectionImpactAssessmentNeeded: formatYesNoString(p.dataProtectionImpactAssessmentNeeded), + TransferImpactAssessmentNeeded: formatYesNoString(p.transferImpactAssessmentNeeded), + LastReviewDate: formatDateOrNotSpecified(p.lastReviewDate), + NextReviewDate: formatDateOrNotSpecified(p.nextReviewDate), + DataProtectionOfficer: dpo, + Vendors: vendors, + } + } + + content, err := probo.BuildProcessingActivityListDocument(docgen.ProcessingActivityListData{ + Title: "Processing Activities", + OrganizationName: orgName, + CreatedAt: publishedAt, + TotalProcessingActivities: len(listRows), + Rows: listRows, + }) + if err != nil { + return "", 0, err + } + return content, len(listRows), nil +} + +func loadVendorsForSnapshot(ctx context.Context, tx pg.Tx, snapshotID string) (map[gid.GID][]string, error) { + rows, err := tx.Query( + ctx, + ` +SELECT pav.processing_activity_id, v.name +FROM processing_activity_vendors pav +INNER JOIN vendors v ON v.id = pav.vendor_id +WHERE pav.snapshot_id = @snapshot_id +ORDER BY pav.processing_activity_id, v.name; +`, + pgx.NamedArgs{"snapshot_id": snapshotID}, + ) + if err != nil { + return nil, fmt.Errorf("cannot load snapshot vendors: %w", err) + } + defer rows.Close() + + result := make(map[gid.GID][]string) + for rows.Next() { + var paID gid.GID + var name string + if err := rows.Scan(&paID, &name); err != nil { + return nil, fmt.Errorf("cannot scan vendor row: %w", err) + } + result[paID] = append(result[paID], name) + } + return result, rows.Err() +} + +func buildDPIAContent( + ctx context.Context, + tx pg.Tx, + snapshotID string, + orgName string, + publishedAt time.Time, +) (string, int, error) { + rows, err := tx.Query( + ctx, + ` +SELECT + pa.name, + dpia.description, + dpia.necessity_and_proportionality, + dpia.potential_risk, + dpia.mitigations, + dpia.residual_risk +FROM processing_activity_data_protection_impact_assessments dpia +INNER JOIN processing_activities pa ON pa.id = dpia.processing_activity_id +WHERE dpia.snapshot_id = @snapshot_id +ORDER BY pa.name ASC; +`, + pgx.NamedArgs{"snapshot_id": snapshotID}, + ) + if err != nil { + return "", 0, fmt.Errorf("cannot load snapshot DPIAs: %w", err) + } + defer rows.Close() + + var listRows []docgen.DataProtectionImpactAssessmentListRow + for rows.Next() { + var name string + var description, necessity, potentialRisk, mitigations *string + var residualRisk *string + if err := rows.Scan(&name, &description, &necessity, &potentialRisk, &mitigations, &residualRisk); err != nil { + return "", 0, fmt.Errorf("cannot scan DPIA: %w", err) + } + listRows = append(listRows, docgen.DataProtectionImpactAssessmentListRow{ + ProcessingActivityName: name, + Description: derefOrNotSpecified(description), + NecessityAndProportionality: derefOrNotSpecified(necessity), + PotentialRisk: derefOrNotSpecified(potentialRisk), + Mitigations: derefOrNotSpecified(mitigations), + ResidualRisk: formatResidualRiskString(residualRisk), + }) + } + if err := rows.Err(); err != nil { + return "", 0, err + } + + if len(listRows) == 0 { + return "", 0, nil + } + + content, err := probo.BuildDataProtectionImpactAssessmentListDocument(docgen.DataProtectionImpactAssessmentListData{ + Title: "Data Protection Impact Assessments", + OrganizationName: orgName, + CreatedAt: publishedAt, + TotalDataProtectionImpactAssessments: len(listRows), + Rows: listRows, + }) + if err != nil { + return "", 0, err + } + return content, len(listRows), nil +} + +func buildTIAContent( + ctx context.Context, + tx pg.Tx, + snapshotID string, + orgName string, + publishedAt time.Time, +) (string, int, error) { + rows, err := tx.Query( + ctx, + ` +SELECT + pa.name, + tia.data_subjects, + tia.legal_mechanism, + tia.transfer, + tia.local_law_risk, + tia.supplementary_measures +FROM processing_activity_transfer_impact_assessments tia +INNER JOIN processing_activities pa ON pa.id = tia.processing_activity_id +WHERE tia.snapshot_id = @snapshot_id +ORDER BY pa.name ASC; +`, + pgx.NamedArgs{"snapshot_id": snapshotID}, + ) + if err != nil { + return "", 0, fmt.Errorf("cannot load snapshot TIAs: %w", err) + } + defer rows.Close() + + var listRows []docgen.TransferImpactAssessmentListRow + for rows.Next() { + var name string + var dataSubjects, legalMechanism, transfer, localLawRisk, supplementary *string + if err := rows.Scan(&name, &dataSubjects, &legalMechanism, &transfer, &localLawRisk, &supplementary); err != nil { + return "", 0, fmt.Errorf("cannot scan TIA: %w", err) + } + listRows = append(listRows, docgen.TransferImpactAssessmentListRow{ + ProcessingActivityName: name, + DataSubjects: derefOrNotSpecified(dataSubjects), + LegalMechanism: derefOrNotSpecified(legalMechanism), + Transfer: derefOrNotSpecified(transfer), + LocalLawRisk: derefOrNotSpecified(localLawRisk), + SupplementaryMeasures: derefOrNotSpecified(supplementary), + }) + } + if err := rows.Err(); err != nil { + return "", 0, err + } + + if len(listRows) == 0 { + return "", 0, nil + } + + content, err := probo.BuildTransferImpactAssessmentListDocument(docgen.TransferImpactAssessmentListData{ + Title: "Transfer Impact Assessments", + OrganizationName: orgName, + CreatedAt: publishedAt, + TotalTransferImpactAssessments: len(listRows), + Rows: listRows, + }) + if err != nil { + return "", 0, err + } + return content, len(listRows), nil +} + +func derefOrNotSpecified(s *string) string { + if s == nil || *s == "" { + return "Not specified" + } + return *s +} + +func formatDateOrNotSpecified(t *time.Time) string { + if t == nil { + return "Not specified" + } + return t.Format("January 2, 2006") +} + +func yesNoLabel(b bool) string { + if b { + return "Yes" + } + return "No" +} + +func formatYesNoString(s string) string { + switch s { + case "NEEDED": + return "Yes" + case "NOT_NEEDED": + return "No" + default: + return s + } +} + +func formatRoleString(role string) string { + switch role { + case "CONTROLLER": + return "Controller" + case "PROCESSOR": + return "Processor" + default: + return role + } +} + +func formatLawfulBasisString(b string) string { + switch b { + case "CONSENT": + return "Consent" + case "CONTRACTUAL_NECESSITY": + return "Contractual Necessity" + case "LEGAL_OBLIGATION": + return "Legal Obligation" + case "LEGITIMATE_INTEREST": + return "Legitimate Interest" + case "PUBLIC_TASK": + return "Public Task" + case "VITAL_INTERESTS": + return "Vital Interests" + default: + return b + } +} + +func formatSpecialOrCriminalDataString(s string) string { + switch s { + case "YES": + return "Yes" + case "NO": + return "No" + case "POSSIBLE": + return "Possible" + default: + return s + } +} + +func formatTransferSafeguardString(s *string) string { + if s == nil { + return "Not specified" + } + switch *s { + case "STANDARD_CONTRACTUAL_CLAUSES": + return "Standard Contractual Clauses" + case "BINDING_CORPORATE_RULES": + return "Binding Corporate Rules" + case "ADEQUACY_DECISION": + return "Adequacy Decision" + case "DEROGATIONS": + return "Derogations" + case "CODES_OF_CONDUCT": + return "Codes of Conduct" + case "CERTIFICATION_MECHANISMS": + return "Certification Mechanisms" + default: + return *s + } +} + +func formatResidualRiskString(s *string) string { + if s == nil { + return "Not specified" + } + switch *s { + case "LOW": + return "Low" + case "MEDIUM": + return "Medium" + case "HIGH": + return "High" + default: + return *s + } +} + +func newPgClientFromDSN(dsn string) (*pg.Client, error) { + u, err := url.Parse(dsn) + if err != nil { + return nil, fmt.Errorf("cannot parse DSN: %w", err) + } + + var opts []pg.Option + + if u.Host != "" { + opts = append(opts, pg.WithAddr(u.Host)) + } + + if u.User != nil { + opts = append(opts, pg.WithUser(u.User.Username())) + if password, ok := u.User.Password(); ok { + opts = append(opts, pg.WithPassword(password)) + } + } + + if len(u.Path) > 1 { + opts = append(opts, pg.WithDatabase(u.Path[1:])) + } + + return pg.NewClient(opts...) +} diff --git a/e2e/console/dpia_publish_test.go b/e2e/console/dpia_publish_test.go new file mode 100644 index 000000000..c7bc2ef3c --- /dev/null +++ b/e2e/console/dpia_publish_test.go @@ -0,0 +1,305 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package console_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestDataProtectionImpactAssessment_PublishList(t *testing.T) { + t.Parallel() + + t.Run( + "publish without approvers publishes immediately", + func(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + paID := factory.NewProcessingActivity(owner). + WithName("DPIA Publish PA"). + WithLawfulBasis("CONSENT"). + Create() + createDPIAForPublish(t, owner, paID, "DPIA description") + + const query = ` + mutation($input: PublishDataProtectionImpactAssessmentListInput!) { + publishDataProtectionImpactAssessmentList(input: $input) { + documentEdge { + node { + id + writeMode + status + } + } + documentVersionEdge { + node { + id + title + documentType + status + major + minor + content + } + } + } + } + ` + + var result struct { + PublishDataProtectionImpactAssessmentList struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + WriteMode string `json:"writeMode"` + Status string `json:"status"` + } `json:"node"` + } `json:"documentEdge"` + DocumentVersionEdge struct { + Node struct { + ID string `json:"id"` + Title string `json:"title"` + DocumentType string `json:"documentType"` + Status string `json:"status"` + Major int `json:"major"` + Minor int `json:"minor"` + Content string `json:"content"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishDataProtectionImpactAssessmentList"` + } + + err := owner.Execute( + query, + map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID(), + }, + }, + &result, + ) + require.NoError(t, err) + + doc := result.PublishDataProtectionImpactAssessmentList.DocumentEdge.Node + assert.NotEmpty(t, doc.ID) + assert.Equal(t, "GENERATED", doc.WriteMode) + assert.Equal(t, "ACTIVE", doc.Status) + + ver := result.PublishDataProtectionImpactAssessmentList.DocumentVersionEdge.Node + assert.NotEmpty(t, ver.ID) + assert.Equal(t, "REGISTER", ver.DocumentType) + assert.Equal(t, "PUBLISHED", ver.Status) + assert.Equal(t, 1, ver.Major) + assert.Equal(t, 0, ver.Minor) + assert.Contains(t, ver.Content, "DPIA description") + }, + ) + + t.Run( + "publish with approvers creates draft pending approval", + func(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + paID := factory.NewProcessingActivity(owner). + WithName("DPIA Approval PA"). + WithLawfulBasis("CONSENT"). + Create() + createDPIAForPublish(t, owner, paID, "DPIA Approval") + + const query = ` + mutation($input: PublishDataProtectionImpactAssessmentListInput!) { + publishDataProtectionImpactAssessmentList(input: $input) { + documentVersionEdge { + node { + status + } + } + } + } + ` + + var result struct { + PublishDataProtectionImpactAssessmentList struct { + DocumentVersionEdge struct { + Node struct { + Status string `json:"status"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishDataProtectionImpactAssessmentList"` + } + + err := owner.Execute( + query, + map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID(), + "approverIds": []string{owner.GetProfileID().String()}, + }, + }, + &result, + ) + require.NoError(t, err) + assert.Equal( + t, + "PENDING_APPROVAL", + result.PublishDataProtectionImpactAssessmentList.DocumentVersionEdge.Node.Status, + ) + }, + ) + + t.Run( + "document linked back to organization via dataProtectionImpactAssessmentsDocument", + func(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + paID := factory.NewProcessingActivity(owner). + WithName("DPIA Link PA"). + WithLawfulBasis("CONSENT"). + Create() + createDPIAForPublish(t, owner, paID, "DPIA Link") + + const publishQuery = ` + mutation($input: PublishDataProtectionImpactAssessmentListInput!) { + publishDataProtectionImpactAssessmentList(input: $input) { + documentEdge { node { id } } + } + } + ` + + var publishResult struct { + PublishDataProtectionImpactAssessmentList struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"documentEdge"` + } `json:"publishDataProtectionImpactAssessmentList"` + } + + err := owner.Execute( + publishQuery, + map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID(), + }, + }, + &publishResult, + ) + require.NoError(t, err) + + docID := publishResult.PublishDataProtectionImpactAssessmentList.DocumentEdge.Node.ID + + const orgQuery = ` + query($id: ID!) { + node(id: $id) { + ... on Organization { + dataProtectionImpactAssessmentsDocument { id } + } + } + } + ` + + var orgResult struct { + Node struct { + DataProtectionImpactAssessmentsDocument *struct { + ID string `json:"id"` + } `json:"dataProtectionImpactAssessmentsDocument"` + } `json:"node"` + } + + err = owner.Execute( + orgQuery, + map[string]any{"id": owner.GetOrganizationID()}, + &orgResult, + ) + require.NoError(t, err) + require.NotNil(t, orgResult.Node.DataProtectionImpactAssessmentsDocument) + assert.Equal(t, docID, orgResult.Node.DataProtectionImpactAssessmentsDocument.ID) + }, + ) +} + +func TestDataProtectionImpactAssessment_PublishList_RBAC(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + paID := factory.NewProcessingActivity(owner). + WithName("DPIA RBAC PA"). + WithLawfulBasis("CONSENT"). + Create() + createDPIAForPublish(t, owner, paID, "DPIA RBAC") + + const query = ` + mutation($input: PublishDataProtectionImpactAssessmentListInput!) { + publishDataProtectionImpactAssessmentList(input: $input) { + documentEdge { node { id } } + } + } + ` + + t.Run("viewer cannot publish DPIA list", func(t *testing.T) { + t.Parallel() + + err := viewer.ExecuteShouldFail( + query, + map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID(), + }, + }, + ) + testutil.RequireForbiddenError(t, err) + }) +} + +func createDPIAForPublish(t *testing.T, client *testutil.Client, processingActivityID string, description string) string { + t.Helper() + + const query = ` + mutation($input: CreateDataProtectionImpactAssessmentInput!) { + createDataProtectionImpactAssessment(input: $input) { + dataProtectionImpactAssessment { id } + } + } + ` + + var result struct { + CreateDataProtectionImpactAssessment struct { + DataProtectionImpactAssessment struct { + ID string `json:"id"` + } `json:"dataProtectionImpactAssessment"` + } `json:"createDataProtectionImpactAssessment"` + } + + err := client.Execute(query, map[string]any{ + "input": map[string]any{ + "processingActivityId": processingActivityID, + "description": description, + "residualRisk": "LOW", + }, + }, &result) + require.NoError(t, err) + + return result.CreateDataProtectionImpactAssessment.DataProtectionImpactAssessment.ID +} diff --git a/e2e/console/processing_activity_publish_test.go b/e2e/console/processing_activity_publish_test.go new file mode 100644 index 000000000..ff95c08cd --- /dev/null +++ b/e2e/console/processing_activity_publish_test.go @@ -0,0 +1,348 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package console_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestProcessingActivity_PublishProcessingActivityList(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "publish without approvers publishes immediately", + func(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + _ = factory.NewProcessingActivity(owner). + WithName("Test Processing Activity"). + WithLawfulBasis("CONSENT"). + Create() + + const query = ` + mutation($input: PublishProcessingActivityListInput!) { + publishProcessingActivityList(input: $input) { + documentEdge { + node { + id + writeMode + status + } + } + documentVersionEdge { + node { + id + title + documentType + status + major + minor + content + } + } + } + } + ` + + var result struct { + PublishProcessingActivityList struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + WriteMode string `json:"writeMode"` + Status string `json:"status"` + } `json:"node"` + } `json:"documentEdge"` + DocumentVersionEdge struct { + Node struct { + ID string `json:"id"` + Title string `json:"title"` + DocumentType string `json:"documentType"` + Status string `json:"status"` + Major int `json:"major"` + Minor int `json:"minor"` + Content string `json:"content"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishProcessingActivityList"` + } + + err := owner.Execute( + query, + map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID(), + }, + }, + &result, + ) + require.NoError(t, err) + + doc := result.PublishProcessingActivityList.DocumentEdge.Node + assert.NotEmpty(t, doc.ID) + assert.Equal(t, "GENERATED", doc.WriteMode) + assert.Equal(t, "ACTIVE", doc.Status) + + ver := result.PublishProcessingActivityList.DocumentVersionEdge.Node + assert.NotEmpty(t, ver.ID) + assert.Equal(t, "REGISTER", ver.DocumentType) + assert.Equal(t, "PUBLISHED", ver.Status) + assert.Equal(t, 1, ver.Major) + assert.Equal(t, 0, ver.Minor) + assert.Contains(t, ver.Content, "Purpose") + assert.Contains(t, ver.Content, "Test Processing Activity") + }, + ) + + t.Run( + "publish with approvers creates draft pending approval", + func(t *testing.T) { + t.Parallel() + + _ = factory.NewProcessingActivity(owner). + WithName("Approval PA"). + WithLawfulBasis("CONSENT"). + Create() + + const query = ` + mutation($input: PublishProcessingActivityListInput!) { + publishProcessingActivityList(input: $input) { + documentVersionEdge { + node { + id + status + } + } + } + } + ` + + var result struct { + PublishProcessingActivityList struct { + DocumentVersionEdge struct { + Node struct { + ID string `json:"id"` + Status string `json:"status"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishProcessingActivityList"` + } + + err := owner.Execute( + query, + map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID(), + "approverIds": []string{owner.GetProfileID().String()}, + }, + }, + &result, + ) + require.NoError(t, err) + + ver := result.PublishProcessingActivityList.DocumentVersionEdge.Node + assert.NotEmpty(t, ver.ID) + assert.Equal(t, "PENDING_APPROVAL", ver.Status) + }, + ) + + t.Run( + "second publish reuses existing document and bumps major version", + func(t *testing.T) { + t.Parallel() + + secondOwner := testutil.NewClient(t, testutil.RoleOwner) + + _ = factory.NewProcessingActivity(secondOwner). + WithName("Reuse PA"). + WithLawfulBasis("CONSENT"). + Create() + + const query = ` + mutation($input: PublishProcessingActivityListInput!) { + publishProcessingActivityList(input: $input) { + documentEdge { + node { id } + } + documentVersionEdge { + node { id major } + } + } + } + ` + + var result1, result2 struct { + PublishProcessingActivityList struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"documentEdge"` + DocumentVersionEdge struct { + Node struct { + ID string `json:"id"` + Major int `json:"major"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishProcessingActivityList"` + } + + input := map[string]any{ + "input": map[string]any{ + "organizationId": secondOwner.GetOrganizationID(), + }, + } + + err := secondOwner.Execute(query, input, &result1) + require.NoError(t, err) + + err = secondOwner.Execute(query, input, &result2) + require.NoError(t, err) + + assert.Equal( + t, + result1.PublishProcessingActivityList.DocumentEdge.Node.ID, + result2.PublishProcessingActivityList.DocumentEdge.Node.ID, + "should reuse the same document", + ) + assert.Equal(t, 1, result1.PublishProcessingActivityList.DocumentVersionEdge.Node.Major) + assert.Equal(t, 2, result2.PublishProcessingActivityList.DocumentVersionEdge.Node.Major) + }, + ) + + t.Run( + "document linked back to organization via processingActivitiesDocument", + func(t *testing.T) { + t.Parallel() + + thirdOwner := testutil.NewClient(t, testutil.RoleOwner) + + _ = factory.NewProcessingActivity(thirdOwner). + WithName("Linked PA"). + WithLawfulBasis("CONSENT"). + Create() + + const publishQuery = ` + mutation($input: PublishProcessingActivityListInput!) { + publishProcessingActivityList(input: $input) { + documentEdge { + node { id } + } + } + } + ` + + var publishResult struct { + PublishProcessingActivityList struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"documentEdge"` + } `json:"publishProcessingActivityList"` + } + + err := thirdOwner.Execute( + publishQuery, + map[string]any{ + "input": map[string]any{ + "organizationId": thirdOwner.GetOrganizationID(), + }, + }, + &publishResult, + ) + require.NoError(t, err) + + docID := publishResult.PublishProcessingActivityList.DocumentEdge.Node.ID + + const orgQuery = ` + query($id: ID!) { + node(id: $id) { + ... on Organization { + id + processingActivitiesDocument { id } + } + } + } + ` + + var orgResult struct { + Node struct { + ID string `json:"id"` + ProcessingActivitiesDocument *struct { + ID string `json:"id"` + } `json:"processingActivitiesDocument"` + } `json:"node"` + } + + err = thirdOwner.Execute( + orgQuery, + map[string]any{"id": thirdOwner.GetOrganizationID()}, + &orgResult, + ) + require.NoError(t, err) + require.NotNil(t, orgResult.Node.ProcessingActivitiesDocument) + assert.Equal(t, docID, orgResult.Node.ProcessingActivitiesDocument.ID) + }, + ) +} + +func TestProcessingActivity_PublishProcessingActivityList_RBAC(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + _ = factory.NewProcessingActivity(owner). + WithName("RBAC PA"). + WithLawfulBasis("CONSENT"). + Create() + + const query = ` + mutation($input: PublishProcessingActivityListInput!) { + publishProcessingActivityList(input: $input) { + documentEdge { + node { id } + } + } + } + ` + + t.Run( + "viewer cannot publish processing activity list", + func(t *testing.T) { + t.Parallel() + + err := viewer.ExecuteShouldFail( + query, + map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID(), + }, + }, + ) + testutil.RequireForbiddenError(t, err) + }, + ) +} diff --git a/e2e/console/processing_activity_test.go b/e2e/console/processing_activity_test.go index 35804dc2c..820f0543c 100644 --- a/e2e/console/processing_activity_test.go +++ b/e2e/console/processing_activity_test.go @@ -2306,680 +2306,3 @@ func TestProcessingActivity_TIA_RBAC(t *testing.T) { testutil.RequireForbiddenError(t, err, "viewer should not be able to delete TIA") }) } - -func TestProcessingActivity_Snapshot_DPIA_TIA(t *testing.T) { - t.Parallel() - owner := testutil.NewClient(t, testutil.RoleOwner) - - t.Run("snapshot includes DPIA and TIA", func(t *testing.T) { - paID := factory.NewProcessingActivity(owner). - WithName("Snapshot DPIA TIA Test"). - Create() - - var dpiaResult struct { - CreateDataProtectionImpactAssessment struct { - DataProtectionImpactAssessment struct { - ID string `json:"id"` - } `json:"dataProtectionImpactAssessment"` - } `json:"createDataProtectionImpactAssessment"` - } - err := owner.Execute(` - mutation($input: CreateDataProtectionImpactAssessmentInput!) { - createDataProtectionImpactAssessment(input: $input) { - dataProtectionImpactAssessment { id } - } - } - `, map[string]any{ - "input": map[string]any{ - "processingActivityId": paID, - "description": "DPIA for snapshot", - "residualRisk": "MEDIUM", - }, - }, &dpiaResult) - require.NoError(t, err) - - var tiaResult struct { - CreateTransferImpactAssessment struct { - TransferImpactAssessment struct { - ID string `json:"id"` - } `json:"transferImpactAssessment"` - } `json:"createTransferImpactAssessment"` - } - err = owner.Execute(` - mutation($input: CreateTransferImpactAssessmentInput!) { - createTransferImpactAssessment(input: $input) { - transferImpactAssessment { id } - } - } - `, map[string]any{ - "input": map[string]any{ - "processingActivityId": paID, - "dataSubjects": "TIA subjects for snapshot", - "transfer": "EU to US", - }, - }, &tiaResult) - require.NoError(t, err) - - var snapshotResult struct { - CreateSnapshot struct { - SnapshotEdge struct { - Node struct { - ID string `json:"id"` - } `json:"node"` - } `json:"snapshotEdge"` - } `json:"createSnapshot"` - } - err = owner.Execute(` - mutation($input: CreateSnapshotInput!) { - createSnapshot(input: $input) { - snapshotEdge { - node { id } - } - } - } - `, map[string]any{ - "input": map[string]any{ - "organizationId": owner.GetOrganizationID().String(), - "name": fmt.Sprintf("PA Snapshot Test %d", time.Now().UnixNano()), - "type": "PROCESSING_ACTIVITIES", - }, - }, &snapshotResult) - require.NoError(t, err) - snapshotID := snapshotResult.CreateSnapshot.SnapshotEdge.Node.ID - - var queryResult struct { - Node struct { - ProcessingActivities struct { - Edges []struct { - Node struct { - ID string `json:"id"` - Name string `json:"name"` - DataProtectionImpactAssessment *struct { - ID string `json:"id"` - Description *string `json:"description"` - ResidualRisk *string `json:"residualRisk"` - } `json:"dataProtectionImpactAssessment"` - TransferImpactAssessment *struct { - ID string `json:"id"` - DataSubjects *string `json:"dataSubjects"` - Transfer *string `json:"transfer"` - } `json:"transferImpactAssessment"` - } `json:"node"` - } `json:"edges"` - } `json:"processingActivities"` - } `json:"node"` - } - err = owner.Execute(` - query($orgId: ID!, $snapshotId: ID!) { - node(id: $orgId) { - ... on Organization { - processingActivities(first: 100, filter: { snapshotId: $snapshotId }) { - edges { - node { - id - name - dataProtectionImpactAssessment { - id - description - residualRisk - } - transferImpactAssessment { - id - dataSubjects - transfer - } - } - } - } - } - } - } - `, map[string]any{ - "orgId": owner.GetOrganizationID().String(), - "snapshotId": snapshotID, - }, &queryResult) - require.NoError(t, err) - - var foundPA bool - for _, edge := range queryResult.Node.ProcessingActivities.Edges { - if edge.Node.Name == "Snapshot DPIA TIA Test" { - foundPA = true - require.NotNil(t, edge.Node.DataProtectionImpactAssessment, "DPIA should be included in snapshot") - assert.Equal(t, "DPIA for snapshot", *edge.Node.DataProtectionImpactAssessment.Description) - assert.Equal(t, "MEDIUM", *edge.Node.DataProtectionImpactAssessment.ResidualRisk) - - require.NotNil(t, edge.Node.TransferImpactAssessment, "TIA should be included in snapshot") - assert.Equal(t, "TIA subjects for snapshot", *edge.Node.TransferImpactAssessment.DataSubjects) - assert.Equal(t, "EU to US", *edge.Node.TransferImpactAssessment.Transfer) - break - } - } - assert.True(t, foundPA, "Processing activity should be found in snapshot") - }) - - t.Run("snapshot DPIA and TIA are independent of source", func(t *testing.T) { - paID := factory.NewProcessingActivity(owner). - WithName("Snapshot Independence Test"). - Create() - - err := owner.Execute(` - mutation($input: CreateDataProtectionImpactAssessmentInput!) { - createDataProtectionImpactAssessment(input: $input) { - dataProtectionImpactAssessment { id } - } - } - `, map[string]any{ - "input": map[string]any{ - "processingActivityId": paID, - "description": "Original DPIA", - "residualRisk": "LOW", - }, - }, nil) - require.NoError(t, err) - - var snapshotResult struct { - CreateSnapshot struct { - SnapshotEdge struct { - Node struct { - ID string `json:"id"` - } `json:"node"` - } `json:"snapshotEdge"` - } `json:"createSnapshot"` - } - err = owner.Execute(` - mutation($input: CreateSnapshotInput!) { - createSnapshot(input: $input) { - snapshotEdge { - node { id } - } - } - } - `, map[string]any{ - "input": map[string]any{ - "organizationId": owner.GetOrganizationID().String(), - "name": fmt.Sprintf("PA Independence Test %d", time.Now().UnixNano()), - "type": "PROCESSING_ACTIVITIES", - }, - }, &snapshotResult) - require.NoError(t, err) - snapshotID := snapshotResult.CreateSnapshot.SnapshotEdge.Node.ID - - var sourceResult struct { - Node struct { - DataProtectionImpactAssessment *struct { - ID string `json:"id"` - Description *string `json:"description"` - ResidualRisk *string `json:"residualRisk"` - } `json:"dataProtectionImpactAssessment"` - } `json:"node"` - } - err = owner.Execute(` - query($id: ID!) { - node(id: $id) { - ... on ProcessingActivity { - dataProtectionImpactAssessment { - id - description - residualRisk - } - } - } - } - `, map[string]any{"id": paID}, &sourceResult) - require.NoError(t, err) - require.NotNil(t, sourceResult.Node.DataProtectionImpactAssessment) - sourceDpiaID := sourceResult.Node.DataProtectionImpactAssessment.ID - - _, err = owner.Do(` - mutation($input: UpdateDataProtectionImpactAssessmentInput!) { - updateDataProtectionImpactAssessment(input: $input) { - dataProtectionImpactAssessment { id } - } - } - `, map[string]any{ - "input": map[string]any{ - "id": sourceDpiaID, - "description": "Updated after snapshot", - "residualRisk": "HIGH", - }, - }) - require.NoError(t, err) - - var snapshotQueryResult struct { - Node struct { - ProcessingActivities struct { - Edges []struct { - Node struct { - Name string `json:"name"` - DataProtectionImpactAssessment *struct { - Description *string `json:"description"` - ResidualRisk *string `json:"residualRisk"` - } `json:"dataProtectionImpactAssessment"` - } `json:"node"` - } `json:"edges"` - } `json:"processingActivities"` - } `json:"node"` - } - err = owner.Execute(` - query($orgId: ID!, $snapshotId: ID!) { - node(id: $orgId) { - ... on Organization { - processingActivities(first: 100, filter: { snapshotId: $snapshotId }) { - edges { - node { - name - dataProtectionImpactAssessment { - description - residualRisk - } - } - } - } - } - } - } - `, map[string]any{ - "orgId": owner.GetOrganizationID().String(), - "snapshotId": snapshotID, - }, &snapshotQueryResult) - require.NoError(t, err) - - for _, edge := range snapshotQueryResult.Node.ProcessingActivities.Edges { - if edge.Node.Name == "Snapshot Independence Test" { - require.NotNil(t, edge.Node.DataProtectionImpactAssessment) - assert.Equal(t, "Original DPIA", *edge.Node.DataProtectionImpactAssessment.Description, "Snapshot DPIA should retain original value") - assert.Equal(t, "LOW", *edge.Node.DataProtectionImpactAssessment.ResidualRisk, "Snapshot DPIA should retain original value") - break - } - } - }) -} - -func TestProcessingActivity_ExportPDF(t *testing.T) { - t.Parallel() - owner := testutil.NewClient(t, testutil.RoleOwner) - - t.Run("export processing activities PDF", func(t *testing.T) { - _ = factory.NewProcessingActivity(owner). - WithName("PA Export Test 1"). - WithLawfulBasis("CONSENT"). - Create() - _ = factory.NewProcessingActivity(owner). - WithName("PA Export Test 2"). - WithLawfulBasis("LEGITIMATE_INTEREST"). - Create() - - query := ` - mutation ExportProcessingActivitiesPDF($input: ExportProcessingActivitiesPDFInput!) { - exportProcessingActivitiesPDF(input: $input) { - data - } - } - ` - - var result struct { - ExportProcessingActivitiesPDF struct { - Data string `json:"data"` - } `json:"exportProcessingActivitiesPDF"` - } - - err := owner.Execute(query, map[string]any{ - "input": map[string]any{ - "organizationId": owner.GetOrganizationID().String(), - "filter": nil, - }, - }, &result) - require.NoError(t, err) - assert.NotEmpty(t, result.ExportProcessingActivitiesPDF.Data) - assert.Contains(t, result.ExportProcessingActivitiesPDF.Data, "data:application/pdf;base64,") - }) - - t.Run("export processing activities PDF with snapshot filter", func(t *testing.T) { - _ = factory.NewProcessingActivity(owner). - WithName("PA Snapshot Export Test"). - Create() - - // Create snapshot - var snapshotResult struct { - CreateSnapshot struct { - SnapshotEdge struct { - Node struct { - ID string `json:"id"` - } `json:"node"` - } `json:"snapshotEdge"` - } `json:"createSnapshot"` - } - err := owner.Execute(` - mutation($input: CreateSnapshotInput!) { - createSnapshot(input: $input) { - snapshotEdge { - node { id } - } - } - } - `, map[string]any{ - "input": map[string]any{ - "organizationId": owner.GetOrganizationID().String(), - "name": fmt.Sprintf("PA Export Snapshot Test %d", time.Now().UnixNano()), - "type": "PROCESSING_ACTIVITIES", - }, - }, &snapshotResult) - require.NoError(t, err) - snapshotID := snapshotResult.CreateSnapshot.SnapshotEdge.Node.ID - - query := ` - mutation ExportProcessingActivitiesPDF($input: ExportProcessingActivitiesPDFInput!) { - exportProcessingActivitiesPDF(input: $input) { - data - } - } - ` - - var result struct { - ExportProcessingActivitiesPDF struct { - Data string `json:"data"` - } `json:"exportProcessingActivitiesPDF"` - } - - err = owner.Execute(query, map[string]any{ - "input": map[string]any{ - "organizationId": owner.GetOrganizationID().String(), - "filter": map[string]any{ - "snapshotId": snapshotID, - }, - }, - }, &result) - require.NoError(t, err) - assert.NotEmpty(t, result.ExportProcessingActivitiesPDF.Data) - assert.Contains(t, result.ExportProcessingActivitiesPDF.Data, "data:application/pdf;base64,") - }) - - t.Run("export fails with no processing activities", func(t *testing.T) { - newOwner := testutil.NewClient(t, testutil.RoleOwner) - - query := ` - mutation ExportProcessingActivitiesPDF($input: ExportProcessingActivitiesPDFInput!) { - exportProcessingActivitiesPDF(input: $input) { - data - } - } - ` - - _, err := newOwner.Do(query, map[string]any{ - "input": map[string]any{ - "organizationId": newOwner.GetOrganizationID().String(), - "filter": nil, - }, - }) - testutil.RequireErrorCode(t, err, "NOT_FOUND") - assert.Contains(t, err.Error(), "no processing activities found") - }) -} - -func TestDataProtectionImpactAssessment_ExportPDF(t *testing.T) { - t.Parallel() - owner := testutil.NewClient(t, testutil.RoleOwner) - - t.Run("export DPIA PDF", func(t *testing.T) { - pa1ID := factory.NewProcessingActivity(owner). - WithName("DPIA Export Test PA 1"). - Create() - pa2ID := factory.NewProcessingActivity(owner). - WithName("DPIA Export Test PA 2"). - Create() - - _, err := owner.Do(` - mutation($input: CreateDataProtectionImpactAssessmentInput!) { - createDataProtectionImpactAssessment(input: $input) { - dataProtectionImpactAssessment { id } - } - } - `, map[string]any{ - "input": map[string]any{ - "processingActivityId": pa1ID, - "description": "DPIA 1 description", - "residualRisk": "LOW", - }, - }) - require.NoError(t, err) - - _, err = owner.Do(` - mutation($input: CreateDataProtectionImpactAssessmentInput!) { - createDataProtectionImpactAssessment(input: $input) { - dataProtectionImpactAssessment { id } - } - } - `, map[string]any{ - "input": map[string]any{ - "processingActivityId": pa2ID, - "description": "DPIA 2 description", - "residualRisk": "MEDIUM", - }, - }) - require.NoError(t, err) - - query := ` - mutation ExportDataProtectionImpactAssessmentsPDF($input: ExportDataProtectionImpactAssessmentsPDFInput!) { - exportDataProtectionImpactAssessmentsPDF(input: $input) { - data - } - } - ` - - var result struct { - ExportDataProtectionImpactAssessmentsPDF struct { - Data string `json:"data"` - } `json:"exportDataProtectionImpactAssessmentsPDF"` - } - - err = owner.Execute(query, map[string]any{ - "input": map[string]any{ - "organizationId": owner.GetOrganizationID().String(), - "filter": nil, - }, - }, &result) - require.NoError(t, err) - assert.NotEmpty(t, result.ExportDataProtectionImpactAssessmentsPDF.Data) - assert.Contains(t, result.ExportDataProtectionImpactAssessmentsPDF.Data, "data:application/pdf;base64,") - }) - - t.Run("export fails with no DPIAs", func(t *testing.T) { - newOwner := testutil.NewClient(t, testutil.RoleOwner) - - query := ` - mutation ExportDataProtectionImpactAssessmentsPDF($input: ExportDataProtectionImpactAssessmentsPDFInput!) { - exportDataProtectionImpactAssessmentsPDF(input: $input) { - data - } - } - ` - - _, err := newOwner.Do(query, map[string]any{ - "input": map[string]any{ - "organizationId": newOwner.GetOrganizationID().String(), - "filter": nil, - }, - }) - testutil.RequireErrorCode(t, err, "NOT_FOUND") - assert.Contains(t, err.Error(), "no data protection impact assessments found") - }) -} - -func TestTransferImpactAssessment_ExportPDF(t *testing.T) { - t.Parallel() - owner := testutil.NewClient(t, testutil.RoleOwner) - - t.Run("export TIA PDF", func(t *testing.T) { - pa1ID := factory.NewProcessingActivity(owner). - WithName("TIA Export Test PA 1"). - Create() - pa2ID := factory.NewProcessingActivity(owner). - WithName("TIA Export Test PA 2"). - Create() - - _, err := owner.Do(` - mutation($input: CreateTransferImpactAssessmentInput!) { - createTransferImpactAssessment(input: $input) { - transferImpactAssessment { id } - } - } - `, map[string]any{ - "input": map[string]any{ - "processingActivityId": pa1ID, - "dataSubjects": "TIA 1 subjects", - "transfer": "EU to US", - }, - }) - require.NoError(t, err) - - _, err = owner.Do(` - mutation($input: CreateTransferImpactAssessmentInput!) { - createTransferImpactAssessment(input: $input) { - transferImpactAssessment { id } - } - } - `, map[string]any{ - "input": map[string]any{ - "processingActivityId": pa2ID, - "dataSubjects": "TIA 2 subjects", - "transfer": "EU to UK", - }, - }) - require.NoError(t, err) - - query := ` - mutation ExportTransferImpactAssessmentsPDF($input: ExportTransferImpactAssessmentsPDFInput!) { - exportTransferImpactAssessmentsPDF(input: $input) { - data - } - } - ` - - var result struct { - ExportTransferImpactAssessmentsPDF struct { - Data string `json:"data"` - } `json:"exportTransferImpactAssessmentsPDF"` - } - - err = owner.Execute(query, map[string]any{ - "input": map[string]any{ - "organizationId": owner.GetOrganizationID().String(), - "filter": nil, - }, - }, &result) - require.NoError(t, err) - assert.NotEmpty(t, result.ExportTransferImpactAssessmentsPDF.Data) - assert.Contains(t, result.ExportTransferImpactAssessmentsPDF.Data, "data:application/pdf;base64,") - }) - - t.Run("export fails with no TIAs", func(t *testing.T) { - newOwner := testutil.NewClient(t, testutil.RoleOwner) - - query := ` - mutation ExportTransferImpactAssessmentsPDF($input: ExportTransferImpactAssessmentsPDFInput!) { - exportTransferImpactAssessmentsPDF(input: $input) { - data - } - } - ` - - _, err := newOwner.Do(query, map[string]any{ - "input": map[string]any{ - "organizationId": newOwner.GetOrganizationID().String(), - "filter": nil, - }, - }) - testutil.RequireErrorCode(t, err, "NOT_FOUND") - assert.Contains(t, err.Error(), "no transfer impact assessments found") - }) -} - -func TestProcessingActivity_ExportPDF_RBAC(t *testing.T) { - t.Parallel() - - t.Run("owner can export PDF", func(t *testing.T) { - owner := testutil.NewClient(t, testutil.RoleOwner) - _ = factory.NewProcessingActivity(owner).WithName("RBAC Export Test").Create() - - query := ` - mutation ExportProcessingActivitiesPDF($input: ExportProcessingActivitiesPDFInput!) { - exportProcessingActivitiesPDF(input: $input) { - data - } - } - ` - - var result struct { - ExportProcessingActivitiesPDF struct { - Data string `json:"data"` - } `json:"exportProcessingActivitiesPDF"` - } - - err := owner.Execute(query, map[string]any{ - "input": map[string]any{ - "organizationId": owner.GetOrganizationID().String(), - "filter": nil, - }, - }, &result) - require.NoError(t, err, "owner should be able to export PDF") - assert.NotEmpty(t, result.ExportProcessingActivitiesPDF.Data) - }) - - t.Run("admin can export PDF", func(t *testing.T) { - owner := testutil.NewClient(t, testutil.RoleOwner) - admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner) - _ = factory.NewProcessingActivity(owner).WithName("RBAC Export Test").Create() - - query := ` - mutation ExportProcessingActivitiesPDF($input: ExportProcessingActivitiesPDFInput!) { - exportProcessingActivitiesPDF(input: $input) { - data - } - } - ` - - var result struct { - ExportProcessingActivitiesPDF struct { - Data string `json:"data"` - } `json:"exportProcessingActivitiesPDF"` - } - - err := admin.Execute(query, map[string]any{ - "input": map[string]any{ - "organizationId": admin.GetOrganizationID().String(), - "filter": nil, - }, - }, &result) - require.NoError(t, err, "admin should be able to export PDF") - assert.NotEmpty(t, result.ExportProcessingActivitiesPDF.Data) - }) - - t.Run("viewer can export PDF", func(t *testing.T) { - owner := testutil.NewClient(t, testutil.RoleOwner) - viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) - _ = factory.NewProcessingActivity(owner).WithName("RBAC Export Test").Create() - - query := ` - mutation ExportProcessingActivitiesPDF($input: ExportProcessingActivitiesPDFInput!) { - exportProcessingActivitiesPDF(input: $input) { - data - } - } - ` - - var result struct { - ExportProcessingActivitiesPDF struct { - Data string `json:"data"` - } `json:"exportProcessingActivitiesPDF"` - } - - err := viewer.Execute(query, map[string]any{ - "input": map[string]any{ - "organizationId": viewer.GetOrganizationID().String(), - "filter": nil, - }, - }, &result) - require.NoError(t, err, "viewer should be able to export PDF") - assert.NotEmpty(t, result.ExportProcessingActivitiesPDF.Data) - }) -} diff --git a/e2e/console/tia_publish_test.go b/e2e/console/tia_publish_test.go new file mode 100644 index 000000000..88e2b3259 --- /dev/null +++ b/e2e/console/tia_publish_test.go @@ -0,0 +1,304 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package console_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestTransferImpactAssessment_PublishList(t *testing.T) { + t.Parallel() + + t.Run( + "publish without approvers publishes immediately", + func(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + paID := factory.NewProcessingActivity(owner). + WithName("TIA Publish PA"). + WithLawfulBasis("CONSENT"). + Create() + createTIAForPublish(t, owner, paID, "EU to US transfer") + + const query = ` + mutation($input: PublishTransferImpactAssessmentListInput!) { + publishTransferImpactAssessmentList(input: $input) { + documentEdge { + node { + id + writeMode + status + } + } + documentVersionEdge { + node { + id + title + documentType + status + major + minor + content + } + } + } + } + ` + + var result struct { + PublishTransferImpactAssessmentList struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + WriteMode string `json:"writeMode"` + Status string `json:"status"` + } `json:"node"` + } `json:"documentEdge"` + DocumentVersionEdge struct { + Node struct { + ID string `json:"id"` + Title string `json:"title"` + DocumentType string `json:"documentType"` + Status string `json:"status"` + Major int `json:"major"` + Minor int `json:"minor"` + Content string `json:"content"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishTransferImpactAssessmentList"` + } + + err := owner.Execute( + query, + map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID(), + }, + }, + &result, + ) + require.NoError(t, err) + + doc := result.PublishTransferImpactAssessmentList.DocumentEdge.Node + assert.NotEmpty(t, doc.ID) + assert.Equal(t, "GENERATED", doc.WriteMode) + assert.Equal(t, "ACTIVE", doc.Status) + + ver := result.PublishTransferImpactAssessmentList.DocumentVersionEdge.Node + assert.NotEmpty(t, ver.ID) + assert.Equal(t, "REGISTER", ver.DocumentType) + assert.Equal(t, "PUBLISHED", ver.Status) + assert.Equal(t, 1, ver.Major) + assert.Equal(t, 0, ver.Minor) + assert.Contains(t, ver.Content, "EU to US transfer") + }, + ) + + t.Run( + "publish with approvers creates draft pending approval", + func(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + paID := factory.NewProcessingActivity(owner). + WithName("TIA Approval PA"). + WithLawfulBasis("CONSENT"). + Create() + createTIAForPublish(t, owner, paID, "TIA Approval") + + const query = ` + mutation($input: PublishTransferImpactAssessmentListInput!) { + publishTransferImpactAssessmentList(input: $input) { + documentVersionEdge { + node { + status + } + } + } + } + ` + + var result struct { + PublishTransferImpactAssessmentList struct { + DocumentVersionEdge struct { + Node struct { + Status string `json:"status"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishTransferImpactAssessmentList"` + } + + err := owner.Execute( + query, + map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID(), + "approverIds": []string{owner.GetProfileID().String()}, + }, + }, + &result, + ) + require.NoError(t, err) + assert.Equal( + t, + "PENDING_APPROVAL", + result.PublishTransferImpactAssessmentList.DocumentVersionEdge.Node.Status, + ) + }, + ) + + t.Run( + "document linked back to organization via transferImpactAssessmentsDocument", + func(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + paID := factory.NewProcessingActivity(owner). + WithName("TIA Link PA"). + WithLawfulBasis("CONSENT"). + Create() + createTIAForPublish(t, owner, paID, "TIA Link") + + const publishQuery = ` + mutation($input: PublishTransferImpactAssessmentListInput!) { + publishTransferImpactAssessmentList(input: $input) { + documentEdge { node { id } } + } + } + ` + + var publishResult struct { + PublishTransferImpactAssessmentList struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"documentEdge"` + } `json:"publishTransferImpactAssessmentList"` + } + + err := owner.Execute( + publishQuery, + map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID(), + }, + }, + &publishResult, + ) + require.NoError(t, err) + + docID := publishResult.PublishTransferImpactAssessmentList.DocumentEdge.Node.ID + + const orgQuery = ` + query($id: ID!) { + node(id: $id) { + ... on Organization { + transferImpactAssessmentsDocument { id } + } + } + } + ` + + var orgResult struct { + Node struct { + TransferImpactAssessmentsDocument *struct { + ID string `json:"id"` + } `json:"transferImpactAssessmentsDocument"` + } `json:"node"` + } + + err = owner.Execute( + orgQuery, + map[string]any{"id": owner.GetOrganizationID()}, + &orgResult, + ) + require.NoError(t, err) + require.NotNil(t, orgResult.Node.TransferImpactAssessmentsDocument) + assert.Equal(t, docID, orgResult.Node.TransferImpactAssessmentsDocument.ID) + }, + ) +} + +func TestTransferImpactAssessment_PublishList_RBAC(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + paID := factory.NewProcessingActivity(owner). + WithName("TIA RBAC PA"). + WithLawfulBasis("CONSENT"). + Create() + createTIAForPublish(t, owner, paID, "TIA RBAC") + + const query = ` + mutation($input: PublishTransferImpactAssessmentListInput!) { + publishTransferImpactAssessmentList(input: $input) { + documentEdge { node { id } } + } + } + ` + + t.Run("viewer cannot publish TIA list", func(t *testing.T) { + t.Parallel() + + err := viewer.ExecuteShouldFail( + query, + map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID(), + }, + }, + ) + testutil.RequireForbiddenError(t, err) + }) +} + +func createTIAForPublish(t *testing.T, client *testutil.Client, processingActivityID string, transfer string) string { + t.Helper() + + const query = ` + mutation($input: CreateTransferImpactAssessmentInput!) { + createTransferImpactAssessment(input: $input) { + transferImpactAssessment { id } + } + } + ` + + var result struct { + CreateTransferImpactAssessment struct { + TransferImpactAssessment struct { + ID string `json:"id"` + } `json:"transferImpactAssessment"` + } `json:"createTransferImpactAssessment"` + } + + err := client.Execute(query, map[string]any{ + "input": map[string]any{ + "processingActivityId": processingActivityID, + "transfer": transfer, + }, + }, &result) + require.NoError(t, err) + + return result.CreateTransferImpactAssessment.TransferImpactAssessment.ID +} diff --git a/packages/n8n-node/nodes/Probo/actions/dpia/index.ts b/packages/n8n-node/nodes/Probo/actions/dpia/index.ts index 195a1f898..ad1c0700a 100644 --- a/packages/n8n-node/nodes/Probo/actions/dpia/index.ts +++ b/packages/n8n-node/nodes/Probo/actions/dpia/index.ts @@ -18,6 +18,7 @@ import * as updateOp from './update.operation'; import * as deleteOp from './delete.operation'; import * as getOp from './get.operation'; import * as getAllOp from './getAll.operation'; +import * as publishOp from './publish.operation'; export const description: INodeProperties[] = [ { @@ -55,6 +56,12 @@ export const description: INodeProperties[] = [ description: 'Get many DPIAs', action: 'Get many dpias', }, + { + name: 'Publish', + value: 'publish', + description: 'Publish the DPIA list as a document', + action: 'Publish the DPIA list', + }, { name: 'Update', value: 'update', @@ -69,6 +76,14 @@ export const description: INodeProperties[] = [ ...deleteOp.description, ...getOp.description, ...getAllOp.description, + ...publishOp.description, ]; -export { createOp as create, updateOp as update, deleteOp as delete, getOp as get, getAllOp as getAll }; +export { + createOp as create, + updateOp as update, + deleteOp as delete, + getOp as get, + getAllOp as getAll, + publishOp as publish, +}; diff --git a/packages/n8n-node/nodes/Probo/actions/dpia/publish.operation.ts b/packages/n8n-node/nodes/Probo/actions/dpia/publish.operation.ts new file mode 100644 index 000000000..2b54fae5f --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/dpia/publish.operation.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'string', + displayOptions: { + show: { + resource: ['dpia'], + operation: ['publish'], + }, + }, + default: '', + description: 'The ID of the organization whose DPIA list to publish', + required: true, + }, + { + displayName: 'Approver IDs', + name: 'approverIds', + type: 'string', + displayOptions: { + show: { + resource: ['dpia'], + operation: ['publish'], + }, + }, + default: '', + description: 'Comma-separated list of approver profile IDs', + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const organizationId = this.getNodeParameter('organizationId', itemIndex) as string; + const approverIds = this.getNodeParameter('approverIds', itemIndex, '') as string; + + const query = ` + mutation PublishDataProtectionImpactAssessmentList($input: PublishDataProtectionImpactAssessmentListInput!) { + publishDataProtectionImpactAssessmentList(input: $input) { + documentEdge { + node { + id + status + currentPublishedMajor + currentPublishedMinor + createdAt + updatedAt + } + } + documentVersionEdge { + node { + id + title + major + minor + status + classification + documentType + publishedAt + createdAt + updatedAt + } + } + } + } + `; + + const input: Record = { organizationId }; + + if (approverIds) { + input.approverIds = approverIds + .split(',') + .map(id => id.trim()) + .filter(Boolean); + } + + const responseData = await proboApiRequest.call(this, query, { input }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/processingActivity/index.ts b/packages/n8n-node/nodes/Probo/actions/processingActivity/index.ts index 606be22f2..1dfda5ac3 100644 --- a/packages/n8n-node/nodes/Probo/actions/processingActivity/index.ts +++ b/packages/n8n-node/nodes/Probo/actions/processingActivity/index.ts @@ -18,6 +18,7 @@ import * as updateOp from './update.operation'; import * as deleteOp from './delete.operation'; import * as getOp from './get.operation'; import * as getAllOp from './getAll.operation'; +import * as publishOp from './publish.operation'; export const description: INodeProperties[] = [ { @@ -55,6 +56,12 @@ export const description: INodeProperties[] = [ description: 'Get many processing activities', action: 'Get many processing activities', }, + { + name: 'Publish', + value: 'publish', + description: 'Publish the processing activity list as a document', + action: 'Publish the processing activity list', + }, { name: 'Update', value: 'update', @@ -69,6 +76,14 @@ export const description: INodeProperties[] = [ ...deleteOp.description, ...getOp.description, ...getAllOp.description, + ...publishOp.description, ]; -export { createOp as create, updateOp as update, deleteOp as delete, getOp as get, getAllOp as getAll }; +export { + createOp as create, + updateOp as update, + deleteOp as delete, + getOp as get, + getAllOp as getAll, + publishOp as publish, +}; diff --git a/packages/n8n-node/nodes/Probo/actions/processingActivity/publish.operation.ts b/packages/n8n-node/nodes/Probo/actions/processingActivity/publish.operation.ts new file mode 100644 index 000000000..c80df39c1 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/processingActivity/publish.operation.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'string', + displayOptions: { + show: { + resource: ['processingActivity'], + operation: ['publish'], + }, + }, + default: '', + description: 'The ID of the organization whose processing activity list to publish', + required: true, + }, + { + displayName: 'Approver IDs', + name: 'approverIds', + type: 'string', + displayOptions: { + show: { + resource: ['processingActivity'], + operation: ['publish'], + }, + }, + default: '', + description: 'Comma-separated list of approver profile IDs', + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const organizationId = this.getNodeParameter('organizationId', itemIndex) as string; + const approverIds = this.getNodeParameter('approverIds', itemIndex, '') as string; + + const query = ` + mutation PublishProcessingActivityList($input: PublishProcessingActivityListInput!) { + publishProcessingActivityList(input: $input) { + documentEdge { + node { + id + status + currentPublishedMajor + currentPublishedMinor + createdAt + updatedAt + } + } + documentVersionEdge { + node { + id + title + major + minor + status + classification + documentType + publishedAt + createdAt + updatedAt + } + } + } + } + `; + + const input: Record = { organizationId }; + + if (approverIds) { + input.approverIds = approverIds + .split(',') + .map(id => id.trim()) + .filter(Boolean); + } + + const responseData = await proboApiRequest.call(this, query, { input }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/tia/index.ts b/packages/n8n-node/nodes/Probo/actions/tia/index.ts index cbedd2c88..7c327f41d 100644 --- a/packages/n8n-node/nodes/Probo/actions/tia/index.ts +++ b/packages/n8n-node/nodes/Probo/actions/tia/index.ts @@ -18,6 +18,7 @@ import * as updateOp from './update.operation'; import * as deleteOp from './delete.operation'; import * as getOp from './get.operation'; import * as getAllOp from './getAll.operation'; +import * as publishOp from './publish.operation'; export const description: INodeProperties[] = [ { @@ -55,6 +56,12 @@ export const description: INodeProperties[] = [ description: 'Get many TIAs', action: 'Get many tias', }, + { + name: 'Publish', + value: 'publish', + description: 'Publish the TIA list as a document', + action: 'Publish the TIA list', + }, { name: 'Update', value: 'update', @@ -69,6 +76,14 @@ export const description: INodeProperties[] = [ ...deleteOp.description, ...getOp.description, ...getAllOp.description, + ...publishOp.description, ]; -export { createOp as create, updateOp as update, deleteOp as delete, getOp as get, getAllOp as getAll }; +export { + createOp as create, + updateOp as update, + deleteOp as delete, + getOp as get, + getAllOp as getAll, + publishOp as publish, +}; diff --git a/packages/n8n-node/nodes/Probo/actions/tia/publish.operation.ts b/packages/n8n-node/nodes/Probo/actions/tia/publish.operation.ts new file mode 100644 index 000000000..ed978a360 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/tia/publish.operation.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'string', + displayOptions: { + show: { + resource: ['tia'], + operation: ['publish'], + }, + }, + default: '', + description: 'The ID of the organization whose TIA list to publish', + required: true, + }, + { + displayName: 'Approver IDs', + name: 'approverIds', + type: 'string', + displayOptions: { + show: { + resource: ['tia'], + operation: ['publish'], + }, + }, + default: '', + description: 'Comma-separated list of approver profile IDs', + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const organizationId = this.getNodeParameter('organizationId', itemIndex) as string; + const approverIds = this.getNodeParameter('approverIds', itemIndex, '') as string; + + const query = ` + mutation PublishTransferImpactAssessmentList($input: PublishTransferImpactAssessmentListInput!) { + publishTransferImpactAssessmentList(input: $input) { + documentEdge { + node { + id + status + currentPublishedMajor + currentPublishedMinor + createdAt + updatedAt + } + } + documentVersionEdge { + node { + id + title + major + minor + status + classification + documentType + publishedAt + createdAt + updatedAt + } + } + } + } + `; + + const input: Record = { organizationId }; + + if (approverIds) { + input.approverIds = approverIds + .split(',') + .map(id => id.trim()) + .filter(Boolean); + } + + const responseData = await proboApiRequest.call(this, query, { input }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/pkg/cmd/dpia/dpia.go b/pkg/cmd/dpia/dpia.go index f22e57073..3b248ae47 100644 --- a/pkg/cmd/dpia/dpia.go +++ b/pkg/cmd/dpia/dpia.go @@ -20,6 +20,7 @@ import ( "go.probo.inc/probo/pkg/cmd/dpia/create" "go.probo.inc/probo/pkg/cmd/dpia/delete" "go.probo.inc/probo/pkg/cmd/dpia/list" + "go.probo.inc/probo/pkg/cmd/dpia/publish" "go.probo.inc/probo/pkg/cmd/dpia/update" "go.probo.inc/probo/pkg/cmd/dpia/view" ) @@ -35,6 +36,7 @@ func NewCmdDPIA(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(view.NewCmdView(f)) cmd.AddCommand(update.NewCmdUpdate(f)) cmd.AddCommand(delete.NewCmdDelete(f)) + cmd.AddCommand(publish.NewCmdPublish(f)) return cmd } diff --git a/pkg/cmd/dpia/publish/publish.go b/pkg/cmd/dpia/publish/publish.go new file mode 100644 index 000000000..38c960315 --- /dev/null +++ b/pkg/cmd/dpia/publish/publish.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package publish + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const publishMutation = ` +mutation($input: PublishDataProtectionImpactAssessmentListInput!) { + publishDataProtectionImpactAssessmentList(input: $input) { + documentEdge { + node { + id + status + createdAt + } + } + documentVersionEdge { + node { + id + title + major + minor + status + } + } + } +} +` + +type publishResponse struct { + PublishDataProtectionImpactAssessmentList struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + } `json:"node"` + } `json:"documentEdge"` + DocumentVersionEdge struct { + Node struct { + ID string `json:"id"` + Title string `json:"title"` + Major int `json:"major"` + Minor int `json:"minor"` + Status string `json:"status"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishDataProtectionImpactAssessmentList"` +} + +func NewCmdPublish(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagApprover []string + ) + + cmd := &cobra.Command{ + Use: "publish", + Short: "Publish the Data Protection Impact Assessment register as a document version", + Example: ` # Publish the DPIA register + prb dpia publish --org ORG_ID + + # Publish with approvers + prb dpia publish --org ORG_ID --approver PROFILE_ID1 --approver PROFILE_ID2`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + if flagOrg == "" { + flagOrg = hc.Organization + } + if flagOrg == "" { + return fmt.Errorf("organization is required: pass --org or run `prb auth login`") + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + input := map[string]any{ + "organizationId": flagOrg, + } + + if len(flagApprover) > 0 { + input["approverIds"] = flagApprover + } + + data, err := client.Do( + publishMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp publishResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + v := resp.PublishDataProtectionImpactAssessmentList.DocumentVersionEdge.Node + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Data Protection Impact Assessment register %s v%d.%d (%s)\n", + v.Title, + v.Major, + v.Minor, + v.Status, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().StringArrayVar(&flagApprover, "approver", nil, "Approver profile ID (can be repeated)") + + return cmd +} diff --git a/pkg/cmd/processing-activity/processing_activity.go b/pkg/cmd/processing-activity/processing_activity.go index 29d6d95ce..37169c28d 100644 --- a/pkg/cmd/processing-activity/processing_activity.go +++ b/pkg/cmd/processing-activity/processing_activity.go @@ -20,6 +20,7 @@ import ( "go.probo.inc/probo/pkg/cmd/processing-activity/create" "go.probo.inc/probo/pkg/cmd/processing-activity/delete" "go.probo.inc/probo/pkg/cmd/processing-activity/list" + "go.probo.inc/probo/pkg/cmd/processing-activity/publish" "go.probo.inc/probo/pkg/cmd/processing-activity/update" "go.probo.inc/probo/pkg/cmd/processing-activity/view" ) @@ -36,6 +37,7 @@ func NewCmdProcessingActivity(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(view.NewCmdView(f)) cmd.AddCommand(update.NewCmdUpdate(f)) cmd.AddCommand(delete.NewCmdDelete(f)) + cmd.AddCommand(publish.NewCmdPublish(f)) return cmd } diff --git a/pkg/cmd/processing-activity/publish/publish.go b/pkg/cmd/processing-activity/publish/publish.go new file mode 100644 index 000000000..f119574ce --- /dev/null +++ b/pkg/cmd/processing-activity/publish/publish.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package publish + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const publishMutation = ` +mutation($input: PublishProcessingActivityListInput!) { + publishProcessingActivityList(input: $input) { + documentEdge { + node { + id + status + createdAt + } + } + documentVersionEdge { + node { + id + title + major + minor + status + } + } + } +} +` + +type publishResponse struct { + PublishProcessingActivityList struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + } `json:"node"` + } `json:"documentEdge"` + DocumentVersionEdge struct { + Node struct { + ID string `json:"id"` + Title string `json:"title"` + Major int `json:"major"` + Minor int `json:"minor"` + Status string `json:"status"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishProcessingActivityList"` +} + +func NewCmdPublish(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagApprover []string + ) + + cmd := &cobra.Command{ + Use: "publish", + Short: "Publish the processing activity register as a document version", + Example: ` # Publish the processing activity register + prb processing-activity publish --org ORG_ID + + # Publish with approvers + prb processing-activity publish --org ORG_ID --approver PROFILE_ID1 --approver PROFILE_ID2`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + if flagOrg == "" { + flagOrg = hc.Organization + } + if flagOrg == "" { + return fmt.Errorf("organization is required: pass --org or run `prb auth login`") + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + input := map[string]any{ + "organizationId": flagOrg, + } + + if len(flagApprover) > 0 { + input["approverIds"] = flagApprover + } + + data, err := client.Do( + publishMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp publishResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + v := resp.PublishProcessingActivityList.DocumentVersionEdge.Node + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Processing activity register %s v%d.%d (%s)\n", + v.Title, + v.Major, + v.Minor, + v.Status, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().StringArrayVar(&flagApprover, "approver", nil, "Approver profile ID (can be repeated)") + + return cmd +} diff --git a/pkg/cmd/tia/publish/publish.go b/pkg/cmd/tia/publish/publish.go new file mode 100644 index 000000000..9146c146e --- /dev/null +++ b/pkg/cmd/tia/publish/publish.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package publish + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const publishMutation = ` +mutation($input: PublishTransferImpactAssessmentListInput!) { + publishTransferImpactAssessmentList(input: $input) { + documentEdge { + node { + id + status + createdAt + } + } + documentVersionEdge { + node { + id + title + major + minor + status + } + } + } +} +` + +type publishResponse struct { + PublishTransferImpactAssessmentList struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + } `json:"node"` + } `json:"documentEdge"` + DocumentVersionEdge struct { + Node struct { + ID string `json:"id"` + Title string `json:"title"` + Major int `json:"major"` + Minor int `json:"minor"` + Status string `json:"status"` + } `json:"node"` + } `json:"documentVersionEdge"` + } `json:"publishTransferImpactAssessmentList"` +} + +func NewCmdPublish(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagApprover []string + ) + + cmd := &cobra.Command{ + Use: "publish", + Short: "Publish the Transfer Impact Assessment register as a document version", + Example: ` # Publish the TIA register + prb tia publish --org ORG_ID + + # Publish with approvers + prb tia publish --org ORG_ID --approver PROFILE_ID1 --approver PROFILE_ID2`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + if flagOrg == "" { + flagOrg = hc.Organization + } + if flagOrg == "" { + return fmt.Errorf("organization is required: pass --org or run `prb auth login`") + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + input := map[string]any{ + "organizationId": flagOrg, + } + + if len(flagApprover) > 0 { + input["approverIds"] = flagApprover + } + + data, err := client.Do( + publishMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp publishResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + v := resp.PublishTransferImpactAssessmentList.DocumentVersionEdge.Node + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Transfer Impact Assessment register %s v%d.%d (%s)\n", + v.Title, + v.Major, + v.Minor, + v.Status, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().StringArrayVar(&flagApprover, "approver", nil, "Approver profile ID (can be repeated)") + + return cmd +} diff --git a/pkg/cmd/tia/tia.go b/pkg/cmd/tia/tia.go index ddb6e5d9f..0ee9e9773 100644 --- a/pkg/cmd/tia/tia.go +++ b/pkg/cmd/tia/tia.go @@ -20,6 +20,7 @@ import ( "go.probo.inc/probo/pkg/cmd/tia/create" "go.probo.inc/probo/pkg/cmd/tia/delete" "go.probo.inc/probo/pkg/cmd/tia/list" + "go.probo.inc/probo/pkg/cmd/tia/publish" "go.probo.inc/probo/pkg/cmd/tia/update" "go.probo.inc/probo/pkg/cmd/tia/view" ) @@ -35,6 +36,7 @@ func NewCmdTIA(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(view.NewCmdView(f)) cmd.AddCommand(update.NewCmdUpdate(f)) cmd.AddCommand(delete.NewCmdDelete(f)) + cmd.AddCommand(publish.NewCmdPublish(f)) return cmd } diff --git a/pkg/coredata/data_protection_impact_assessment.go b/pkg/coredata/data_protection_impact_assessment.go index ca554e392..042cdb004 100644 --- a/pkg/coredata/data_protection_impact_assessment.go +++ b/pkg/coredata/data_protection_impact_assessment.go @@ -28,6 +28,113 @@ import ( "go.probo.inc/probo/pkg/page" ) +func (d DataProtectionImpactAssessment) GetGeneratedDocumentID( + ctx context.Context, + conn pg.Querier, + organizationID gid.GID, +) (*gid.GID, error) { + var documentID *gid.GID + + err := conn.QueryRow( + ctx, + ` +SELECT + data_protection_impact_assessments_document_id +FROM + generated_documents +WHERE + organization_id = @organization_id +`, + pgx.NamedArgs{"organization_id": organizationID}, + ).Scan(&documentID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("cannot get DPIA list document ID: %w", err) + } + + return documentID, nil +} + +func (d DataProtectionImpactAssessment) UpsertGeneratedDocumentID( + ctx context.Context, + conn pg.Tx, + organizationID gid.GID, + tenantID gid.TenantID, + documentID gid.GID, +) error { + now := time.Now() + + _, err := conn.Exec( + ctx, + ` +INSERT INTO generated_documents ( + organization_id, + tenant_id, + data_protection_impact_assessments_document_id, + created_at, + updated_at +) VALUES ( + @organization_id, + @tenant_id, + @data_protection_impact_assessments_document_id, + @created_at, + @updated_at +) +ON CONFLICT (organization_id) DO UPDATE +SET + data_protection_impact_assessments_document_id = @data_protection_impact_assessments_document_id, + updated_at = @updated_at +`, + pgx.NamedArgs{ + "organization_id": organizationID, + "tenant_id": tenantID, + "data_protection_impact_assessments_document_id": documentID, + "created_at": now, + "updated_at": now, + }, + ) + if err != nil { + return fmt.Errorf("cannot upsert DPIA list document ID: %w", err) + } + + return nil +} + +func (d DataProtectionImpactAssessment) ClearGeneratedDocumentID( + ctx context.Context, + conn pg.Tx, + documentIDs []gid.GID, +) error { + ids := make([]string, len(documentIDs)) + for i, id := range documentIDs { + ids[i] = id.String() + } + + _, err := conn.Exec( + ctx, + ` +UPDATE + generated_documents +SET + data_protection_impact_assessments_document_id = NULL, + updated_at = @now +WHERE + data_protection_impact_assessments_document_id = ANY(@ids) +`, + pgx.NamedArgs{ + "ids": ids, + "now": time.Now(), + }, + ) + if err != nil { + return fmt.Errorf("cannot clear DPIA list document references: %w", err) + } + + return nil +} + type ( DataProtectionImpactAssessment struct { ID gid.GID `db:"id"` @@ -76,7 +183,6 @@ func (dpias *DataProtectionImpactAssessments) CountByOrganizationID( conn pg.Querier, scope Scoper, organizationID gid.GID, - filter *DataProtectionImpactAssessmentFilter, ) (int, error) { q := ` SELECT @@ -86,14 +192,13 @@ FROM WHERE %s AND organization_id = @organization_id - AND %s + AND snapshot_id IS NULL ` - q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment()) + q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.StrictNamedArgs{"organization_id": organizationID} maps.Copy(args, scope.SQLArguments()) - maps.Copy(args, filter.SQLArguments()) row := conn.QueryRow(ctx, q, args) @@ -112,7 +217,6 @@ func (dpias *DataProtectionImpactAssessments) LoadByOrganizationID( scope Scoper, organizationID gid.GID, cursor *page.Cursor[DataProtectionImpactAssessmentOrderField], - filter *DataProtectionImpactAssessmentFilter, ) error { q := ` SELECT @@ -133,15 +237,14 @@ FROM WHERE %s AND organization_id = @organization_id - AND %s + AND snapshot_id IS NULL AND %s ` - q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) args := pgx.StrictNamedArgs{"organization_id": organizationID} maps.Copy(args, scope.SQLArguments()) - maps.Copy(args, filter.SQLArguments()) maps.Copy(args, cursor.SQLArguments()) rows, err := conn.Query(ctx, q, args) @@ -164,7 +267,6 @@ func (dpias *DataProtectionImpactAssessments) LoadAllByOrganizationID( conn pg.Querier, scope Scoper, organizationID gid.GID, - filter *DataProtectionImpactAssessmentFilter, ) error { q := ` SELECT @@ -185,14 +287,13 @@ FROM WHERE %s AND organization_id = @organization_id - AND %s + AND snapshot_id IS NULL ` - q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment()) + q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.StrictNamedArgs{"organization_id": organizationID} maps.Copy(args, scope.SQLArguments()) - maps.Copy(args, filter.SQLArguments()) rows, err := conn.Query(ctx, q, args) if err != nil { @@ -438,61 +539,3 @@ WHERE return nil } - -func (dpias DataProtectionImpactAssessments) InsertProcessingActivitySnapshots( - ctx context.Context, - conn pg.Tx, - scope Scoper, - organizationID gid.GID, - snapshotID gid.GID, -) error { - query := ` -INSERT INTO processing_activity_data_protection_impact_assessments ( - id, - tenant_id, - snapshot_id, - source_id, - organization_id, - processing_activity_id, - description, - necessity_and_proportionality, - potential_risk, - mitigations, - residual_risk, - created_at, - updated_at -) -SELECT - generate_gid(decode_base64_unpadded(@tenant_id), @dpia_entity_type), - @tenant_id, - @snapshot_id, - dpia.id, - dpia.organization_id, - pa_snapshot.id, - dpia.description, - dpia.necessity_and_proportionality, - dpia.potential_risk, - dpia.mitigations, - dpia.residual_risk, - dpia.created_at, - dpia.updated_at -FROM processing_activity_data_protection_impact_assessments dpia -INNER JOIN processing_activities pa_source ON dpia.processing_activity_id = pa_source.id AND pa_source.snapshot_id IS NULL -INNER JOIN processing_activities pa_snapshot ON pa_source.id = pa_snapshot.source_id AND pa_snapshot.snapshot_id = @snapshot_id -WHERE dpia.tenant_id = @tenant_id AND dpia.organization_id = @organization_id AND dpia.snapshot_id IS NULL - ` - - args := pgx.StrictNamedArgs{ - "tenant_id": scope.GetTenantID(), - "snapshot_id": snapshotID, - "organization_id": organizationID, - "dpia_entity_type": DataProtectionImpactAssessmentEntityType, - } - - _, err := conn.Exec(ctx, query, args) - if err != nil { - return fmt.Errorf("cannot insert data protection impact assessment snapshots: %w", err) - } - - return nil -} diff --git a/pkg/coredata/data_protection_impact_assessment_filter.go b/pkg/coredata/data_protection_impact_assessment_filter.go deleted file mode 100644 index 8b6d7095f..000000000 --- a/pkg/coredata/data_protection_impact_assessment_filter.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2025-2026 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -// PERFORMANCE OF THIS SOFTWARE. - -package coredata - -import ( - "github.com/jackc/pgx/v5" - "go.probo.inc/probo/pkg/gid" -) - -type ( - DataProtectionImpactAssessmentFilter struct { - snapshotID **gid.GID - } -) - -func NewDataProtectionImpactAssessmentFilter(snapshotID **gid.GID) *DataProtectionImpactAssessmentFilter { - return &DataProtectionImpactAssessmentFilter{ - snapshotID: snapshotID, - } -} - -func (f *DataProtectionImpactAssessmentFilter) SQLArguments() pgx.NamedArgs { - args := pgx.NamedArgs{} - - if f.snapshotID != nil && *f.snapshotID != nil { - args["filter_snapshot_id"] = **f.snapshotID - } - - return args -} - -func (f *DataProtectionImpactAssessmentFilter) SQLFragment() string { - if f.snapshotID == nil { - return "TRUE" - } - - if *f.snapshotID == nil { - return "snapshot_id IS NULL" - } else { - return "snapshot_id = @filter_snapshot_id" - } -} - -func (f *DataProtectionImpactAssessmentFilter) SnapshotID() *gid.GID { - if f.snapshotID == nil || *f.snapshotID == nil { - return nil - } - return *f.snapshotID -} diff --git a/pkg/coredata/migrations/20260428T115900Z.sql b/pkg/coredata/migrations/20260428T115900Z.sql new file mode 100644 index 000000000..e956bb837 --- /dev/null +++ b/pkg/coredata/migrations/20260428T115900Z.sql @@ -0,0 +1,18 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +-- PERFORMANCE OF THIS SOFTWARE. + +ALTER TABLE generated_documents + ADD COLUMN processing_activities_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL, + ADD COLUMN data_protection_impact_assessments_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL, + ADD COLUMN transfer_impact_assessments_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL; diff --git a/pkg/coredata/processing_activities.go b/pkg/coredata/processing_activities.go index 9feb3fff5..bf0bcca6d 100644 --- a/pkg/coredata/processing_activities.go +++ b/pkg/coredata/processing_activities.go @@ -27,6 +27,113 @@ import ( "go.probo.inc/probo/pkg/page" ) +func (p ProcessingActivity) GetGeneratedDocumentID( + ctx context.Context, + conn pg.Querier, + organizationID gid.GID, +) (*gid.GID, error) { + var documentID *gid.GID + + err := conn.QueryRow( + ctx, + ` +SELECT + processing_activities_document_id +FROM + generated_documents +WHERE + organization_id = @organization_id +`, + pgx.NamedArgs{"organization_id": organizationID}, + ).Scan(&documentID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("cannot get processing activity list document ID: %w", err) + } + + return documentID, nil +} + +func (p ProcessingActivity) UpsertGeneratedDocumentID( + ctx context.Context, + conn pg.Tx, + organizationID gid.GID, + tenantID gid.TenantID, + documentID gid.GID, +) error { + now := time.Now() + + _, err := conn.Exec( + ctx, + ` +INSERT INTO generated_documents ( + organization_id, + tenant_id, + processing_activities_document_id, + created_at, + updated_at +) VALUES ( + @organization_id, + @tenant_id, + @processing_activities_document_id, + @created_at, + @updated_at +) +ON CONFLICT (organization_id) DO UPDATE +SET + processing_activities_document_id = @processing_activities_document_id, + updated_at = @updated_at +`, + pgx.NamedArgs{ + "organization_id": organizationID, + "tenant_id": tenantID, + "processing_activities_document_id": documentID, + "created_at": now, + "updated_at": now, + }, + ) + if err != nil { + return fmt.Errorf("cannot upsert processing activity list document ID: %w", err) + } + + return nil +} + +func (p ProcessingActivity) ClearGeneratedDocumentID( + ctx context.Context, + conn pg.Tx, + documentIDs []gid.GID, +) error { + ids := make([]string, len(documentIDs)) + for i, id := range documentIDs { + ids[i] = id.String() + } + + _, err := conn.Exec( + ctx, + ` +UPDATE + generated_documents +SET + processing_activities_document_id = NULL, + updated_at = @now +WHERE + processing_activities_document_id = ANY(@ids) +`, + pgx.NamedArgs{ + "ids": ids, + "now": time.Now(), + }, + ) + if err != nil { + return fmt.Errorf("cannot clear processing activity list document references: %w", err) + } + + return nil +} + type ( ProcessingActivity struct { ID gid.GID `db:"id"` @@ -150,7 +257,6 @@ func (p *ProcessingActivities) CountByOrganizationID( conn pg.Querier, scope Scoper, organizationID gid.GID, - filter *ProcessingActivityFilter, ) (int, error) { q := ` SELECT @@ -160,14 +266,13 @@ FROM WHERE %s AND organization_id = @organization_id - AND %s + AND snapshot_id IS NULL ` - q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment()) + q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.StrictNamedArgs{"organization_id": organizationID} maps.Copy(args, scope.SQLArguments()) - maps.Copy(args, filter.SQLArguments()) row := conn.QueryRow(ctx, q, args) @@ -180,13 +285,82 @@ WHERE return count, nil } +func (p *ProcessingActivities) LoadByIDs( + ctx context.Context, + conn pg.Querier, + scope Scoper, + processingActivityIDs []gid.GID, +) error { + if len(processingActivityIDs) == 0 { + *p = ProcessingActivities{} + return nil + } + + q := ` +SELECT + id, + snapshot_id, + source_id, + organization_id, + name, + purpose, + data_subject_category, + personal_data_category, + special_or_criminal_data, + consent_evidence_link, + lawful_basis, + recipients, + location, + international_transfers, + transfer_safeguards, + retention_period, + security_measures, + data_protection_impact_assessment_needed, + transfer_impact_assessment_needed, + last_review_date, + next_review_date, + role, + dpo_profile_id, + created_at, + updated_at +FROM + processing_activities +WHERE + %s + AND id = ANY(@ids) +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + ids := make([]string, len(processingActivityIDs)) + for i, id := range processingActivityIDs { + ids[i] = id.String() + } + + args := pgx.StrictNamedArgs{"ids": ids} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query processing activities: %w", err) + } + + processingActivities, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ProcessingActivity]) + if err != nil { + return fmt.Errorf("cannot collect processing activities: %w", err) + } + + *p = processingActivities + + return nil +} + func (p *ProcessingActivities) LoadByOrganizationID( ctx context.Context, conn pg.Querier, scope Scoper, organizationID gid.GID, cursor *page.Cursor[ProcessingActivityOrderField], - filter *ProcessingActivityFilter, ) error { q := ` SELECT @@ -220,15 +394,14 @@ FROM WHERE %s AND organization_id = @organization_id - AND %s + AND snapshot_id IS NULL AND %s ` - q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) args := pgx.StrictNamedArgs{"organization_id": organizationID} maps.Copy(args, scope.SQLArguments()) - maps.Copy(args, filter.SQLArguments()) maps.Copy(args, cursor.SQLArguments()) rows, err := conn.Query(ctx, q, args) @@ -251,7 +424,6 @@ func (p *ProcessingActivities) LoadAllByOrganizationID( conn pg.Querier, scope Scoper, organizationID gid.GID, - filter *ProcessingActivityFilter, ) error { q := ` SELECT @@ -285,15 +457,14 @@ FROM WHERE %s AND organization_id = @organization_id - AND %s + AND snapshot_id IS NULL ORDER BY created_at DESC ` - q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment()) + q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.StrictNamedArgs{"organization_id": organizationID} maps.Copy(args, scope.SQLArguments()) - maps.Copy(args, filter.SQLArguments()) rows, err := conn.Query(ctx, q, args) if err != nil { @@ -504,100 +675,3 @@ WHERE return nil } - -func (pas ProcessingActivities) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error { - snapshotters := []ProcessingActivitySnapshotter{ProcessingActivities{}, Vendors{}, ProcessingActivityVendors{}, DataProtectionImpactAssessments{}, TransferImpactAssessments{}} - - for _, snapshotter := range snapshotters { - if err := snapshotter.InsertProcessingActivitySnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil { - return fmt.Errorf("cannot create processing activity snapshots: (%T) %w", snapshotter, err) - } - } - - return nil -} - -func (pas ProcessingActivities) InsertProcessingActivitySnapshots( - ctx context.Context, - conn pg.Tx, - scope Scoper, - organizationID gid.GID, - snapshotID gid.GID, -) error { - query := ` -INSERT INTO processing_activities ( - id, - tenant_id, - snapshot_id, - source_id, - organization_id, - name, - purpose, - data_subject_category, - personal_data_category, - special_or_criminal_data, - consent_evidence_link, - lawful_basis, - recipients, - location, - international_transfers, - transfer_safeguards, - retention_period, - security_measures, - data_protection_impact_assessment_needed, - transfer_impact_assessment_needed, - last_review_date, - next_review_date, - role, - dpo_profile_id, - created_at, - updated_at -) -SELECT - generate_gid(decode_base64_unpadded(@tenant_id), @processing_activity_entity_type), - @tenant_id, - @snapshot_id, - par.id, - par.organization_id, - par.name, - par.purpose, - par.data_subject_category, - par.personal_data_category, - par.special_or_criminal_data, - par.consent_evidence_link, - par.lawful_basis, - par.recipients, - par.location, - par.international_transfers, - par.transfer_safeguards, - par.retention_period, - par.security_measures, - par.data_protection_impact_assessment_needed, - par.transfer_impact_assessment_needed, - par.last_review_date, - par.next_review_date, - par.role, - par.dpo_profile_id, - par.created_at, - par.updated_at -FROM processing_activities par -WHERE %s AND par.organization_id = @organization_id AND par.snapshot_id IS NULL - ` - - query = fmt.Sprintf(query, scope.SQLFragment()) - - args := pgx.StrictNamedArgs{ - "tenant_id": scope.GetTenantID(), - "snapshot_id": snapshotID, - "organization_id": organizationID, - "processing_activity_entity_type": ProcessingActivityEntityType, - } - maps.Copy(args, scope.SQLArguments()) - - _, err := conn.Exec(ctx, query, args) - if err != nil { - return fmt.Errorf("cannot insert processing activity snapshots: %w", err) - } - - return nil -} diff --git a/pkg/coredata/processing_activity_filter.go b/pkg/coredata/processing_activity_filter.go deleted file mode 100644 index 61748f4bb..000000000 --- a/pkg/coredata/processing_activity_filter.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2025-2026 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -// PERFORMANCE OF THIS SOFTWARE. - -package coredata - -import ( - "github.com/jackc/pgx/v5" - "go.probo.inc/probo/pkg/gid" -) - -type ( - ProcessingActivityFilter struct { - snapshotID **gid.GID - } -) - -func NewProcessingActivityFilter(snapshotID **gid.GID) *ProcessingActivityFilter { - return &ProcessingActivityFilter{ - snapshotID: snapshotID, - } -} - -func (f *ProcessingActivityFilter) SQLArguments() pgx.NamedArgs { - args := pgx.NamedArgs{} - - if f.snapshotID != nil && *f.snapshotID != nil { - args["filter_snapshot_id"] = **f.snapshotID - } - - return args -} - -func (f *ProcessingActivityFilter) SQLFragment() string { - if f.snapshotID == nil { - return "TRUE" - } - - if *f.snapshotID == nil { - return "snapshot_id IS NULL" - } else { - return "snapshot_id = @filter_snapshot_id" - } -} - -func (f *ProcessingActivityFilter) SnapshotID() *gid.GID { - if f.snapshotID == nil || *f.snapshotID == nil { - return nil - } - return *f.snapshotID -} diff --git a/pkg/coredata/processing_activity_vendor.go b/pkg/coredata/processing_activity_vendor.go index baedbcdbc..a4173419f 100644 --- a/pkg/coredata/processing_activity_vendor.go +++ b/pkg/coredata/processing_activity_vendor.go @@ -17,7 +17,6 @@ package coredata import ( "context" "fmt" - "maps" "time" "github.com/jackc/pgx/v5" @@ -35,10 +34,6 @@ type ( } ProcessingActivityVendors []*ProcessingActivityVendor - - ProcessingActivitySnapshotter interface { - InsertProcessingActivitySnapshots(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error - } ) func (pav ProcessingActivityVendors) Merge( @@ -124,61 +119,3 @@ FROM vendor_ids return nil } - -func (pav ProcessingActivityVendors) InsertProcessingActivitySnapshots( - ctx context.Context, - conn pg.Tx, - scope Scoper, - organizationID gid.GID, - snapshotID gid.GID, -) error { - query := ` -WITH - source_processing_activities AS ( - SELECT id - FROM processing_activities - WHERE organization_id = @organization_id AND snapshot_id IS NULL - ), - snapshot_processing_activities AS ( - SELECT id, source_id - FROM processing_activities - WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id - ), - snapshot_vendors AS ( - SELECT id, source_id - FROM vendors - WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id - ), - source_processing_activity_vendors AS ( - SELECT processing_activity_id, vendor_id, snapshot_id, created_at - FROM processing_activity_vendors - WHERE %s AND processing_activity_id = ANY(SELECT id FROM source_processing_activities) AND snapshot_id IS NULL - ) -INSERT INTO processing_activity_vendors (tenant_id, processing_activity_id, vendor_id, organization_id, snapshot_id, created_at) -SELECT - @tenant_id, - spa.id, - sv.id, - @organization_id, - @snapshot_id, - pav.created_at -FROM source_processing_activity_vendors pav -JOIN snapshot_processing_activities spa ON spa.source_id = pav.processing_activity_id -JOIN snapshot_vendors sv ON sv.source_id = pav.vendor_id -` - - query = fmt.Sprintf(query, scope.SQLFragment()) - - args := pgx.StrictNamedArgs{ - "snapshot_id": snapshotID, - "organization_id": organizationID, - } - maps.Copy(args, scope.SQLArguments()) - - _, err := conn.Exec(ctx, query, args) - if err != nil { - return fmt.Errorf("cannot insert processing activity vendor snapshots: %w", err) - } - - return nil -} diff --git a/pkg/coredata/snapshots_type.go b/pkg/coredata/snapshots_type.go index eaaf457a6..f30b3ccca 100644 --- a/pkg/coredata/snapshots_type.go +++ b/pkg/coredata/snapshots_type.go @@ -38,7 +38,6 @@ func SnapshotsTypes() []SnapshotsType { return []SnapshotsType{ SnapshotsTypeRisks, SnapshotsTypeVendors, - SnapshotsTypeProcessingActivities, } } diff --git a/pkg/coredata/snapshottable.go b/pkg/coredata/snapshottable.go index 8436d17c1..01ffbbb20 100644 --- a/pkg/coredata/snapshottable.go +++ b/pkg/coredata/snapshottable.go @@ -30,8 +30,6 @@ func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) { switch snapshotType { case SnapshotsTypeRisks: return Risks{}, nil - case SnapshotsTypeProcessingActivities: - return ProcessingActivities{}, nil case SnapshotsTypeVendors: return Vendors{}, nil default: diff --git a/pkg/coredata/transfer_impact_assessment.go b/pkg/coredata/transfer_impact_assessment.go index 044938846..5fbc54ed5 100644 --- a/pkg/coredata/transfer_impact_assessment.go +++ b/pkg/coredata/transfer_impact_assessment.go @@ -28,6 +28,113 @@ import ( "go.probo.inc/probo/pkg/page" ) +func (t TransferImpactAssessment) GetGeneratedDocumentID( + ctx context.Context, + conn pg.Querier, + organizationID gid.GID, +) (*gid.GID, error) { + var documentID *gid.GID + + err := conn.QueryRow( + ctx, + ` +SELECT + transfer_impact_assessments_document_id +FROM + generated_documents +WHERE + organization_id = @organization_id +`, + pgx.NamedArgs{"organization_id": organizationID}, + ).Scan(&documentID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("cannot get TIA list document ID: %w", err) + } + + return documentID, nil +} + +func (t TransferImpactAssessment) UpsertGeneratedDocumentID( + ctx context.Context, + conn pg.Tx, + organizationID gid.GID, + tenantID gid.TenantID, + documentID gid.GID, +) error { + now := time.Now() + + _, err := conn.Exec( + ctx, + ` +INSERT INTO generated_documents ( + organization_id, + tenant_id, + transfer_impact_assessments_document_id, + created_at, + updated_at +) VALUES ( + @organization_id, + @tenant_id, + @transfer_impact_assessments_document_id, + @created_at, + @updated_at +) +ON CONFLICT (organization_id) DO UPDATE +SET + transfer_impact_assessments_document_id = @transfer_impact_assessments_document_id, + updated_at = @updated_at +`, + pgx.NamedArgs{ + "organization_id": organizationID, + "tenant_id": tenantID, + "transfer_impact_assessments_document_id": documentID, + "created_at": now, + "updated_at": now, + }, + ) + if err != nil { + return fmt.Errorf("cannot upsert TIA list document ID: %w", err) + } + + return nil +} + +func (t TransferImpactAssessment) ClearGeneratedDocumentID( + ctx context.Context, + conn pg.Tx, + documentIDs []gid.GID, +) error { + ids := make([]string, len(documentIDs)) + for i, id := range documentIDs { + ids[i] = id.String() + } + + _, err := conn.Exec( + ctx, + ` +UPDATE + generated_documents +SET + transfer_impact_assessments_document_id = NULL, + updated_at = @now +WHERE + transfer_impact_assessments_document_id = ANY(@ids) +`, + pgx.NamedArgs{ + "ids": ids, + "now": time.Now(), + }, + ) + if err != nil { + return fmt.Errorf("cannot clear TIA list document references: %w", err) + } + + return nil +} + type ( TransferImpactAssessment struct { ID gid.GID `db:"id"` @@ -76,7 +183,6 @@ func (tias *TransferImpactAssessments) CountByOrganizationID( conn pg.Querier, scope Scoper, organizationID gid.GID, - filter *TransferImpactAssessmentFilter, ) (int, error) { q := ` SELECT @@ -86,14 +192,13 @@ FROM WHERE %s AND organization_id = @organization_id - AND %s + AND snapshot_id IS NULL ` - q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment()) + q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.StrictNamedArgs{"organization_id": organizationID} maps.Copy(args, scope.SQLArguments()) - maps.Copy(args, filter.SQLArguments()) row := conn.QueryRow(ctx, q, args) @@ -112,7 +217,6 @@ func (tias *TransferImpactAssessments) LoadByOrganizationID( scope Scoper, organizationID gid.GID, cursor *page.Cursor[TransferImpactAssessmentOrderField], - filter *TransferImpactAssessmentFilter, ) error { q := ` SELECT @@ -133,15 +237,14 @@ FROM WHERE %s AND organization_id = @organization_id - AND %s + AND snapshot_id IS NULL AND %s ` - q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) args := pgx.StrictNamedArgs{"organization_id": organizationID} maps.Copy(args, scope.SQLArguments()) - maps.Copy(args, filter.SQLArguments()) maps.Copy(args, cursor.SQLArguments()) rows, err := conn.Query(ctx, q, args) @@ -164,7 +267,6 @@ func (tias *TransferImpactAssessments) LoadAllByOrganizationID( conn pg.Querier, scope Scoper, organizationID gid.GID, - filter *TransferImpactAssessmentFilter, ) error { q := ` SELECT @@ -185,14 +287,13 @@ FROM WHERE %s AND organization_id = @organization_id - AND %s + AND snapshot_id IS NULL ` - q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment()) + q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.StrictNamedArgs{"organization_id": organizationID} maps.Copy(args, scope.SQLArguments()) - maps.Copy(args, filter.SQLArguments()) rows, err := conn.Query(ctx, q, args) if err != nil { @@ -436,61 +537,3 @@ WHERE return nil } - -func (tias TransferImpactAssessments) InsertProcessingActivitySnapshots( - ctx context.Context, - conn pg.Tx, - scope Scoper, - organizationID gid.GID, - snapshotID gid.GID, -) error { - query := ` -INSERT INTO processing_activity_transfer_impact_assessments ( - id, - tenant_id, - snapshot_id, - source_id, - organization_id, - processing_activity_id, - data_subjects, - legal_mechanism, - transfer, - local_law_risk, - supplementary_measures, - created_at, - updated_at -) -SELECT - generate_gid(decode_base64_unpadded(@tenant_id), @tia_entity_type), - @tenant_id, - @snapshot_id, - tia.id, - tia.organization_id, - pa_snapshot.id, - tia.data_subjects, - tia.legal_mechanism, - tia.transfer, - tia.local_law_risk, - tia.supplementary_measures, - tia.created_at, - tia.updated_at -FROM processing_activity_transfer_impact_assessments tia -INNER JOIN processing_activities pa_source ON tia.processing_activity_id = pa_source.id AND pa_source.snapshot_id IS NULL -INNER JOIN processing_activities pa_snapshot ON pa_source.id = pa_snapshot.source_id AND pa_snapshot.snapshot_id = @snapshot_id -WHERE tia.tenant_id = @tenant_id AND tia.organization_id = @organization_id AND tia.snapshot_id IS NULL - ` - - args := pgx.StrictNamedArgs{ - "tenant_id": scope.GetTenantID(), - "snapshot_id": snapshotID, - "organization_id": organizationID, - "tia_entity_type": TransferImpactAssessmentEntityType, - } - - _, err := conn.Exec(ctx, query, args) - if err != nil { - return fmt.Errorf("cannot insert transfer impact assessment snapshots: %w", err) - } - - return nil -} diff --git a/pkg/coredata/transfer_impact_assessment_filter.go b/pkg/coredata/transfer_impact_assessment_filter.go deleted file mode 100644 index 5aa500a1f..000000000 --- a/pkg/coredata/transfer_impact_assessment_filter.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2025-2026 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -// PERFORMANCE OF THIS SOFTWARE. - -package coredata - -import ( - "github.com/jackc/pgx/v5" - "go.probo.inc/probo/pkg/gid" -) - -type ( - TransferImpactAssessmentFilter struct { - snapshotID **gid.GID - } -) - -func NewTransferImpactAssessmentFilter(snapshotID **gid.GID) *TransferImpactAssessmentFilter { - return &TransferImpactAssessmentFilter{ - snapshotID: snapshotID, - } -} - -func (f *TransferImpactAssessmentFilter) SQLArguments() pgx.NamedArgs { - args := pgx.NamedArgs{} - - if f.snapshotID != nil && *f.snapshotID != nil { - args["filter_snapshot_id"] = **f.snapshotID - } - - return args -} - -func (f *TransferImpactAssessmentFilter) SQLFragment() string { - if f.snapshotID == nil { - return "TRUE" - } - - if *f.snapshotID == nil { - return "snapshot_id IS NULL" - } else { - return "snapshot_id = @filter_snapshot_id" - } -} - -func (f *TransferImpactAssessmentFilter) SnapshotID() *gid.GID { - if f.snapshotID == nil || *f.snapshotID == nil { - return nil - } - return *f.snapshotID -} diff --git a/pkg/coredata/vendor.go b/pkg/coredata/vendor.go index 9b77ddaba..360615f63 100644 --- a/pkg/coredata/vendor.go +++ b/pkg/coredata/vendor.go @@ -1014,7 +1014,6 @@ func (v *Vendors) LoadAllByProcessingActivities( conn pg.Querier, scope Scoper, organizationID gid.GID, - filter *ProcessingActivityFilter, ) (map[gid.GID][]string, error) { q := ` WITH filtered_processing_activities AS ( @@ -1025,7 +1024,7 @@ WITH filtered_processing_activities AS ( WHERE pa.tenant_id = @tenant_id AND pa.organization_id = @organization_id - AND %s + AND pa.snapshot_id IS NULL ), filtered_vendors AS ( SELECT @@ -1050,13 +1049,11 @@ WHERE ORDER BY pav.processing_activity_id, fv.name ` - q = fmt.Sprintf(q, filter.SQLFragment()) args := pgx.StrictNamedArgs{ "organization_id": organizationID, } maps.Copy(args, scope.SQLArguments()) - maps.Copy(args, filter.SQLArguments()) rows, err := conn.Query(ctx, q, args) if err != nil { @@ -1173,108 +1170,6 @@ ORDER BY name ASC return nil } -func (vs Vendors) InsertProcessingActivitySnapshots( - ctx context.Context, - conn pg.Tx, - scope Scoper, - organizationID gid.GID, - snapshotID gid.GID, -) error { - query := ` -WITH - source_processing_activities AS ( - SELECT id - FROM processing_activities - WHERE organization_id = @organization_id AND snapshot_id IS NULL - ), - source_processing_activity_vendors AS ( - SELECT processing_activity_id, vendor_id, snapshot_id, created_at - FROM processing_activity_vendors - WHERE processing_activity_id = ANY(SELECT id FROM source_processing_activities) - ), - source_vendors AS ( - SELECT * - FROM vendors - WHERE %s AND id = ANY(SELECT vendor_id FROM source_processing_activity_vendors) - ) -INSERT INTO vendors ( - tenant_id, - id, - snapshot_id, - source_id, - organization_id, - name, - description, - category, - headquarter_address, - legal_name, - website_url, - privacy_policy_url, - service_level_agreement_url, - data_processing_agreement_url, - business_associate_agreement_url, - subprocessors_list_url, - certifications, - countries, - business_owner_profile_id, - security_owner_profile_id, - status_page_url, - terms_of_service_url, - security_page_url, - trust_page_url, - show_on_trust_center, - created_at, - updated_at -) -SELECT - @tenant_id, - generate_gid(decode_base64_unpadded(@tenant_id), @vendor_entity_type), - @snapshot_id, - v.id, - v.organization_id, - v.name, - v.description, - v.category, - v.headquarter_address, - v.legal_name, - v.website_url, - v.privacy_policy_url, - v.service_level_agreement_url, - v.data_processing_agreement_url, - v.business_associate_agreement_url, - v.subprocessors_list_url, - v.certifications, - v.countries, - v.business_owner_profile_id, - v.security_owner_profile_id, - v.status_page_url, - v.terms_of_service_url, - v.security_page_url, - v.trust_page_url, - v.show_on_trust_center, - v.created_at, - v.updated_at -FROM source_vendors v - ` - - query = fmt.Sprintf(query, scope.SQLFragment()) - - args := pgx.StrictNamedArgs{ - "tenant_id": scope.GetTenantID(), - "snapshot_id": snapshotID, - "organization_id": organizationID, - "vendor_entity_type": VendorEntityType, - } - maps.Copy(args, scope.SQLArguments()) - - _, err := conn.Exec(ctx, query, args) - if err != nil { - return fmt.Errorf("cannot insert vendor snapshots for processing activities: %w", err) - } - - return nil -} - func (v Vendors) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error { for _, snapshotter := range []VendorSnapshotter{ Vendors{}, diff --git a/pkg/docgen/data_protection_impact_assessments_template.html b/pkg/docgen/data_protection_impact_assessments_template.html deleted file mode 100644 index 9572b2476..000000000 --- a/pkg/docgen/data_protection_impact_assessments_template.html +++ /dev/null @@ -1,316 +0,0 @@ - - - - - - Data Protection Impact Assessments Export - - - -
-
- {{- if .CompanyHorizontalLogoBase64}} - {{imgTag .CompanyHorizontalLogoBase64 "Company Logo" "company-logo"}} - {{- else}} -
{{.CompanyName}}
- {{- end}} -
- -

Data Protection Impact Assessments

- -
- - - - - - - - - - - - - -
Classification - CONFIDENTIAL -
Version{{.Version}}
Published{{.PublishedAt.Format "January 2, 2006"}}
-
- -
-
1. Purpose
-
- This document contains Data Protection Impact Assessments (DPIAs) conducted for processing activities - that present a high risk to individuals' rights and freedoms. DPIAs are systematic assessments that - evaluate the necessity, proportionality, and risks associated with data processing operations, along - with the measures implemented to mitigate identified risks. -
-
-
- - {{- range $index, $assessment := .Assessments}} -
- {{if eq $index 0}} -

2. Records

- {{end}} -

2.{{add $index 1}} {{$assessment.ProcessingActivityName}}

- -
-
Description
-
{{if .Description}}{{.Description}}{{else}}Not specified{{end}}
-
- -
-
Necessity and Proportionality
-
{{if .NecessityAndProportionality}}{{.NecessityAndProportionality}}{{else}}Not specified{{end}}
-
- -
-
Potential Risk
-
{{if .PotentialRisk}}{{.PotentialRisk}}{{else}}Not specified{{end}}
-
- -
-
Mitigations
-
{{if .Mitigations}}{{.Mitigations}}{{else}}Not specified{{end}}
-
- -
-
Residual Risk
-
{{if .ResidualRisk}}{{.ResidualRisk | formatResidualRisk}}{{else}}Not specified{{end}}
-
-
- {{- end}} - -
-

3. Annexes

- -
-
3.1 Lexicon
-
- -
-
Residual Risk
-
    -
  • - Low: - The residual risk after implementing mitigation measures is considered low. The processing activity poses minimal risk to individuals' rights and freedoms. -
  • -
  • - Medium: - The residual risk after implementing mitigation measures is considered medium. The processing activity poses a moderate risk to individuals' rights and freedoms, requiring ongoing monitoring. -
  • -
  • - High: - The residual risk after implementing mitigation measures is considered high. The processing activity poses significant risk to individuals' rights and freedoms, requiring enhanced safeguards and regular review. -
  • -
-
-
- - diff --git a/pkg/docgen/generator.go b/pkg/docgen/generator.go index 6fc086533..f5c1d71b9 100644 --- a/pkg/docgen/generator.go +++ b/pkg/docgen/generator.go @@ -35,15 +35,6 @@ var ( //go:embed template.html htmlTemplateContent string - //go:embed processing_activities_template.html - processingActivitiesTemplateContent string - - //go:embed data_protection_impact_assessments_template.html - dataProtectionImpactAssessmentsTemplateContent string - - //go:embed transfer_impact_assessments_template.html - transferImpactAssessmentsTemplateContent string - //go:embed signature_page_template.html signaturePageTemplateContent string @@ -181,12 +172,6 @@ var ( documentTemplate = template.Must(template.New("document").Funcs(templateFuncs).Parse(htmlTemplateContent)) - processingActivitiesTemplate = template.Must(template.New("processingActivities").Funcs(templateFuncs).Parse(processingActivitiesTemplateContent)) - - dataProtectionImpactAssessmentsTemplate = template.Must(template.New("dataProtectionImpactAssessments").Funcs(templateFuncs).Parse(dataProtectionImpactAssessmentsTemplateContent)) - - transferImpactAssessmentsTemplate = template.Must(template.New("transferImpactAssessments").Funcs(templateFuncs).Parse(transferImpactAssessmentsTemplateContent)) - signaturePageTemplate = template.Must(template.New("signaturePage").Funcs(templateFuncs).Parse(signaturePageTemplateContent)) ) @@ -220,71 +205,6 @@ type ( Landscape bool } - ProcessingActivityTableData struct { - CompanyName string - CompanyHorizontalLogoBase64 string - Version int - PublishedAt time.Time - Activities []ProcessingActivityRowData - } - - ProcessingActivityRowData struct { - Name string - Purpose *string - DataSubjectCategory *string - PersonalDataCategory *string - SpecialOrCriminalData coredata.ProcessingActivitySpecialOrCriminalDatum - ConsentEvidenceLink *string - LawfulBasis coredata.ProcessingActivityLawfulBasis - Recipients *string - Location *string - InternationalTransfers bool - TransferSafeguards *coredata.ProcessingActivityTransferSafeguard - RetentionPeriod *string - SecurityMeasures *string - DataProtectionImpactAssessmentNeeded coredata.ProcessingActivityDataProtectionImpactAssessment - TransferImpactAssessmentNeeded coredata.ProcessingActivityTransferImpactAssessment - LastReviewDate *time.Time - NextReviewDate *time.Time - Role coredata.ProcessingActivityRole - DataProtectionOfficerFullName *string - Vendors string - } - - DataProtectionImpactAssessmentTableData struct { - CompanyName string - CompanyHorizontalLogoBase64 string - Version int - PublishedAt time.Time - Assessments []DataProtectionImpactAssessmentRowData - } - - DataProtectionImpactAssessmentRowData struct { - ProcessingActivityName string - Description *string - NecessityAndProportionality *string - PotentialRisk *string - Mitigations *string - ResidualRisk *coredata.DataProtectionImpactAssessmentResidualRisk - } - - TransferImpactAssessmentTableData struct { - CompanyName string - CompanyHorizontalLogoBase64 string - Version int - PublishedAt time.Time - Assessments []TransferImpactAssessmentRowData - } - - TransferImpactAssessmentRowData struct { - ProcessingActivityName string - DataSubjects *string - LegalMechanism *string - Transfer *string - LocalLawRisk *string - SupplementaryMeasures *string - } - StatementOfApplicabilityData struct { Title string OrganizationName string @@ -381,6 +301,71 @@ type ( Owner string DueDate string } + + ProcessingActivityListData struct { + Title string + OrganizationName string + CreatedAt time.Time + TotalProcessingActivities int + Rows []ProcessingActivityListRow + } + + ProcessingActivityListRow struct { + Name string + Purpose string + Role string + DataSubjectCategory string + PersonalDataCategory string + SpecialOrCriminalData string + LawfulBasis string + ConsentEvidenceLink string + Recipients string + Location string + InternationalTransfers string + TransferSafeguards string + RetentionPeriod string + SecurityMeasures string + DataProtectionImpactAssessmentNeeded string + TransferImpactAssessmentNeeded string + LastReviewDate string + NextReviewDate string + DataProtectionOfficer string + Vendors string + } + + DataProtectionImpactAssessmentListData struct { + Title string + OrganizationName string + CreatedAt time.Time + TotalDataProtectionImpactAssessments int + Rows []DataProtectionImpactAssessmentListRow + } + + DataProtectionImpactAssessmentListRow struct { + ProcessingActivityName string + Description string + NecessityAndProportionality string + PotentialRisk string + Mitigations string + ResidualRisk string + } + + TransferImpactAssessmentListData struct { + Title string + OrganizationName string + CreatedAt time.Time + TotalTransferImpactAssessments int + Rows []TransferImpactAssessmentListRow + } + + TransferImpactAssessmentListRow struct { + ProcessingActivityName string + DataSubjects string + Transfer string + LegalMechanism string + LocalLawRisk string + SupplementaryMeasures string + } ) func BoolLabel(v bool) string { @@ -460,30 +445,3 @@ func RenderSignaturePageHTML(data SignaturePageData) ([]byte, error) { return buf.Bytes(), nil } - -func RenderProcessingActivitiesTableHTML(data ProcessingActivityTableData) ([]byte, error) { - var buf bytes.Buffer - if err := processingActivitiesTemplate.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("cannot execute processing activities template: %w", err) - } - - return buf.Bytes(), nil -} - -func RenderDataProtectionImpactAssessmentsTableHTML(data DataProtectionImpactAssessmentTableData) ([]byte, error) { - var buf bytes.Buffer - if err := dataProtectionImpactAssessmentsTemplate.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("cannot execute data protection impact assessments template: %w", err) - } - - return buf.Bytes(), nil -} - -func RenderTransferImpactAssessmentsTableHTML(data TransferImpactAssessmentTableData) ([]byte, error) { - var buf bytes.Buffer - if err := transferImpactAssessmentsTemplate.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("cannot execute transfer impact assessments template: %w", err) - } - - return buf.Bytes(), nil -} diff --git a/pkg/docgen/processing_activities_template.html b/pkg/docgen/processing_activities_template.html deleted file mode 100644 index fedeb4bb4..000000000 --- a/pkg/docgen/processing_activities_template.html +++ /dev/null @@ -1,536 +0,0 @@ - - - - - - Processing Activities Export - - - -
-
- {{- if .CompanyHorizontalLogoBase64}} - {{imgTag .CompanyHorizontalLogoBase64 "Company Logo" "company-logo"}} - {{- else}} -
{{.CompanyName}}
- {{- end}} -
- -

Processing Activities

- -
- - - - - - - - - - - - - -
Classification - CONFIDENTIAL -
Version{{.Version}}
Published{{.PublishedAt.Format "January 2, 2006"}}
-
- -
-
1. Purpose
-
- This document provides a comprehensive overview of all processing activities within the organization. - It serves as a record of personal data processing operations, documenting the purposes, legal bases, - data categories, and associated safeguards for each activity. -
-
-
- - {{- range $index, $activity := .Activities}} -
- {{if eq $index 0}} -

2. Records

- {{end}} -

2.{{add $index 1}} {{$activity.Name}}

- -
-
2.{{add $index 1}}.1 General Information
-
-
Purpose
-
{{if .Purpose}}{{.Purpose}}{{else}}Not specified{{end}}
-
-
-
-
Role
-
{{.Role | formatRole}}
-
-
-
- -
-
2.{{add $index 1}}.2 Data Categories
-
-
-
Data Subject Category
-
{{if .DataSubjectCategory}}{{.DataSubjectCategory}}{{else}}Not specified{{end}}
-
-
-
Personal Data Category
-
{{if .PersonalDataCategory}}{{.PersonalDataCategory}}{{else}}Not specified{{end}}
-
-
-
Special/Criminal Data
-
{{.SpecialOrCriminalData | formatSpecialOrCriminalData}}
-
-
-
- -
-
2.{{add $index 1}}.3 Legal Basis
-
-
-
Lawful Basis
-
{{.LawfulBasis | formatLawfulBasis}}
-
-
-
Consent Evidence Link
-
{{if .ConsentEvidenceLink}}{{.ConsentEvidenceLink}}{{else}}Not specified{{end}}
-
-
-
- -
-
2.{{add $index 1}}.4 Data Sharing & Transfers
-
-
-
Recipients
-
{{if .Recipients}}{{.Recipients}}{{else}}Not specified{{end}}
-
-
-
Location
-
{{if .Location}}{{.Location}}{{else}}Not specified{{end}}
-
-
-
International Transfers
-
{{if .InternationalTransfers}}Yes{{else}}No{{end}}
-
-
-
Transfer Safeguards
-
{{if .TransferSafeguards}}{{.TransferSafeguards | formatTransferSafeguard}}{{else}}Not specified{{end}}
-
-
-
- -
-
2.{{add $index 1}}.5 Retention & Security
-
-
-
Retention Period
-
{{if .RetentionPeriod}}{{.RetentionPeriod}}{{else}}Not specified{{end}}
-
-
-
-
Security Measures
-
{{if .SecurityMeasures}}{{.SecurityMeasures}}{{else}}Not specified{{end}}
-
-
- -
-
2.{{add $index 1}}.6 Assessments & Reviews
-
-
-
DPIA Needed
-
{{.DataProtectionImpactAssessmentNeeded | formatDPIANeeded}}
-
-
-
TIA Needed
-
{{.TransferImpactAssessmentNeeded | formatTIANeeded}}
-
-
-
Last Review Date
-
{{if .LastReviewDate}}{{.LastReviewDate.Format "January 2, 2006"}}{{else}}Not specified{{end}}
-
-
-
Next Review Date
-
{{if .NextReviewDate}}{{.NextReviewDate.Format "January 2, 2006"}}{{else}}Not specified{{end}}
-
-
-
- -
-
2.{{add $index 1}}.7 Responsible Parties
-
-
-
Data Protection Officer
-
{{if .DataProtectionOfficerFullName}}{{.DataProtectionOfficerFullName}}{{else}}Not assigned{{end}}
-
-
-
Vendors
-
{{if .Vendors}}{{.Vendors}}{{else}}None{{end}}
-
-
-
-
- {{- end}} - -
-

3. Annexes

- -
-
3.1 Lexicon
-
- -
-
Role
-
    -
  • - Controller: - The entity that determines the purposes and means of processing personal data. -
  • -
  • - Processor: - The entity that processes personal data on behalf of the controller. -
  • -
-
- -
-
Lawful Basis
-
    -
  • - Consent: - The data subject has given consent to the processing of their personal data. -
  • -
  • - Contractual Necessity: - Processing is necessary for the performance of a contract to which the data subject is party. -
  • -
  • - Legal Obligation: - Processing is necessary for compliance with a legal obligation to which the controller is subject. -
  • -
  • - Legitimate Interest: - Processing is necessary for the purposes of the legitimate interests pursued by the controller or a third party. -
  • -
  • - Public Task: - Processing is necessary for the performance of a task carried out in the public interest or in the exercise of official authority. -
  • -
  • - Vital Interests: - Processing is necessary to protect the vital interests of the data subject or of another natural person. -
  • -
-
- -
-
Special/Criminal Data
-
    -
  • - Yes: - The processing activity involves special categories of personal data or data relating to criminal convictions and offences. -
  • -
  • - No: - The processing activity does not involve special categories of personal data or data relating to criminal convictions and offences. -
  • -
  • - Possible: - The processing activity may involve special categories of personal data or data relating to criminal convictions and offences. -
  • -
-
- -
-
Transfer Safeguards
-
    -
  • - Standard Contractual Clauses: - Standard contractual clauses approved by the European Commission are used to ensure adequate protection for international transfers. -
  • -
  • - Binding Corporate Rules: - Binding corporate rules approved by a supervisory authority are used to ensure adequate protection for international transfers. -
  • -
  • - Adequacy Decision: - The European Commission has determined that the third country ensures an adequate level of protection. -
  • -
  • - Derogations: - A derogation under Article 49 of the GDPR is used for the international transfer. -
  • -
  • - Codes of Conduct: - An approved code of conduct together with binding and enforceable commitments is used to ensure adequate protection. -
  • -
  • - Certification Mechanisms: - An approved certification mechanism together with binding and enforceable commitments is used to ensure adequate protection. -
  • -
-
- -
-
DPIA Needed
-
    -
  • - Yes: - A Data Protection Impact Assessment is required for this processing activity. -
  • -
  • - No: - A Data Protection Impact Assessment is not required for this processing activity. -
  • -
-
- -
-
TIA Needed
-
    -
  • - Yes: - A Transfer Impact Assessment is required for this processing activity. -
  • -
  • - No: - A Transfer Impact Assessment is not required for this processing activity. -
  • -
-
-
- - diff --git a/pkg/docgen/transfer_impact_assessments_template.html b/pkg/docgen/transfer_impact_assessments_template.html deleted file mode 100644 index 52409312a..000000000 --- a/pkg/docgen/transfer_impact_assessments_template.html +++ /dev/null @@ -1,289 +0,0 @@ - - - - - - Transfer Impact Assessments Export - - - -
-
- {{- if .CompanyHorizontalLogoBase64}} - {{imgTag .CompanyHorizontalLogoBase64 "Company Logo" "company-logo"}} - {{- else}} -
{{.CompanyName}}
- {{- end}} -
- -

Transfer Impact Assessments

- -
- - - - - - - - - - - - - -
Classification - CONFIDENTIAL -
Version{{.Version}}
Published{{.PublishedAt.Format "January 2, 2006"}}
-
- -
-
1. Purpose
-
- This document contains Transfer Impact Assessments (TIAs) conducted for processing activities involving - international transfers of personal data to countries outside the European Economic Area (EEA). TIAs - evaluate the legal mechanisms used for transfers, assess risks related to local laws in destination - countries, and document supplementary measures implemented to ensure an adequate level of data protection. -
-
-
- - {{- range $index, $assessment := .Assessments}} -
- {{if eq $index 0}} -

2. Records

- {{end}} -

2.{{add $index 1}} {{$assessment.ProcessingActivityName}}

- -
-
Data Subjects
-
{{if .DataSubjects}}{{.DataSubjects}}{{else}}Not specified{{end}}
-
- -
-
Transfer
-
{{if .Transfer}}{{.Transfer}}{{else}}Not specified{{end}}
-
- -
-
Legal Mechanism
-
{{if .LegalMechanism}}{{.LegalMechanism}}{{else}}Not specified{{end}}
-
- -
-
Local Law Risk
-
{{if .LocalLawRisk}}{{.LocalLawRisk}}{{else}}Not specified{{end}}
-
- -
-
Supplementary Measures
-
{{if .SupplementaryMeasures}}{{.SupplementaryMeasures}}{{else}}Not specified{{end}}
-
-
- {{- end}} - - diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index 4a6a1fc84..45ffd8064 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -275,12 +275,12 @@ const ( ActionObligationPublish = "core:obligation:publish" // ProcessingActivity actions - ActionProcessingActivityList = "core:processing-activity:list" - ActionProcessingActivityGet = "core:processing-activity:get" - ActionProcessingActivityCreate = "core:processing-activity:create" - ActionProcessingActivityUpdate = "core:processing-activity:update" - ActionProcessingActivityDelete = "core:processing-activity:delete" - ActionProcessingActivityExport = "core:processing-activity:export" + ActionProcessingActivityList = "core:processing-activity:list" + ActionProcessingActivityGet = "core:processing-activity:get" + ActionProcessingActivityCreate = "core:processing-activity:create" + ActionProcessingActivityUpdate = "core:processing-activity:update" + ActionProcessingActivityDelete = "core:processing-activity:delete" + ActionProcessingActivityPublish = "core:processing-activity:publish" // Snapshot actions ActionSnapshotGet = "core:snapshot:get" @@ -309,20 +309,20 @@ const ( ActionConnectorDelete = "core:connector:delete" // DataProtectionImpactAssessment actions - ActionDataProtectionImpactAssessmentList = "core:data-protection-impact-assessment:list" - ActionDataProtectionImpactAssessmentGet = "core:data-protection-impact-assessment:get" - ActionDataProtectionImpactAssessmentCreate = "core:data-protection-impact-assessment:create" - ActionDataProtectionImpactAssessmentUpdate = "core:data-protection-impact-assessment:update" - ActionDataProtectionImpactAssessmentDelete = "core:data-protection-impact-assessment:delete" - ActionDataProtectionImpactAssessmentExport = "core:data-protection-impact-assessment:export" + ActionDataProtectionImpactAssessmentList = "core:data-protection-impact-assessment:list" + ActionDataProtectionImpactAssessmentGet = "core:data-protection-impact-assessment:get" + ActionDataProtectionImpactAssessmentCreate = "core:data-protection-impact-assessment:create" + ActionDataProtectionImpactAssessmentUpdate = "core:data-protection-impact-assessment:update" + ActionDataProtectionImpactAssessmentDelete = "core:data-protection-impact-assessment:delete" + ActionDataProtectionImpactAssessmentPublish = "core:data-protection-impact-assessment:publish" // TransferImpactAssessment actions - ActionTransferImpactAssessmentList = "core:transfer-impact-assessment:list" - ActionTransferImpactAssessmentGet = "core:transfer-impact-assessment:get" - ActionTransferImpactAssessmentCreate = "core:transfer-impact-assessment:create" - ActionTransferImpactAssessmentUpdate = "core:transfer-impact-assessment:update" - ActionTransferImpactAssessmentDelete = "core:transfer-impact-assessment:delete" - ActionTransferImpactAssessmentExport = "core:transfer-impact-assessment:export" + ActionTransferImpactAssessmentList = "core:transfer-impact-assessment:list" + ActionTransferImpactAssessmentGet = "core:transfer-impact-assessment:get" + ActionTransferImpactAssessmentCreate = "core:transfer-impact-assessment:create" + ActionTransferImpactAssessmentUpdate = "core:transfer-impact-assessment:update" + ActionTransferImpactAssessmentDelete = "core:transfer-impact-assessment:delete" + ActionTransferImpactAssessmentPublish = "core:transfer-impact-assessment:publish" // TrustCenterDocumentAccess actions ActionTrustCenterDocumentAccessList = "core:trust-center-document-access:list" diff --git a/pkg/probo/data_protection_impact_assessment_service.go b/pkg/probo/data_protection_impact_assessment_service.go index 120eefa72..a06859f36 100644 --- a/pkg/probo/data_protection_impact_assessment_service.go +++ b/pkg/probo/data_protection_impact_assessment_service.go @@ -17,21 +17,17 @@ package probo import ( "context" "fmt" - "io" "time" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/docgen" "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/html2pdf" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/validator" ) type DataProtectionImpactAssessmentService struct { - svc *TenantService - html2pdfConverter *html2pdf.Converter + svc *TenantService } type ( @@ -132,14 +128,13 @@ func (s DataProtectionImpactAssessmentService) ListForOrganizationID( ctx context.Context, organizationID gid.GID, cursor *page.Cursor[coredata.DataProtectionImpactAssessmentOrderField], - filter *coredata.DataProtectionImpactAssessmentFilter, ) (*page.Page[*coredata.DataProtectionImpactAssessment, coredata.DataProtectionImpactAssessmentOrderField], error) { var dpias coredata.DataProtectionImpactAssessments err := s.svc.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - err := dpias.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor, filter) + err := dpias.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor) if err != nil { return fmt.Errorf("cannot load data protection impact assessments: %w", err) } @@ -158,7 +153,6 @@ func (s DataProtectionImpactAssessmentService) ListForOrganizationID( func (s DataProtectionImpactAssessmentService) CountForOrganizationID( ctx context.Context, organizationID gid.GID, - filter *coredata.DataProtectionImpactAssessmentFilter, ) (int, error) { var count int @@ -166,7 +160,7 @@ func (s DataProtectionImpactAssessmentService) CountForOrganizationID( ctx, func(ctx context.Context, conn pg.Querier) (err error) { dpias := coredata.DataProtectionImpactAssessments{} - count, err = dpias.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter) + count, err = dpias.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID) return err }, ) @@ -301,129 +295,3 @@ func (s *DataProtectionImpactAssessmentService) Delete( return err } - -func (s *DataProtectionImpactAssessmentService) ExportPDF( - ctx context.Context, - organizationID gid.GID, - filter *coredata.DataProtectionImpactAssessmentFilter, -) ([]byte, error) { - var tableData docgen.DataProtectionImpactAssessmentTableData - - err := s.svc.pg.WithTx( - ctx, - func(ctx context.Context, conn pg.Tx) error { - var assessments coredata.DataProtectionImpactAssessments - if err := assessments.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter); err != nil { - return fmt.Errorf("cannot load data protection impact assessments: %w", err) - } - - if len(assessments) == 0 { - return fmt.Errorf("no data protection impact assessments found: %w", coredata.ErrResourceNotFound) - } - - organization := &coredata.Organization{} - if err := organization.LoadByID(ctx, conn, s.svc.scope, organizationID); err != nil { - return fmt.Errorf("cannot load organization: %w", err) - } - - horizontalLogoBase64 := "" - if organization.HorizontalLogoFileID != nil { - fileRecord := &coredata.File{} - fileErr := fileRecord.LoadByID(ctx, conn, s.svc.scope, *organization.HorizontalLogoFileID) - if fileErr == nil { - base64Data, mimeType, logoErr := s.svc.fileManager.GetFileBase64(ctx, fileRecord) - if logoErr == nil { - horizontalLogoBase64 = fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data) - } - } - } - - var snapshots coredata.Snapshots - snapshotType := coredata.SnapshotsTypeProcessingActivities - - var version int - var publishedAt time.Time - - if snapshotID := filter.SnapshotID(); snapshotID != nil { - snapshot := &coredata.Snapshot{} - if err := snapshot.LoadByID(ctx, conn, s.svc.scope, *snapshotID); err != nil { - return fmt.Errorf("cannot load snapshot: %w", err) - } - publishedAt = snapshot.CreatedAt - snapshotFilter := coredata.NewSnapshotFilter(&snapshotType).WithBeforeDate(&snapshot.CreatedAt) - snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, snapshotFilter) - if err != nil { - return fmt.Errorf("cannot count processing activities snapshots: %w", err) - } - version = snapshotCount - } else { - publishedAt = time.Now() - snapshotFilter := coredata.NewSnapshotFilter(&snapshotType) - snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, snapshotFilter) - if err != nil { - return fmt.Errorf("cannot count processing activities snapshots: %w", err) - } - version = snapshotCount + 1 - } - - assessmentRows := make([]docgen.DataProtectionImpactAssessmentRowData, len(assessments)) - for i, assessment := range assessments { - processingActivity := &coredata.ProcessingActivity{} - if err := processingActivity.LoadByID(ctx, conn, s.svc.scope, assessment.ProcessingActivityID); err != nil { - return fmt.Errorf("cannot load processing activity: %w", err) - } - - assessmentRows[i] = docgen.DataProtectionImpactAssessmentRowData{ - ProcessingActivityName: processingActivity.Name, - Description: assessment.Description, - NecessityAndProportionality: assessment.NecessityAndProportionality, - PotentialRisk: assessment.PotentialRisk, - Mitigations: assessment.Mitigations, - ResidualRisk: assessment.ResidualRisk, - } - } - - tableData = docgen.DataProtectionImpactAssessmentTableData{ - CompanyName: organization.Name, - CompanyHorizontalLogoBase64: horizontalLogoBase64, - Version: version, - PublishedAt: publishedAt, - Assessments: assessmentRows, - } - - return nil - }, - ) - - if err != nil { - return nil, err - } - - htmlContent, err := docgen.RenderDataProtectionImpactAssessmentsTableHTML(tableData) - if err != nil { - return nil, fmt.Errorf("cannot render HTML: %w", err) - } - - cfg := html2pdf.RenderConfig{ - PageFormat: html2pdf.PageFormatA4, - Orientation: html2pdf.OrientationPortrait, - MarginTop: html2pdf.NewMarginInches(0.98), - MarginBottom: html2pdf.NewMarginInches(0.98), - MarginLeft: html2pdf.NewMarginInches(0.98), - MarginRight: html2pdf.NewMarginInches(0.98), - PrintBackground: true, - Scale: 1.0, - } - - pdfReader, err := s.html2pdfConverter.GeneratePDF(ctx, htmlContent, cfg) - if err != nil { - return nil, fmt.Errorf("cannot generate PDF: %w", err) - } - - pdfData, err := io.ReadAll(pdfReader) - if err != nil { - return nil, fmt.Errorf("cannot read PDF data: %w", err) - } - - return pdfData, nil -} diff --git a/pkg/probo/generated_document_service.go b/pkg/probo/generated_document_service.go index 617b75dd3..0e826a315 100644 --- a/pkg/probo/generated_document_service.go +++ b/pkg/probo/generated_document_service.go @@ -1616,3 +1616,950 @@ func BuildObligationListDocument(data docgen.ObligationListData) (string, error) } return buf.String(), nil } + +func (s *GeneratedDocumentService) PublishProcessingActivityList( + ctx context.Context, + organizationID gid.GID, + approverIDs []gid.GID, +) (*coredata.Document, *coredata.DocumentVersion, error) { + var ( + document *coredata.Document + documentVersion *coredata.DocumentVersion + ) + + err := s.svc.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + organization := &coredata.Organization{} + if err := organization.LoadByID(ctx, tx, s.svc.scope, organizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + documentData, err := s.buildProcessingActivityListDocumentData(ctx, tx, organization) + if err != nil { + return fmt.Errorf("cannot build document data: %w", err) + } + + prosemirrorJSON, err := BuildProcessingActivityListDocument(documentData) + if err != nil { + return fmt.Errorf("cannot build prosemirror document: %w", err) + } + + now := time.Now() + + processingActivity := coredata.ProcessingActivity{} + processingActivityDocumentID, err := processingActivity.GetGeneratedDocumentID(ctx, tx, organizationID) + if err != nil { + return fmt.Errorf("cannot query generated documents: %w", err) + } + + var existingDoc *coredata.Document + if processingActivityDocumentID != nil { + doc := &coredata.Document{} + err = doc.LoadByID(ctx, tx, s.svc.scope, *processingActivityDocumentID) + if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load processing activity list document: %w", err) + } + + if err == nil && doc.ArchivedAt == nil { + existingDoc = doc + } else { + if err := processingActivity.ClearGeneratedDocumentID(ctx, tx, []gid.GID{*processingActivityDocumentID}); err != nil { + return fmt.Errorf("cannot clear document reference: %w", err) + } + } + } + + hasApprovers := len(approverIDs) > 0 + + if existingDoc == nil { + documentID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentEntityType) + + document = &coredata.Document{ + ID: documentID, + OrganizationID: organizationID, + WriteMode: coredata.DocumentWriteModeGenerated, + TrustCenterVisibility: coredata.TrustCenterVisibilityNone, + Status: coredata.DocumentStatusActive, + CreatedAt: now, + UpdatedAt: now, + } + + if err := document.Insert(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot insert document: %w", err) + } + + if err := processingActivity.UpsertGeneratedDocumentID(ctx, tx, organizationID, s.svc.scope.GetTenantID(), documentID); err != nil { + return fmt.Errorf("cannot upsert generated documents: %w", err) + } + } else { + document = existingDoc + } + + var newMajor int + if document.CurrentPublishedMajor != nil { + newMajor = *document.CurrentPublishedMajor + 1 + } else { + newMajor = 1 + } + + versionStatus := coredata.DocumentVersionStatusPublished + var publishedAt *time.Time + if hasApprovers { + versionStatus = coredata.DocumentVersionStatusDraft + } else { + publishedAt = &now + } + + documentVersionID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionEntityType) + documentVersion = &coredata.DocumentVersion{ + ID: documentVersionID, + OrganizationID: organizationID, + DocumentID: document.ID, + Title: "Processing Activities", + Major: newMajor, + Minor: 0, + Content: prosemirrorJSON, + Status: versionStatus, + Classification: coredata.DocumentClassificationConfidential, + DocumentType: coredata.DocumentTypeRegister, + Orientation: coredata.DocumentVersionOrientationPortrait, + PublishedAt: publishedAt, + CreatedAt: now, + UpdatedAt: now, + } + + if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err) + } + return fmt.Errorf("cannot insert document version: %w", err) + } + + if hasApprovers { + defaultApprovers := &coredata.DocumentDefaultApprovers{} + if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil { + return fmt.Errorf("cannot save default approvers: %w", err) + } + + _, err := s.svc.DocumentApprovals.RequestApprovalInTx( + ctx, + tx, + document, + documentVersion, + approverIDs, + nil, + ) + if err != nil { + return fmt.Errorf("cannot request approval: %w", err) + } + } else { + document.CurrentPublishedMajor = &newMajor + document.CurrentPublishedMinor = new(0) + document.UpdatedAt = now + + if err := document.Update(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot update document: %w", err) + } + } + + return nil + }, + ) + + if err != nil { + return nil, nil, err + } + + return document, documentVersion, nil +} + +func (s *GeneratedDocumentService) GetProcessingActivitiesDocumentID( + ctx context.Context, + organizationID gid.GID, +) (*gid.GID, error) { + var documentID *gid.GID + + err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + processingActivity := coredata.ProcessingActivity{} + var err error + documentID, err = processingActivity.GetGeneratedDocumentID(ctx, conn, organizationID) + return err + }) + if err != nil { + return nil, fmt.Errorf("cannot get processing activity list document ID: %w", err) + } + + return documentID, nil +} + +func (s *GeneratedDocumentService) buildProcessingActivityListDocumentData( + ctx context.Context, + conn pg.Querier, + organization *coredata.Organization, +) (docgen.ProcessingActivityListData, error) { + var processingActivities coredata.ProcessingActivities + if err := processingActivities.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organization.ID); err != nil { + return docgen.ProcessingActivityListData{}, fmt.Errorf("cannot load processing activities: %w", err) + } + + if len(processingActivities) == 0 { + return docgen.ProcessingActivityListData{ + Title: "Processing Activities", + OrganizationName: organization.Name, + CreatedAt: time.Now(), + TotalProcessingActivities: 0, + }, nil + } + + var vendors coredata.Vendors + vendorMap, err := vendors.LoadAllByProcessingActivities(ctx, conn, s.svc.scope, organization.ID) + if err != nil { + return docgen.ProcessingActivityListData{}, fmt.Errorf("cannot load vendors: %w", err) + } + + dpoIDs := make([]gid.GID, 0, len(processingActivities)) + dpoIDSet := make(map[gid.GID]struct{}) + for _, pa := range processingActivities { + if pa.DataProtectionOfficerID != nil { + if _, ok := dpoIDSet[*pa.DataProtectionOfficerID]; !ok { + dpoIDs = append(dpoIDs, *pa.DataProtectionOfficerID) + dpoIDSet[*pa.DataProtectionOfficerID] = struct{}{} + } + } + } + + dpoMap := make(map[gid.GID]*coredata.MembershipProfile) + if len(dpoIDs) > 0 { + var profiles coredata.MembershipProfiles + if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, dpoIDs); err != nil { + return docgen.ProcessingActivityListData{}, fmt.Errorf("cannot load DPO profiles: %w", err) + } + + for _, p := range profiles { + dpoMap[p.ID] = p + } + } + + rows := make([]docgen.ProcessingActivityListRow, 0, len(processingActivities)) + for _, pa := range processingActivities { + dpoName := "Not assigned" + if pa.DataProtectionOfficerID != nil { + if p, ok := dpoMap[*pa.DataProtectionOfficerID]; ok { + dpoName = p.FullName + } + } + + vendorStr := "None" + if vendorNames, ok := vendorMap[pa.ID]; ok && len(vendorNames) > 0 { + vendorStr = strings.Join(vendorNames, ", ") + } + + rows = append(rows, docgen.ProcessingActivityListRow{ + Name: pa.Name, + Purpose: derefStringOrNotSpecified(pa.Purpose), + Role: formatProcessingActivityRole(pa.Role), + DataSubjectCategory: derefStringOrNotSpecified(pa.DataSubjectCategory), + PersonalDataCategory: derefStringOrNotSpecified(pa.PersonalDataCategory), + SpecialOrCriminalData: formatSpecialOrCriminalData(pa.SpecialOrCriminalData), + LawfulBasis: formatLawfulBasis(pa.LawfulBasis), + ConsentEvidenceLink: derefStringOrNotSpecified(pa.ConsentEvidenceLink), + Recipients: derefStringOrNotSpecified(pa.Recipients), + Location: derefStringOrNotSpecified(pa.Location), + InternationalTransfers: yesNoLabel(pa.InternationalTransfers), + TransferSafeguards: formatTransferSafeguard(pa.TransferSafeguard), + RetentionPeriod: derefStringOrNotSpecified(pa.RetentionPeriod), + SecurityMeasures: derefStringOrNotSpecified(pa.SecurityMeasures), + DataProtectionImpactAssessmentNeeded: formatDPIANeeded(pa.DataProtectionImpactAssessmentNeeded), + TransferImpactAssessmentNeeded: formatTIANeeded(pa.TransferImpactAssessmentNeeded), + LastReviewDate: formatDateOrNotSpecified(pa.LastReviewDate), + NextReviewDate: formatDateOrNotSpecified(pa.NextReviewDate), + DataProtectionOfficer: dpoName, + Vendors: vendorStr, + }) + } + + return docgen.ProcessingActivityListData{ + Title: "Processing Activities", + OrganizationName: organization.Name, + CreatedAt: time.Now(), + TotalProcessingActivities: len(processingActivities), + Rows: rows, + }, nil +} + +func derefStringOrNotSpecified(s *string) string { + if s == nil || *s == "" { + return "Not specified" + } + return *s +} + +func formatDateOrNotSpecified(t *time.Time) string { + if t == nil { + return "Not specified" + } + return t.Format("January 2, 2006") +} + +func yesNoLabel(b bool) string { + if b { + return "Yes" + } + return "No" +} + +func formatProcessingActivityRole(role coredata.ProcessingActivityRole) string { + switch role { + case coredata.ProcessingActivityRoleController: + return "Controller" + case coredata.ProcessingActivityRoleProcessor: + return "Processor" + default: + return string(role) + } +} + +func formatLawfulBasis(basis coredata.ProcessingActivityLawfulBasis) string { + switch basis { + case coredata.ProcessingActivityLawfulBasisConsent: + return "Consent" + case coredata.ProcessingActivityLawfulBasisContractualNecessity: + return "Contractual Necessity" + case coredata.ProcessingActivityLawfulBasisLegalObligation: + return "Legal Obligation" + case coredata.ProcessingActivityLawfulBasisLegitimateInterest: + return "Legitimate Interest" + case coredata.ProcessingActivityLawfulBasisPublicTask: + return "Public Task" + case coredata.ProcessingActivityLawfulBasisVitalInterests: + return "Vital Interests" + default: + return string(basis) + } +} + +func formatSpecialOrCriminalData(data coredata.ProcessingActivitySpecialOrCriminalDatum) string { + switch data { + case coredata.ProcessingActivitySpecialOrCriminalDatumYes: + return "Yes" + case coredata.ProcessingActivitySpecialOrCriminalDatumNo: + return "No" + case coredata.ProcessingActivitySpecialOrCriminalDatumPossible: + return "Possible" + default: + return string(data) + } +} + +func formatTransferSafeguard(safeguard *coredata.ProcessingActivityTransferSafeguard) string { + if safeguard == nil { + return "Not specified" + } + switch *safeguard { + case coredata.ProcessingActivityTransferSafeguardStandardContractualClauses: + return "Standard Contractual Clauses" + case coredata.ProcessingActivityTransferSafeguardBindingCorporateRules: + return "Binding Corporate Rules" + case coredata.ProcessingActivityTransferSafeguardAdequacyDecision: + return "Adequacy Decision" + case coredata.ProcessingActivityTransferSafeguardDerogations: + return "Derogations" + case coredata.ProcessingActivityTransferSafeguardCodesOfConduct: + return "Codes of Conduct" + case coredata.ProcessingActivityTransferSafeguardCertificationMechanisms: + return "Certification Mechanisms" + default: + return string(*safeguard) + } +} + +func formatDPIANeeded(needed coredata.ProcessingActivityDataProtectionImpactAssessment) string { + switch needed { + case coredata.ProcessingActivityDataProtectionImpactAssessmentNeeded: + return "Yes" + case coredata.ProcessingActivityDataProtectionImpactAssessmentNotNeeded: + return "No" + default: + return string(needed) + } +} + +func formatTIANeeded(needed coredata.ProcessingActivityTransferImpactAssessment) string { + switch needed { + case coredata.ProcessingActivityTransferImpactAssessmentNeeded: + return "Yes" + case coredata.ProcessingActivityTransferImpactAssessmentNotNeeded: + return "No" + default: + return string(needed) + } +} + +func formatResidualRisk(risk *coredata.DataProtectionImpactAssessmentResidualRisk) string { + if risk == nil { + return "Not specified" + } + switch *risk { + case coredata.DataProtectionImpactAssessmentResidualRiskLow: + return "Low" + case coredata.DataProtectionImpactAssessmentResidualRiskMedium: + return "Medium" + case coredata.DataProtectionImpactAssessmentResidualRiskHigh: + return "High" + default: + return string(*risk) + } +} + +var processingActivityListTemplate = template.Must( + template.New("processing_activity_list.json.tmpl"). + Funcs(template.FuncMap{ + "json": func(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil + }, + "printf": fmt.Sprintf, + "add": func(a, b int) int { return a + b }, + }). + ParseFS(Templates, "templates/processing_activity_list.json.tmpl"), +) + +func BuildProcessingActivityListDocument(data docgen.ProcessingActivityListData) (string, error) { + var buf bytes.Buffer + if err := processingActivityListTemplate.Execute(&buf, data); err != nil { + return "", fmt.Errorf("cannot execute processing activity list template: %w", err) + } + return buf.String(), nil +} + +func (s *GeneratedDocumentService) PublishDataProtectionImpactAssessmentList( + ctx context.Context, + organizationID gid.GID, + approverIDs []gid.GID, +) (*coredata.Document, *coredata.DocumentVersion, error) { + var ( + document *coredata.Document + documentVersion *coredata.DocumentVersion + ) + + err := s.svc.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + organization := &coredata.Organization{} + if err := organization.LoadByID(ctx, tx, s.svc.scope, organizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + documentData, err := s.buildDataProtectionImpactAssessmentListDocumentData(ctx, tx, organization) + if err != nil { + return fmt.Errorf("cannot build document data: %w", err) + } + + prosemirrorJSON, err := BuildDataProtectionImpactAssessmentListDocument(documentData) + if err != nil { + return fmt.Errorf("cannot build prosemirror document: %w", err) + } + + now := time.Now() + + dpia := coredata.DataProtectionImpactAssessment{} + dpiaDocumentID, err := dpia.GetGeneratedDocumentID(ctx, tx, organizationID) + if err != nil { + return fmt.Errorf("cannot query generated documents: %w", err) + } + + var existingDoc *coredata.Document + if dpiaDocumentID != nil { + doc := &coredata.Document{} + err = doc.LoadByID(ctx, tx, s.svc.scope, *dpiaDocumentID) + if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load DPIA list document: %w", err) + } + + if err == nil && doc.ArchivedAt == nil { + existingDoc = doc + } else { + if err := dpia.ClearGeneratedDocumentID(ctx, tx, []gid.GID{*dpiaDocumentID}); err != nil { + return fmt.Errorf("cannot clear document reference: %w", err) + } + } + } + + hasApprovers := len(approverIDs) > 0 + + if existingDoc == nil { + documentID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentEntityType) + + document = &coredata.Document{ + ID: documentID, + OrganizationID: organizationID, + WriteMode: coredata.DocumentWriteModeGenerated, + TrustCenterVisibility: coredata.TrustCenterVisibilityNone, + Status: coredata.DocumentStatusActive, + CreatedAt: now, + UpdatedAt: now, + } + + if err := document.Insert(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot insert document: %w", err) + } + + if err := dpia.UpsertGeneratedDocumentID(ctx, tx, organizationID, s.svc.scope.GetTenantID(), documentID); err != nil { + return fmt.Errorf("cannot upsert generated documents: %w", err) + } + } else { + document = existingDoc + } + + var newMajor int + if document.CurrentPublishedMajor != nil { + newMajor = *document.CurrentPublishedMajor + 1 + } else { + newMajor = 1 + } + + versionStatus := coredata.DocumentVersionStatusPublished + var publishedAt *time.Time + if hasApprovers { + versionStatus = coredata.DocumentVersionStatusDraft + } else { + publishedAt = &now + } + + documentVersionID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionEntityType) + documentVersion = &coredata.DocumentVersion{ + ID: documentVersionID, + OrganizationID: organizationID, + DocumentID: document.ID, + Title: "Data Protection Impact Assessments", + Major: newMajor, + Minor: 0, + Content: prosemirrorJSON, + Status: versionStatus, + Classification: coredata.DocumentClassificationConfidential, + DocumentType: coredata.DocumentTypeRegister, + Orientation: coredata.DocumentVersionOrientationPortrait, + PublishedAt: publishedAt, + CreatedAt: now, + UpdatedAt: now, + } + + if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err) + } + return fmt.Errorf("cannot insert document version: %w", err) + } + + if hasApprovers { + defaultApprovers := &coredata.DocumentDefaultApprovers{} + if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil { + return fmt.Errorf("cannot save default approvers: %w", err) + } + + _, err := s.svc.DocumentApprovals.RequestApprovalInTx( + ctx, + tx, + document, + documentVersion, + approverIDs, + nil, + ) + if err != nil { + return fmt.Errorf("cannot request approval: %w", err) + } + } else { + document.CurrentPublishedMajor = &newMajor + document.CurrentPublishedMinor = new(0) + document.UpdatedAt = now + + if err := document.Update(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot update document: %w", err) + } + } + + return nil + }, + ) + + if err != nil { + return nil, nil, err + } + + return document, documentVersion, nil +} + +func (s *GeneratedDocumentService) GetDataProtectionImpactAssessmentsDocumentID( + ctx context.Context, + organizationID gid.GID, +) (*gid.GID, error) { + var documentID *gid.GID + + err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + dpia := coredata.DataProtectionImpactAssessment{} + var err error + documentID, err = dpia.GetGeneratedDocumentID(ctx, conn, organizationID) + return err + }) + if err != nil { + return nil, fmt.Errorf("cannot get DPIA list document ID: %w", err) + } + + return documentID, nil +} + +func (s *GeneratedDocumentService) buildDataProtectionImpactAssessmentListDocumentData( + ctx context.Context, + conn pg.Querier, + organization *coredata.Organization, +) (docgen.DataProtectionImpactAssessmentListData, error) { + var assessments coredata.DataProtectionImpactAssessments + if err := assessments.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organization.ID); err != nil { + return docgen.DataProtectionImpactAssessmentListData{}, fmt.Errorf("cannot load DPIAs: %w", err) + } + + if len(assessments) == 0 { + return docgen.DataProtectionImpactAssessmentListData{ + Title: "Data Protection Impact Assessments", + OrganizationName: organization.Name, + CreatedAt: time.Now(), + TotalDataProtectionImpactAssessments: 0, + }, nil + } + + processingActivityIDs := make([]gid.GID, 0, len(assessments)) + processingActivityIDSet := make(map[gid.GID]struct{}, len(assessments)) + for _, a := range assessments { + if _, ok := processingActivityIDSet[a.ProcessingActivityID]; !ok { + processingActivityIDs = append(processingActivityIDs, a.ProcessingActivityID) + processingActivityIDSet[a.ProcessingActivityID] = struct{}{} + } + } + + var processingActivities coredata.ProcessingActivities + if err := processingActivities.LoadByIDs(ctx, conn, s.svc.scope, processingActivityIDs); err != nil { + return docgen.DataProtectionImpactAssessmentListData{}, fmt.Errorf("cannot load processing activities: %w", err) + } + + processingActivityMap := make(map[gid.GID]*coredata.ProcessingActivity, len(processingActivities)) + for _, pa := range processingActivities { + processingActivityMap[pa.ID] = pa + } + + rows := make([]docgen.DataProtectionImpactAssessmentListRow, 0, len(assessments)) + for _, a := range assessments { + paName := "-" + if pa, ok := processingActivityMap[a.ProcessingActivityID]; ok { + paName = pa.Name + } + + rows = append(rows, docgen.DataProtectionImpactAssessmentListRow{ + ProcessingActivityName: paName, + Description: derefStringOrNotSpecified(a.Description), + NecessityAndProportionality: derefStringOrNotSpecified(a.NecessityAndProportionality), + PotentialRisk: derefStringOrNotSpecified(a.PotentialRisk), + Mitigations: derefStringOrNotSpecified(a.Mitigations), + ResidualRisk: formatResidualRisk(a.ResidualRisk), + }) + } + + return docgen.DataProtectionImpactAssessmentListData{ + Title: "Data Protection Impact Assessments", + OrganizationName: organization.Name, + CreatedAt: time.Now(), + TotalDataProtectionImpactAssessments: len(assessments), + Rows: rows, + }, nil +} + +var dataProtectionImpactAssessmentListTemplate = template.Must( + template.New("data_protection_impact_assessment_list.json.tmpl"). + Funcs(template.FuncMap{ + "json": func(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil + }, + "printf": fmt.Sprintf, + "add": func(a, b int) int { return a + b }, + }). + ParseFS(Templates, "templates/data_protection_impact_assessment_list.json.tmpl"), +) + +func BuildDataProtectionImpactAssessmentListDocument(data docgen.DataProtectionImpactAssessmentListData) (string, error) { + var buf bytes.Buffer + if err := dataProtectionImpactAssessmentListTemplate.Execute(&buf, data); err != nil { + return "", fmt.Errorf("cannot execute DPIA list template: %w", err) + } + return buf.String(), nil +} + +func (s *GeneratedDocumentService) PublishTransferImpactAssessmentList( + ctx context.Context, + organizationID gid.GID, + approverIDs []gid.GID, +) (*coredata.Document, *coredata.DocumentVersion, error) { + var ( + document *coredata.Document + documentVersion *coredata.DocumentVersion + ) + + err := s.svc.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + organization := &coredata.Organization{} + if err := organization.LoadByID(ctx, tx, s.svc.scope, organizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + documentData, err := s.buildTransferImpactAssessmentListDocumentData(ctx, tx, organization) + if err != nil { + return fmt.Errorf("cannot build document data: %w", err) + } + + prosemirrorJSON, err := BuildTransferImpactAssessmentListDocument(documentData) + if err != nil { + return fmt.Errorf("cannot build prosemirror document: %w", err) + } + + now := time.Now() + + tia := coredata.TransferImpactAssessment{} + tiaDocumentID, err := tia.GetGeneratedDocumentID(ctx, tx, organizationID) + if err != nil { + return fmt.Errorf("cannot query generated documents: %w", err) + } + + var existingDoc *coredata.Document + if tiaDocumentID != nil { + doc := &coredata.Document{} + err = doc.LoadByID(ctx, tx, s.svc.scope, *tiaDocumentID) + if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load TIA list document: %w", err) + } + + if err == nil && doc.ArchivedAt == nil { + existingDoc = doc + } else { + if err := tia.ClearGeneratedDocumentID(ctx, tx, []gid.GID{*tiaDocumentID}); err != nil { + return fmt.Errorf("cannot clear document reference: %w", err) + } + } + } + + hasApprovers := len(approverIDs) > 0 + + if existingDoc == nil { + documentID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentEntityType) + + document = &coredata.Document{ + ID: documentID, + OrganizationID: organizationID, + WriteMode: coredata.DocumentWriteModeGenerated, + TrustCenterVisibility: coredata.TrustCenterVisibilityNone, + Status: coredata.DocumentStatusActive, + CreatedAt: now, + UpdatedAt: now, + } + + if err := document.Insert(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot insert document: %w", err) + } + + if err := tia.UpsertGeneratedDocumentID(ctx, tx, organizationID, s.svc.scope.GetTenantID(), documentID); err != nil { + return fmt.Errorf("cannot upsert generated documents: %w", err) + } + } else { + document = existingDoc + } + + var newMajor int + if document.CurrentPublishedMajor != nil { + newMajor = *document.CurrentPublishedMajor + 1 + } else { + newMajor = 1 + } + + versionStatus := coredata.DocumentVersionStatusPublished + var publishedAt *time.Time + if hasApprovers { + versionStatus = coredata.DocumentVersionStatusDraft + } else { + publishedAt = &now + } + + documentVersionID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionEntityType) + documentVersion = &coredata.DocumentVersion{ + ID: documentVersionID, + OrganizationID: organizationID, + DocumentID: document.ID, + Title: "Transfer Impact Assessments", + Major: newMajor, + Minor: 0, + Content: prosemirrorJSON, + Status: versionStatus, + Classification: coredata.DocumentClassificationConfidential, + DocumentType: coredata.DocumentTypeRegister, + Orientation: coredata.DocumentVersionOrientationPortrait, + PublishedAt: publishedAt, + CreatedAt: now, + UpdatedAt: now, + } + + if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err) + } + return fmt.Errorf("cannot insert document version: %w", err) + } + + if hasApprovers { + defaultApprovers := &coredata.DocumentDefaultApprovers{} + if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil { + return fmt.Errorf("cannot save default approvers: %w", err) + } + + _, err := s.svc.DocumentApprovals.RequestApprovalInTx( + ctx, + tx, + document, + documentVersion, + approverIDs, + nil, + ) + if err != nil { + return fmt.Errorf("cannot request approval: %w", err) + } + } else { + document.CurrentPublishedMajor = &newMajor + document.CurrentPublishedMinor = new(0) + document.UpdatedAt = now + + if err := document.Update(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot update document: %w", err) + } + } + + return nil + }, + ) + + if err != nil { + return nil, nil, err + } + + return document, documentVersion, nil +} + +func (s *GeneratedDocumentService) GetTransferImpactAssessmentsDocumentID( + ctx context.Context, + organizationID gid.GID, +) (*gid.GID, error) { + var documentID *gid.GID + + err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + tia := coredata.TransferImpactAssessment{} + var err error + documentID, err = tia.GetGeneratedDocumentID(ctx, conn, organizationID) + return err + }) + if err != nil { + return nil, fmt.Errorf("cannot get TIA list document ID: %w", err) + } + + return documentID, nil +} + +func (s *GeneratedDocumentService) buildTransferImpactAssessmentListDocumentData( + ctx context.Context, + conn pg.Querier, + organization *coredata.Organization, +) (docgen.TransferImpactAssessmentListData, error) { + var assessments coredata.TransferImpactAssessments + if err := assessments.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organization.ID); err != nil { + return docgen.TransferImpactAssessmentListData{}, fmt.Errorf("cannot load TIAs: %w", err) + } + + if len(assessments) == 0 { + return docgen.TransferImpactAssessmentListData{ + Title: "Transfer Impact Assessments", + OrganizationName: organization.Name, + CreatedAt: time.Now(), + TotalTransferImpactAssessments: 0, + }, nil + } + + processingActivityIDs := make([]gid.GID, 0, len(assessments)) + processingActivityIDSet := make(map[gid.GID]struct{}, len(assessments)) + for _, a := range assessments { + if _, ok := processingActivityIDSet[a.ProcessingActivityID]; !ok { + processingActivityIDs = append(processingActivityIDs, a.ProcessingActivityID) + processingActivityIDSet[a.ProcessingActivityID] = struct{}{} + } + } + + var processingActivities coredata.ProcessingActivities + if err := processingActivities.LoadByIDs(ctx, conn, s.svc.scope, processingActivityIDs); err != nil { + return docgen.TransferImpactAssessmentListData{}, fmt.Errorf("cannot load processing activities: %w", err) + } + + processingActivityMap := make(map[gid.GID]*coredata.ProcessingActivity, len(processingActivities)) + for _, pa := range processingActivities { + processingActivityMap[pa.ID] = pa + } + + rows := make([]docgen.TransferImpactAssessmentListRow, 0, len(assessments)) + for _, a := range assessments { + paName := "-" + if pa, ok := processingActivityMap[a.ProcessingActivityID]; ok { + paName = pa.Name + } + + rows = append(rows, docgen.TransferImpactAssessmentListRow{ + ProcessingActivityName: paName, + DataSubjects: derefStringOrNotSpecified(a.DataSubjects), + Transfer: derefStringOrNotSpecified(a.Transfer), + LegalMechanism: derefStringOrNotSpecified(a.LegalMechanism), + LocalLawRisk: derefStringOrNotSpecified(a.LocalLawRisk), + SupplementaryMeasures: derefStringOrNotSpecified(a.SupplementaryMeasures), + }) + } + + return docgen.TransferImpactAssessmentListData{ + Title: "Transfer Impact Assessments", + OrganizationName: organization.Name, + CreatedAt: time.Now(), + TotalTransferImpactAssessments: len(assessments), + Rows: rows, + }, nil +} + +var transferImpactAssessmentListTemplate = template.Must( + template.New("transfer_impact_assessment_list.json.tmpl"). + Funcs(template.FuncMap{ + "json": func(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil + }, + "printf": fmt.Sprintf, + "add": func(a, b int) int { return a + b }, + }). + ParseFS(Templates, "templates/transfer_impact_assessment_list.json.tmpl"), +) + +func BuildTransferImpactAssessmentListDocument(data docgen.TransferImpactAssessmentListData) (string, error) { + var buf bytes.Buffer + if err := transferImpactAssessmentListTemplate.Execute(&buf, data); err != nil { + return "", fmt.Errorf("cannot execute TIA list template: %w", err) + } + return buf.String(), nil +} diff --git a/pkg/probo/policies.go b/pkg/probo/policies.go index 0ae999ef0..aa0c76644 100644 --- a/pkg/probo/policies.go +++ b/pkg/probo/policies.go @@ -114,12 +114,6 @@ var ViewerPolicy = policy.NewPolicy( ActionEmployeeDocumentGet, ActionEmployeeDocumentList, ActionEmployeeDocumentVersionExportPDF, ).WithSID("employee-document-access").When(organizationCondition), - - policy.Allow( - ActionProcessingActivityExport, - ActionDataProtectionImpactAssessmentExport, - ActionTransferImpactAssessmentExport, - ).WithSID("processing-activity-export").When(organizationCondition), ).WithDescription("Read-only probo access for organization viewers") // AuditorPolicy defines permissions for auditor role. diff --git a/pkg/probo/processing_activity_service.go b/pkg/probo/processing_activity_service.go index 12c076282..0099d7702 100644 --- a/pkg/probo/processing_activity_service.go +++ b/pkg/probo/processing_activity_service.go @@ -17,22 +17,17 @@ package probo import ( "context" "fmt" - "io" - "strings" "time" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/docgen" "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/html2pdf" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/validator" ) type ProcessingActivityService struct { - svc *TenantService - html2pdfConverter *html2pdf.Converter + svc *TenantService } type ( @@ -338,14 +333,13 @@ func (s ProcessingActivityService) ListForOrganizationID( ctx context.Context, organizationID gid.GID, cursor *page.Cursor[coredata.ProcessingActivityOrderField], - filter *coredata.ProcessingActivityFilter, ) (*page.Page[*coredata.ProcessingActivity, coredata.ProcessingActivityOrderField], error) { var processingActivities coredata.ProcessingActivities err := s.svc.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - err := processingActivities.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor, filter) + err := processingActivities.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor) if err != nil { return fmt.Errorf("cannot load processing activities: %w", err) } @@ -364,7 +358,6 @@ func (s ProcessingActivityService) ListForOrganizationID( func (s ProcessingActivityService) CountForOrganizationID( ctx context.Context, organizationID gid.GID, - filter *coredata.ProcessingActivityFilter, ) (int, error) { var count int @@ -372,7 +365,7 @@ func (s ProcessingActivityService) CountForOrganizationID( ctx, func(ctx context.Context, conn pg.Querier) (err error) { processingActivities := coredata.ProcessingActivities{} - count, err = processingActivities.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter) + count, err = processingActivities.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID) if err != nil { return fmt.Errorf("cannot count processing activities: %w", err) } @@ -387,157 +380,3 @@ func (s ProcessingActivityService) CountForOrganizationID( return count, nil } - -func (s *ProcessingActivityService) ExportPDF( - ctx context.Context, - organizationID gid.GID, - filter *coredata.ProcessingActivityFilter, -) ([]byte, error) { - var tableData docgen.ProcessingActivityTableData - - err := s.svc.pg.WithTx( - ctx, - func(ctx context.Context, conn pg.Tx) error { - var processingActivities coredata.ProcessingActivities - if err := processingActivities.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter); err != nil { - return fmt.Errorf("cannot load processing activities: %w", err) - } - - if len(processingActivities) == 0 { - return fmt.Errorf("no processing activities found: %w", coredata.ErrResourceNotFound) - } - - organization := &coredata.Organization{} - if err := organization.LoadByID(ctx, conn, s.svc.scope, organizationID); err != nil { - return fmt.Errorf("cannot load organization: %w", err) - } - - horizontalLogoBase64 := "" - if organization.HorizontalLogoFileID != nil { - fileRecord := &coredata.File{} - fileErr := fileRecord.LoadByID(ctx, conn, s.svc.scope, *organization.HorizontalLogoFileID) - if fileErr == nil { - base64Data, mimeType, logoErr := s.svc.fileManager.GetFileBase64(ctx, fileRecord) - if logoErr == nil { - horizontalLogoBase64 = fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data) - } - } - } - - var vendors coredata.Vendors - vendorMap, err := vendors.LoadAllByProcessingActivities(ctx, conn, s.svc.scope, organizationID, filter) - if err != nil { - return fmt.Errorf("cannot load vendors: %w", err) - } - - var snapshots coredata.Snapshots - snapshotType := coredata.SnapshotsTypeProcessingActivities - - var version int - var publishedAt time.Time - - if snapshotID := filter.SnapshotID(); snapshotID != nil { - snapshot := &coredata.Snapshot{} - if err := snapshot.LoadByID(ctx, conn, s.svc.scope, *snapshotID); err != nil { - return fmt.Errorf("cannot load snapshot: %w", err) - } - publishedAt = snapshot.CreatedAt - snapshotFilter := coredata.NewSnapshotFilter(&snapshotType).WithBeforeDate(&snapshot.CreatedAt) - snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, snapshotFilter) - if err != nil { - return fmt.Errorf("cannot count processing activities snapshots: %w", err) - } - version = snapshotCount - } else { - publishedAt = time.Now() - snapshotFilter := coredata.NewSnapshotFilter(&snapshotType) - snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, snapshotFilter) - if err != nil { - return fmt.Errorf("cannot count processing activities snapshots: %w", err) - } - version = snapshotCount + 1 - } - - activities := make([]docgen.ProcessingActivityRowData, len(processingActivities)) - for i, pa := range processingActivities { - dpoFullName := (*string)(nil) - if pa.DataProtectionOfficerID != nil { - dpo := &coredata.MembershipProfile{} - if err := dpo.LoadByID(ctx, conn, s.svc.scope, *pa.DataProtectionOfficerID); err == nil { - dpoFullName = &dpo.FullName - } - } - - vendorsList := "" - if vendorNames, ok := vendorMap[pa.ID]; ok && len(vendorNames) > 0 { - vendorsList = strings.Join(vendorNames, ", ") - } - - activities[i] = docgen.ProcessingActivityRowData{ - Name: pa.Name, - Purpose: pa.Purpose, - DataSubjectCategory: pa.DataSubjectCategory, - PersonalDataCategory: pa.PersonalDataCategory, - SpecialOrCriminalData: pa.SpecialOrCriminalData, - ConsentEvidenceLink: pa.ConsentEvidenceLink, - LawfulBasis: pa.LawfulBasis, - Recipients: pa.Recipients, - Location: pa.Location, - InternationalTransfers: pa.InternationalTransfers, - TransferSafeguards: pa.TransferSafeguard, - RetentionPeriod: pa.RetentionPeriod, - SecurityMeasures: pa.SecurityMeasures, - DataProtectionImpactAssessmentNeeded: pa.DataProtectionImpactAssessmentNeeded, - TransferImpactAssessmentNeeded: pa.TransferImpactAssessmentNeeded, - LastReviewDate: pa.LastReviewDate, - NextReviewDate: pa.NextReviewDate, - Role: pa.Role, - DataProtectionOfficerFullName: dpoFullName, - Vendors: vendorsList, - } - } - - tableData = docgen.ProcessingActivityTableData{ - CompanyName: organization.Name, - CompanyHorizontalLogoBase64: horizontalLogoBase64, - Version: version, - PublishedAt: publishedAt, - Activities: activities, - } - - return nil - }, - ) - - if err != nil { - return nil, err - } - - htmlContent, err := docgen.RenderProcessingActivitiesTableHTML(tableData) - if err != nil { - return nil, fmt.Errorf("cannot generate HTML: %w", err) - } - - cfg := html2pdf.RenderConfig{ - PageFormat: html2pdf.PageFormatA4, - Orientation: html2pdf.OrientationPortrait, - MarginTop: html2pdf.NewMarginInches(0.98), - MarginBottom: html2pdf.NewMarginInches(0.98), - MarginLeft: html2pdf.NewMarginInches(0.98), - MarginRight: html2pdf.NewMarginInches(0.98), - PrintBackground: true, - Scale: 1.0, - } - - pdfReader, err := s.html2pdfConverter.GeneratePDF(ctx, htmlContent, cfg) - if err != nil { - return nil, fmt.Errorf("cannot generate PDF: %w", err) - } - - pdfData, err := io.ReadAll(pdfReader) - if err != nil { - return nil, fmt.Errorf("cannot read PDF data: %w", err) - } - - return pdfData, nil -} diff --git a/pkg/probo/service.go b/pkg/probo/service.go index eac40c9ad..b252ddb17 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -281,16 +281,13 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService { tenantService.Snapshots = &SnapshotService{svc: tenantService} tenantService.RightsRequests = &RightsRequestService{svc: tenantService} tenantService.ProcessingActivities = &ProcessingActivityService{ - svc: tenantService, - html2pdfConverter: s.html2pdfConverter, + svc: tenantService, } tenantService.DataProtectionImpactAssessments = &DataProtectionImpactAssessmentService{ - svc: tenantService, - html2pdfConverter: s.html2pdfConverter, + svc: tenantService, } tenantService.TransferImpactAssessments = &TransferImpactAssessmentService{ - svc: tenantService, - html2pdfConverter: s.html2pdfConverter, + svc: tenantService, } tenantService.StatementsOfApplicability = &StatementOfApplicabilityService{ svc: tenantService, diff --git a/pkg/probo/templates/data_protection_impact_assessment_list.json.tmpl b/pkg/probo/templates/data_protection_impact_assessment_list.json.tmpl new file mode 100644 index 000000000..447e2a286 --- /dev/null +++ b/pkg/probo/templates/data_protection_impact_assessment_list.json.tmpl @@ -0,0 +1,80 @@ +{ + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { "level": 1 }, + "content": [{ "type": "text", "text": "1. Purpose" }] + }, + { + "type": "paragraph", + "content": [{ "type": "text", "text": "This document contains Data Protection Impact Assessments (DPIAs) conducted for processing activities that present a high risk to individuals' rights and freedoms. DPIAs are systematic assessments that evaluate the necessity, proportionality, and risks associated with data processing operations, along with the measures implemented to mitigate identified risks." }] + }, + { "type": "horizontalRule" }, + { + "type": "heading", + "attrs": { "level": 1 }, + "content": [{ "type": "text", "text": "2. Records" }] + }{{range $i, $r := .Rows}}, + {{if $i}}{ "type": "horizontalRule" },{{end}} + { + "type": "heading", + "attrs": { "level": 2 }, + "content": [{ "type": "text", "text": {{json (printf "2.%d %s" (add $i 1) $r.ProcessingActivityName)}} }] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Description: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.Description}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Necessity and Proportionality: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.NecessityAndProportionality}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Potential Risk: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.PotentialRisk}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Mitigations: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.Mitigations}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Residual Risk: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.ResidualRisk}} } + ] + }{{end}}, + { "type": "horizontalRule" }, + { + "type": "heading", + "attrs": { "level": 1 }, + "content": [{ "type": "text", "text": "3. Definitions" }] + }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [{ "type": "text", "text": "Residual Risk" }] + }, + { + "type": "bulletList", + "content": [ + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Low: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The residual risk after implementing mitigation measures is considered low. The processing activity poses minimal risk to individuals' rights and freedoms." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Medium: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The residual risk after implementing mitigation measures is considered medium. The processing activity poses a moderate risk to individuals' rights and freedoms, requiring ongoing monitoring." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "High: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The residual risk after implementing mitigation measures is considered high. The processing activity poses significant risk to individuals' rights and freedoms, requiring enhanced safeguards and regular review." }] }] } + ] + } + ] +} diff --git a/pkg/probo/templates/processing_activity_list.json.tmpl b/pkg/probo/templates/processing_activity_list.json.tmpl new file mode 100644 index 000000000..6901ed28f --- /dev/null +++ b/pkg/probo/templates/processing_activity_list.json.tmpl @@ -0,0 +1,281 @@ +{ + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { "level": 1 }, + "content": [{ "type": "text", "text": "1. Purpose" }] + }, + { + "type": "paragraph", + "content": [{ "type": "text", "text": "This document provides a comprehensive overview of all processing activities within the organization. It serves as a record of personal data processing operations, documenting the purposes, legal bases, data categories, and associated safeguards for each activity." }] + }, + { "type": "horizontalRule" }, + { + "type": "heading", + "attrs": { "level": 1 }, + "content": [{ "type": "text", "text": "2. Records" }] + }{{range $i, $r := .Rows}}, + {{if $i}}{ "type": "horizontalRule" },{{end}} + { + "type": "heading", + "attrs": { "level": 2 }, + "content": [{ "type": "text", "text": {{json (printf "2.%d %s" (add $i 1) $r.Name)}} }] + }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [{ "type": "text", "text": {{json (printf "2.%d.1 General Information" (add $i 1))}} }] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Purpose: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.Purpose}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Role: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.Role}} } + ] + }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [{ "type": "text", "text": {{json (printf "2.%d.2 Data Categories" (add $i 1))}} }] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Data Subject Category: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.DataSubjectCategory}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Personal Data Category: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.PersonalDataCategory}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Special/Criminal Data: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.SpecialOrCriminalData}} } + ] + }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [{ "type": "text", "text": {{json (printf "2.%d.3 Legal Basis" (add $i 1))}} }] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Lawful Basis: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.LawfulBasis}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Consent Evidence Link: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.ConsentEvidenceLink}} } + ] + }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [{ "type": "text", "text": {{json (printf "2.%d.4 Data Sharing & Transfers" (add $i 1))}} }] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Recipients: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.Recipients}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Location: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.Location}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "International Transfers: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.InternationalTransfers}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Transfer Safeguards: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.TransferSafeguards}} } + ] + }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [{ "type": "text", "text": {{json (printf "2.%d.5 Retention & Security" (add $i 1))}} }] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Retention Period: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.RetentionPeriod}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Security Measures: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.SecurityMeasures}} } + ] + }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [{ "type": "text", "text": {{json (printf "2.%d.6 Assessments & Reviews" (add $i 1))}} }] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "DPIA Needed: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.DataProtectionImpactAssessmentNeeded}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "TIA Needed: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.TransferImpactAssessmentNeeded}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Last Review Date: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.LastReviewDate}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Next Review Date: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.NextReviewDate}} } + ] + }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [{ "type": "text", "text": {{json (printf "2.%d.7 Responsible Parties" (add $i 1))}} }] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Data Protection Officer: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.DataProtectionOfficer}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Vendors: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.Vendors}} } + ] + }{{end}}, + { "type": "horizontalRule" }, + { + "type": "heading", + "attrs": { "level": 1 }, + "content": [{ "type": "text", "text": "3. Definitions" }] + }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [{ "type": "text", "text": "Role" }] + }, + { + "type": "bulletList", + "content": [ + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Controller: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The entity that determines the purposes and means of processing personal data." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Processor: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The entity that processes personal data on behalf of the controller." }] }] } + ] + }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [{ "type": "text", "text": "Lawful Basis" }] + }, + { + "type": "bulletList", + "content": [ + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Consent: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The data subject has given consent to the processing of their personal data." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Contractual Necessity: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Processing is necessary for the performance of a contract to which the data subject is party." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Legal Obligation: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Processing is necessary for compliance with a legal obligation to which the controller is subject." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Legitimate Interest: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Processing is necessary for the purposes of the legitimate interests pursued by the controller or a third party." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Public Task: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Processing is necessary for the performance of a task carried out in the public interest or in the exercise of official authority." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Vital Interests: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Processing is necessary to protect the vital interests of the data subject or of another natural person." }] }] } + ] + }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [{ "type": "text", "text": "Special/Criminal Data" }] + }, + { + "type": "bulletList", + "content": [ + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Yes: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The processing activity involves special categories of personal data or data relating to criminal convictions and offences." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "No: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The processing activity does not involve special categories of personal data or data relating to criminal convictions and offences." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Possible: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The processing activity may involve special categories of personal data or data relating to criminal convictions and offences." }] }] } + ] + }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [{ "type": "text", "text": "Transfer Safeguards" }] + }, + { + "type": "bulletList", + "content": [ + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Standard Contractual Clauses: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Standard contractual clauses approved by the European Commission are used to ensure adequate protection for international transfers." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Binding Corporate Rules: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Binding corporate rules approved by a supervisory authority are used to ensure adequate protection for international transfers." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Adequacy Decision: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The European Commission has determined that the third country ensures an adequate level of protection." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Derogations: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "A derogation under Article 49 of the GDPR is used for the international transfer." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Codes of Conduct: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "An approved code of conduct together with binding and enforceable commitments is used to ensure adequate protection." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Certification Mechanisms: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "An approved certification mechanism together with binding and enforceable commitments is used to ensure adequate protection." }] }] } + ] + }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [{ "type": "text", "text": "DPIA Needed" }] + }, + { + "type": "bulletList", + "content": [ + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Yes: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "A Data Protection Impact Assessment is required for this processing activity." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "No: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "A Data Protection Impact Assessment is not required for this processing activity." }] }] } + ] + }, + { + "type": "heading", + "attrs": { "level": 3 }, + "content": [{ "type": "text", "text": "TIA Needed" }] + }, + { + "type": "bulletList", + "content": [ + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Yes: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "A Transfer Impact Assessment is required for this processing activity." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "No: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "A Transfer Impact Assessment is not required for this processing activity." }] }] } + ] + } + ] +} diff --git a/pkg/probo/templates/transfer_impact_assessment_list.json.tmpl b/pkg/probo/templates/transfer_impact_assessment_list.json.tmpl new file mode 100644 index 000000000..b63762de0 --- /dev/null +++ b/pkg/probo/templates/transfer_impact_assessment_list.json.tmpl @@ -0,0 +1,77 @@ +{ + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { "level": 1 }, + "content": [{ "type": "text", "text": "1. Purpose" }] + }, + { + "type": "paragraph", + "content": [{ "type": "text", "text": "This document contains Transfer Impact Assessments (TIAs) conducted for processing activities involving international transfers of personal data to countries outside the European Economic Area (EEA). TIAs evaluate the legal mechanisms used for transfers, assess risks related to local laws in destination countries, and document supplementary measures implemented to ensure an adequate level of data protection." }] + }, + { "type": "horizontalRule" }, + { + "type": "heading", + "attrs": { "level": 1 }, + "content": [{ "type": "text", "text": "2. Records" }] + }{{range $i, $r := .Rows}}, + {{if $i}}{ "type": "horizontalRule" },{{end}} + { + "type": "heading", + "attrs": { "level": 2 }, + "content": [{ "type": "text", "text": {{json (printf "2.%d %s" (add $i 1) $r.ProcessingActivityName)}} }] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Data Subjects: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.DataSubjects}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Transfer: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.Transfer}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Legal Mechanism: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.LegalMechanism}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Local Law Risk: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.LocalLawRisk}} } + ] + }, + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Supplementary Measures: ", "marks": [{ "type": "bold" }] }, + { "type": "text", "text": {{json $r.SupplementaryMeasures}} } + ] + }{{end}}, + { "type": "horizontalRule" }, + { + "type": "heading", + "attrs": { "level": 1 }, + "content": [{ "type": "text", "text": "3. Definitions" }] + }, + { + "type": "bulletList", + "content": [ + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Data Subjects: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The categories of individuals whose personal data is being transferred outside the European Economic Area (e.g., employees, customers, end users)." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Transfer: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "A description of the international transfer being assessed, including the source and destination countries and the nature of the data flow." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Legal Mechanism: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The legal basis used to authorise the transfer (e.g., Standard Contractual Clauses, Binding Corporate Rules, an adequacy decision, or a derogation under Article 49 GDPR)." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Local Law Risk: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The assessment of laws and practices in the destination country that may impinge on the level of protection afforded to transferred personal data, including government access regimes." }] }] }, + { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Supplementary Measures: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The technical, contractual, and organisational measures put in place to bring the level of protection up to the EU standard when the chosen legal mechanism alone is not sufficient." }] }] } + ] + } + ] +} diff --git a/pkg/probo/transfer_impact_assessment_service.go b/pkg/probo/transfer_impact_assessment_service.go index 60f79ad5e..eb99b66c4 100644 --- a/pkg/probo/transfer_impact_assessment_service.go +++ b/pkg/probo/transfer_impact_assessment_service.go @@ -17,21 +17,17 @@ package probo import ( "context" "fmt" - "io" "time" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/docgen" "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/html2pdf" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/validator" ) type TransferImpactAssessmentService struct { - svc *TenantService - html2pdfConverter *html2pdf.Converter + svc *TenantService } type ( @@ -132,14 +128,13 @@ func (s TransferImpactAssessmentService) ListForOrganizationID( ctx context.Context, organizationID gid.GID, cursor *page.Cursor[coredata.TransferImpactAssessmentOrderField], - filter *coredata.TransferImpactAssessmentFilter, ) (*page.Page[*coredata.TransferImpactAssessment, coredata.TransferImpactAssessmentOrderField], error) { var tias coredata.TransferImpactAssessments err := s.svc.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - err := tias.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor, filter) + err := tias.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor) if err != nil { return fmt.Errorf("cannot load transfer impact assessments: %w", err) } @@ -158,7 +153,6 @@ func (s TransferImpactAssessmentService) ListForOrganizationID( func (s TransferImpactAssessmentService) CountForOrganizationID( ctx context.Context, organizationID gid.GID, - filter *coredata.TransferImpactAssessmentFilter, ) (int, error) { var count int @@ -166,7 +160,7 @@ func (s TransferImpactAssessmentService) CountForOrganizationID( ctx, func(ctx context.Context, conn pg.Querier) (err error) { tias := coredata.TransferImpactAssessments{} - count, err = tias.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter) + count, err = tias.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID) return err }, ) @@ -301,129 +295,3 @@ func (s *TransferImpactAssessmentService) Delete( return err } - -func (s *TransferImpactAssessmentService) ExportPDF( - ctx context.Context, - organizationID gid.GID, - filter *coredata.TransferImpactAssessmentFilter, -) ([]byte, error) { - var tableData docgen.TransferImpactAssessmentTableData - - err := s.svc.pg.WithTx( - ctx, - func(ctx context.Context, conn pg.Tx) error { - var assessments coredata.TransferImpactAssessments - if err := assessments.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter); err != nil { - return fmt.Errorf("cannot load transfer impact assessments: %w", err) - } - - if len(assessments) == 0 { - return fmt.Errorf("no transfer impact assessments found: %w", coredata.ErrResourceNotFound) - } - - organization := &coredata.Organization{} - if err := organization.LoadByID(ctx, conn, s.svc.scope, organizationID); err != nil { - return fmt.Errorf("cannot load organization: %w", err) - } - - horizontalLogoBase64 := "" - if organization.HorizontalLogoFileID != nil { - fileRecord := &coredata.File{} - fileErr := fileRecord.LoadByID(ctx, conn, s.svc.scope, *organization.HorizontalLogoFileID) - if fileErr == nil { - base64Data, mimeType, logoErr := s.svc.fileManager.GetFileBase64(ctx, fileRecord) - if logoErr == nil { - horizontalLogoBase64 = fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data) - } - } - } - - var snapshots coredata.Snapshots - snapshotType := coredata.SnapshotsTypeProcessingActivities - - var version int - var publishedAt time.Time - - if snapshotID := filter.SnapshotID(); snapshotID != nil { - snapshot := &coredata.Snapshot{} - if err := snapshot.LoadByID(ctx, conn, s.svc.scope, *snapshotID); err != nil { - return fmt.Errorf("cannot load snapshot: %w", err) - } - publishedAt = snapshot.CreatedAt - snapshotFilter := coredata.NewSnapshotFilter(&snapshotType).WithBeforeDate(&snapshot.CreatedAt) - snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, snapshotFilter) - if err != nil { - return fmt.Errorf("cannot count processing activities snapshots: %w", err) - } - version = snapshotCount - } else { - publishedAt = time.Now() - snapshotFilter := coredata.NewSnapshotFilter(&snapshotType) - snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, snapshotFilter) - if err != nil { - return fmt.Errorf("cannot count processing activities snapshots: %w", err) - } - version = snapshotCount + 1 - } - - assessmentRows := make([]docgen.TransferImpactAssessmentRowData, len(assessments)) - for i, assessment := range assessments { - processingActivity := &coredata.ProcessingActivity{} - if err := processingActivity.LoadByID(ctx, conn, s.svc.scope, assessment.ProcessingActivityID); err != nil { - return fmt.Errorf("cannot load processing activity: %w", err) - } - - assessmentRows[i] = docgen.TransferImpactAssessmentRowData{ - ProcessingActivityName: processingActivity.Name, - DataSubjects: assessment.DataSubjects, - LegalMechanism: assessment.LegalMechanism, - Transfer: assessment.Transfer, - LocalLawRisk: assessment.LocalLawRisk, - SupplementaryMeasures: assessment.SupplementaryMeasures, - } - } - - tableData = docgen.TransferImpactAssessmentTableData{ - CompanyName: organization.Name, - CompanyHorizontalLogoBase64: horizontalLogoBase64, - Version: version, - PublishedAt: publishedAt, - Assessments: assessmentRows, - } - - return nil - }, - ) - - if err != nil { - return nil, err - } - - htmlContent, err := docgen.RenderTransferImpactAssessmentsTableHTML(tableData) - if err != nil { - return nil, fmt.Errorf("cannot render HTML: %w", err) - } - - cfg := html2pdf.RenderConfig{ - PageFormat: html2pdf.PageFormatA4, - Orientation: html2pdf.OrientationPortrait, - MarginTop: html2pdf.NewMarginInches(0.98), - MarginBottom: html2pdf.NewMarginInches(0.98), - MarginLeft: html2pdf.NewMarginInches(0.98), - MarginRight: html2pdf.NewMarginInches(0.98), - PrintBackground: true, - Scale: 1.0, - } - - pdfReader, err := s.html2pdfConverter.GeneratePDF(ctx, htmlContent, cfg) - if err != nil { - return nil, fmt.Errorf("cannot generate PDF: %w", err) - } - - pdfData, err := io.ReadAll(pdfReader) - if err != nil { - return nil, fmt.Errorf("cannot read PDF data: %w", err) - } - - return pdfData, nil -} diff --git a/pkg/server/api/console/v1/data_protection_impact_assessment_resolvers.go b/pkg/server/api/console/v1/data_protection_impact_assessment_resolvers.go index 920ad0387..a6a0d36c3 100644 --- a/pkg/server/api/console/v1/data_protection_impact_assessment_resolvers.go +++ b/pkg/server/api/console/v1/data_protection_impact_assessment_resolvers.go @@ -7,14 +7,11 @@ package console_v1 import ( "context" - "encoding/base64" "errors" - "fmt" "github.com/vikstrous/dataloadgen" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/server/api/console/v1/dataloader" "go.probo.inc/probo/pkg/server/api/console/v1/schema" @@ -87,7 +84,7 @@ func (r *dataProtectionImpactAssessmentConnectionResolver) TotalCount(ctx contex switch obj.Resolver.(type) { case *organizationResolver: - count, err := prb.DataProtectionImpactAssessments.CountForOrganizationID(ctx, obj.ParentID, obj.Filter) + count, err := prb.DataProtectionImpactAssessments.CountForOrganizationID(ctx, obj.ParentID) if err != nil { r.logger.ErrorCtx(ctx, "cannot count organization data protection impact assessments", log.Error(err)) return 0, gqlutils.Internal(ctx) @@ -269,59 +266,49 @@ func (r *mutationResolver) DeleteTransferImpactAssessment(ctx context.Context, i }, nil } -// ExportDataProtectionImpactAssessmentsPDF is the resolver for the exportDataProtectionImpactAssessmentsPDF field. -func (r *mutationResolver) ExportDataProtectionImpactAssessmentsPDF(ctx context.Context, input types.ExportDataProtectionImpactAssessmentsPDFInput) (*types.ExportDataProtectionImpactAssessmentsPDFPayload, error) { - if err := r.authorize(ctx, input.OrganizationID, probo.ActionDataProtectionImpactAssessmentExport); err != nil { +// PublishDataProtectionImpactAssessmentList is the resolver for the publishDataProtectionImpactAssessmentList field. +func (r *mutationResolver) PublishDataProtectionImpactAssessmentList(ctx context.Context, input types.PublishDataProtectionImpactAssessmentListInput) (*types.PublishDataProtectionImpactAssessmentListPayload, error) { + if err := r.authorize(ctx, input.OrganizationID, probo.ActionDataProtectionImpactAssessmentPublish); err != nil { return nil, err } prb := r.ProboService(ctx, input.OrganizationID.TenantID()) - var snapshotIDPtr *gid.GID - if input.Filter != nil { - snapshotIDPtr = input.Filter.SnapshotID - } - dpiaFilter := coredata.NewDataProtectionImpactAssessmentFilter(&snapshotIDPtr) - - pdf, err := prb.DataProtectionImpactAssessments.ExportPDF(ctx, input.OrganizationID, dpiaFilter) + document, documentVersion, err := prb.GeneratedDocuments.PublishDataProtectionImpactAssessmentList(ctx, input.OrganizationID, input.ApproverIds) if err != nil { - if errors.Is(err, coredata.ErrResourceNotFound) { - return nil, gqlutils.NotFound(ctx, err) + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) } - r.logger.ErrorCtx(ctx, "cannot export data protection impact assessments PDF", log.Error(err)) + r.logger.ErrorCtx(ctx, "cannot publish data protection impact assessment list", log.Error(err)) return nil, gqlutils.Internal(ctx) } - return &types.ExportDataProtectionImpactAssessmentsPDFPayload{ - Data: fmt.Sprintf("data:application/pdf;base64,%s", base64.StdEncoding.EncodeToString(pdf)), + return &types.PublishDataProtectionImpactAssessmentListPayload{ + DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt), + DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt), }, nil } -// ExportTransferImpactAssessmentsPDF is the resolver for the exportTransferImpactAssessmentsPDF field. -func (r *mutationResolver) ExportTransferImpactAssessmentsPDF(ctx context.Context, input types.ExportTransferImpactAssessmentsPDFInput) (*types.ExportTransferImpactAssessmentsPDFPayload, error) { - if err := r.authorize(ctx, input.OrganizationID, probo.ActionTransferImpactAssessmentExport); err != nil { +// PublishTransferImpactAssessmentList is the resolver for the publishTransferImpactAssessmentList field. +func (r *mutationResolver) PublishTransferImpactAssessmentList(ctx context.Context, input types.PublishTransferImpactAssessmentListInput) (*types.PublishTransferImpactAssessmentListPayload, error) { + if err := r.authorize(ctx, input.OrganizationID, probo.ActionTransferImpactAssessmentPublish); err != nil { return nil, err } prb := r.ProboService(ctx, input.OrganizationID.TenantID()) - var snapshotIDPtr *gid.GID - if input.Filter != nil { - snapshotIDPtr = input.Filter.SnapshotID - } - tiaFilter := coredata.NewTransferImpactAssessmentFilter(&snapshotIDPtr) - - pdf, err := prb.TransferImpactAssessments.ExportPDF(ctx, input.OrganizationID, tiaFilter) + document, documentVersion, err := prb.GeneratedDocuments.PublishTransferImpactAssessmentList(ctx, input.OrganizationID, input.ApproverIds) if err != nil { - if errors.Is(err, coredata.ErrResourceNotFound) { - return nil, gqlutils.NotFound(ctx, err) + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) } - r.logger.ErrorCtx(ctx, "cannot export transfer impact assessments PDF", log.Error(err)) + r.logger.ErrorCtx(ctx, "cannot publish transfer impact assessment list", log.Error(err)) return nil, gqlutils.Internal(ctx) } - return &types.ExportTransferImpactAssessmentsPDFPayload{ - Data: fmt.Sprintf("data:application/pdf;base64,%s", base64.StdEncoding.EncodeToString(pdf)), + return &types.PublishTransferImpactAssessmentListPayload{ + DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt), + DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt), }, nil } @@ -378,7 +365,7 @@ func (r *transferImpactAssessmentConnectionResolver) TotalCount(ctx context.Cont switch obj.Resolver.(type) { case *organizationResolver: - count, err := prb.TransferImpactAssessments.CountForOrganizationID(ctx, obj.ParentID, obj.Filter) + count, err := prb.TransferImpactAssessments.CountForOrganizationID(ctx, obj.ParentID) if err != nil { r.logger.ErrorCtx(ctx, "cannot count organization transfer impact assessments", log.Error(err)) return 0, gqlutils.Internal(ctx) diff --git a/pkg/server/api/console/v1/graphql/data_protection_impact_assessment.graphql b/pkg/server/api/console/v1/graphql/data_protection_impact_assessment.graphql index 86c1fdc6b..f2cd8d25a 100644 --- a/pkg/server/api/console/v1/graphql/data_protection_impact_assessment.graphql +++ b/pkg/server/api/console/v1/graphql/data_protection_impact_assessment.graphql @@ -52,14 +52,6 @@ input TransferImpactAssessmentOrder field: TransferImpactAssessmentOrderField! } -input DataProtectionImpactAssessmentFilter { - snapshotId: ID -} - -input TransferImpactAssessmentFilter { - snapshotId: ID -} - type DataProtectionImpactAssessment implements Node { id: ID! processingActivity: ProcessingActivity! @goField(forceResolver: true) @@ -137,12 +129,12 @@ extend type Mutation { deleteTransferImpactAssessment( input: DeleteTransferImpactAssessmentInput! ): DeleteTransferImpactAssessmentPayload! - exportDataProtectionImpactAssessmentsPDF( - input: ExportDataProtectionImpactAssessmentsPDFInput! - ): ExportDataProtectionImpactAssessmentsPDFPayload! - exportTransferImpactAssessmentsPDF( - input: ExportTransferImpactAssessmentsPDFInput! - ): ExportTransferImpactAssessmentsPDFPayload! + publishDataProtectionImpactAssessmentList( + input: PublishDataProtectionImpactAssessmentListInput! + ): PublishDataProtectionImpactAssessmentListPayload! + publishTransferImpactAssessmentList( + input: PublishTransferImpactAssessmentListInput! + ): PublishTransferImpactAssessmentListPayload! } input CreateDataProtectionImpactAssessmentInput { @@ -189,14 +181,14 @@ input DeleteTransferImpactAssessmentInput { transferImpactAssessmentId: ID! } -input ExportDataProtectionImpactAssessmentsPDFInput { +input PublishDataProtectionImpactAssessmentListInput { organizationId: ID! - filter: DataProtectionImpactAssessmentFilter + approverIds: [ID!] } -input ExportTransferImpactAssessmentsPDFInput { +input PublishTransferImpactAssessmentListInput { organizationId: ID! - filter: TransferImpactAssessmentFilter + approverIds: [ID!] } type CreateDataProtectionImpactAssessmentPayload { @@ -223,10 +215,12 @@ type DeleteTransferImpactAssessmentPayload { deletedTransferImpactAssessmentId: ID! } -type ExportDataProtectionImpactAssessmentsPDFPayload { - data: String! +type PublishDataProtectionImpactAssessmentListPayload { + documentEdge: DocumentEdge! + documentVersionEdge: DocumentVersionEdge! } -type ExportTransferImpactAssessmentsPDFPayload { - data: String! +type PublishTransferImpactAssessmentListPayload { + documentEdge: DocumentEdge! + documentVersionEdge: DocumentVersionEdge! } diff --git a/pkg/server/api/console/v1/graphql/organization.graphql b/pkg/server/api/console/v1/graphql/organization.graphql index fa6e61dc8..01c68c464 100644 --- a/pkg/server/api/console/v1/graphql/organization.graphql +++ b/pkg/server/api/console/v1/graphql/organization.graphql @@ -203,18 +203,21 @@ type Organization implements Node { last: Int before: CursorKey orderBy: DataProtectionImpactAssessmentOrder - filter: DataProtectionImpactAssessmentFilter = { snapshotId: null } ): DataProtectionImpactAssessmentConnection! @goField(forceResolver: true) + dataProtectionImpactAssessmentsDocument: Document + @goField(forceResolver: true) + transferImpactAssessments( first: Int after: CursorKey last: Int before: CursorKey orderBy: TransferImpactAssessmentOrder - filter: TransferImpactAssessmentFilter = { snapshotId: null } ): TransferImpactAssessmentConnection! @goField(forceResolver: true) + transferImpactAssessmentsDocument: Document @goField(forceResolver: true) + documents( first: Int after: CursorKey @@ -265,9 +268,10 @@ type Organization implements Node { last: Int before: CursorKey orderBy: ProcessingActivityOrder - filter: ProcessingActivityFilter = { snapshotId: null } ): ProcessingActivityConnection! @goField(forceResolver: true) + processingActivitiesDocument: Document @goField(forceResolver: true) + rightsRequests( first: Int after: CursorKey diff --git a/pkg/server/api/console/v1/graphql/processing_activity.graphql b/pkg/server/api/console/v1/graphql/processing_activity.graphql index 6b72edfed..660dea384 100644 --- a/pkg/server/api/console/v1/graphql/processing_activity.graphql +++ b/pkg/server/api/console/v1/graphql/processing_activity.graphql @@ -138,14 +138,8 @@ input ProcessingActivityOrder field: ProcessingActivityOrderField! } -input ProcessingActivityFilter { - snapshotId: ID -} - type ProcessingActivity implements Node { id: ID! - snapshotId: ID - sourceId: ID organization: Organization! @goField(forceResolver: true) name: String! purpose: String @@ -207,9 +201,9 @@ extend type Mutation { deleteProcessingActivity( input: DeleteProcessingActivityInput! ): DeleteProcessingActivityPayload! - exportProcessingActivitiesPDF( - input: ExportProcessingActivitiesPDFInput! - ): ExportProcessingActivitiesPDFPayload! + publishProcessingActivityList( + input: PublishProcessingActivityListInput! + ): PublishProcessingActivityListPayload! } input CreateProcessingActivityInput { @@ -265,9 +259,9 @@ input DeleteProcessingActivityInput { processingActivityId: ID! } -input ExportProcessingActivitiesPDFInput { +input PublishProcessingActivityListInput { organizationId: ID! - filter: ProcessingActivityFilter + approverIds: [ID!] } type CreateProcessingActivityPayload { @@ -282,6 +276,7 @@ type DeleteProcessingActivityPayload { deletedProcessingActivityId: ID! } -type ExportProcessingActivitiesPDFPayload { - data: String! +type PublishProcessingActivityListPayload { + documentEdge: DocumentEdge! + documentVersionEdge: DocumentVersionEdge! } diff --git a/pkg/server/api/console/v1/organization_resolvers.go b/pkg/server/api/console/v1/organization_resolvers.go index 6f62b7228..cbbaecdec 100644 --- a/pkg/server/api/console/v1/organization_resolvers.go +++ b/pkg/server/api/console/v1/organization_resolvers.go @@ -632,7 +632,7 @@ func (r *organizationResolver) StatementsOfApplicability(ctx context.Context, ob } // DataProtectionImpactAssessments is the resolver for the dataProtectionImpactAssessments field. -func (r *organizationResolver) DataProtectionImpactAssessments(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DataProtectionImpactAssessmentOrderBy, filter *types.DataProtectionImpactAssessmentFilter) (*types.DataProtectionImpactAssessmentConnection, error) { +func (r *organizationResolver) DataProtectionImpactAssessments(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DataProtectionImpactAssessmentOrderBy) (*types.DataProtectionImpactAssessmentConnection, error) { if err := r.authorize(ctx, obj.ID, probo.ActionDataProtectionImpactAssessmentList); err != nil { return nil, err } @@ -653,22 +653,46 @@ func (r *organizationResolver) DataProtectionImpactAssessments(ctx context.Conte cursor := types.NewCursor(first, after, last, before, pageOrderBy) - dpiaFilter := coredata.NewDataProtectionImpactAssessmentFilter(nil) - if filter != nil { - dpiaFilter = coredata.NewDataProtectionImpactAssessmentFilter(&filter.SnapshotID) - } - - page, err := prb.DataProtectionImpactAssessments.ListForOrganizationID(ctx, obj.ID, cursor, dpiaFilter) + page, err := prb.DataProtectionImpactAssessments.ListForOrganizationID(ctx, obj.ID, cursor) if err != nil { r.logger.ErrorCtx(ctx, "cannot list organization data protection impact assessments", log.Error(err)) return nil, gqlutils.Internal(ctx) } - return types.NewDataProtectionImpactAssessmentConnection(page, r, obj.ID, dpiaFilter), nil + return types.NewDataProtectionImpactAssessmentConnection(page, r, obj.ID), nil +} + +// DataProtectionImpactAssessmentsDocument is the resolver for the dataProtectionImpactAssessmentsDocument field. +func (r *organizationResolver) DataProtectionImpactAssessmentsDocument(ctx context.Context, obj *types.Organization) (*types.Document, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionDocumentGet); err != nil { + return nil, err + } + + prb := r.ProboService(ctx, obj.ID.TenantID()) + + documentID, err := prb.GeneratedDocuments.GetDataProtectionImpactAssessmentsDocumentID(ctx, obj.ID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get DPIA list document ID", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + if documentID == nil { + return nil, nil + } + + document, err := prb.Documents.Get(ctx, *documentID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, nil + } + r.logger.ErrorCtx(ctx, "cannot load DPIA list document", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewDocument(document), nil } // TransferImpactAssessments is the resolver for the transferImpactAssessments field. -func (r *organizationResolver) TransferImpactAssessments(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.TransferImpactAssessmentOrderBy, filter *types.TransferImpactAssessmentFilter) (*types.TransferImpactAssessmentConnection, error) { +func (r *organizationResolver) TransferImpactAssessments(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.TransferImpactAssessmentOrderBy) (*types.TransferImpactAssessmentConnection, error) { if err := r.authorize(ctx, obj.ID, probo.ActionTransferImpactAssessmentList); err != nil { return nil, err } @@ -689,18 +713,42 @@ func (r *organizationResolver) TransferImpactAssessments(ctx context.Context, ob cursor := types.NewCursor(first, after, last, before, pageOrderBy) - tiaFilter := coredata.NewTransferImpactAssessmentFilter(nil) - if filter != nil { - tiaFilter = coredata.NewTransferImpactAssessmentFilter(&filter.SnapshotID) - } - - page, err := prb.TransferImpactAssessments.ListForOrganizationID(ctx, obj.ID, cursor, tiaFilter) + page, err := prb.TransferImpactAssessments.ListForOrganizationID(ctx, obj.ID, cursor) if err != nil { r.logger.ErrorCtx(ctx, "cannot list organization transfer impact assessments", log.Error(err)) return nil, gqlutils.Internal(ctx) } - return types.NewTransferImpactAssessmentConnection(page, r, obj.ID, tiaFilter), nil + return types.NewTransferImpactAssessmentConnection(page, r, obj.ID), nil +} + +// TransferImpactAssessmentsDocument is the resolver for the transferImpactAssessmentsDocument field. +func (r *organizationResolver) TransferImpactAssessmentsDocument(ctx context.Context, obj *types.Organization) (*types.Document, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionDocumentGet); err != nil { + return nil, err + } + + prb := r.ProboService(ctx, obj.ID.TenantID()) + + documentID, err := prb.GeneratedDocuments.GetTransferImpactAssessmentsDocumentID(ctx, obj.ID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get TIA list document ID", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + if documentID == nil { + return nil, nil + } + + document, err := prb.Documents.Get(ctx, *documentID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, nil + } + r.logger.ErrorCtx(ctx, "cannot load TIA list document", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewDocument(document), nil } // Documents is the resolver for the documents field. @@ -868,7 +916,7 @@ func (r *organizationResolver) Obligations(ctx context.Context, obj *types.Organ } // ProcessingActivities is the resolver for the processingActivities field. -func (r *organizationResolver) ProcessingActivities(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ProcessingActivityOrderBy, filter *types.ProcessingActivityFilter) (*types.ProcessingActivityConnection, error) { +func (r *organizationResolver) ProcessingActivities(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ProcessingActivityOrderBy) (*types.ProcessingActivityConnection, error) { if err := r.authorize(ctx, obj.ID, probo.ActionProcessingActivityList); err != nil { return nil, err } @@ -889,18 +937,42 @@ func (r *organizationResolver) ProcessingActivities(ctx context.Context, obj *ty cursor := types.NewCursor(first, after, last, before, pageOrderBy) - processingActivityFilter := coredata.NewProcessingActivityFilter(nil) - if filter != nil { - processingActivityFilter = coredata.NewProcessingActivityFilter(&filter.SnapshotID) - } - - page, err := prb.ProcessingActivities.ListForOrganizationID(ctx, obj.ID, cursor, processingActivityFilter) + page, err := prb.ProcessingActivities.ListForOrganizationID(ctx, obj.ID, cursor) if err != nil { r.logger.ErrorCtx(ctx, "cannot list organization processing activities", log.Error(err)) return nil, gqlutils.Internal(ctx) } - return types.NewProcessingActivityConnection(page, r, obj.ID, filter), nil + return types.NewProcessingActivityConnection(page, r, obj.ID), nil +} + +// ProcessingActivitiesDocument is the resolver for the processingActivitiesDocument field. +func (r *organizationResolver) ProcessingActivitiesDocument(ctx context.Context, obj *types.Organization) (*types.Document, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionDocumentGet); err != nil { + return nil, err + } + + prb := r.ProboService(ctx, obj.ID.TenantID()) + + documentID, err := prb.GeneratedDocuments.GetProcessingActivitiesDocumentID(ctx, obj.ID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get processing activities document ID", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + if documentID == nil { + return nil, nil + } + + document, err := prb.Documents.Get(ctx, *documentID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, nil + } + r.logger.ErrorCtx(ctx, "cannot load processing activities document", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewDocument(document), nil } // RightsRequests is the resolver for the rightsRequests field. diff --git a/pkg/server/api/console/v1/processing_activity_resolvers.go b/pkg/server/api/console/v1/processing_activity_resolvers.go index e0f9902c0..7a497e27c 100644 --- a/pkg/server/api/console/v1/processing_activity_resolvers.go +++ b/pkg/server/api/console/v1/processing_activity_resolvers.go @@ -7,14 +7,11 @@ package console_v1 import ( "context" - "encoding/base64" "errors" - "fmt" "github.com/vikstrous/dataloadgen" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/probo" @@ -127,31 +124,26 @@ func (r *mutationResolver) DeleteProcessingActivity(ctx context.Context, input t }, nil } -// ExportProcessingActivitiesPDF is the resolver for the exportProcessingActivitiesPDF field. -func (r *mutationResolver) ExportProcessingActivitiesPDF(ctx context.Context, input types.ExportProcessingActivitiesPDFInput) (*types.ExportProcessingActivitiesPDFPayload, error) { - if err := r.authorize(ctx, input.OrganizationID, probo.ActionProcessingActivityExport); err != nil { +// PublishProcessingActivityList is the resolver for the publishProcessingActivityList field. +func (r *mutationResolver) PublishProcessingActivityList(ctx context.Context, input types.PublishProcessingActivityListInput) (*types.PublishProcessingActivityListPayload, error) { + if err := r.authorize(ctx, input.OrganizationID, probo.ActionProcessingActivityPublish); err != nil { return nil, err } prb := r.ProboService(ctx, input.OrganizationID.TenantID()) - var snapshotIDPtr *gid.GID - if input.Filter != nil { - snapshotIDPtr = input.Filter.SnapshotID - } - processingActivityFilter := coredata.NewProcessingActivityFilter(&snapshotIDPtr) - - pdf, err := prb.ProcessingActivities.ExportPDF(ctx, input.OrganizationID, processingActivityFilter) + document, documentVersion, err := prb.GeneratedDocuments.PublishProcessingActivityList(ctx, input.OrganizationID, input.ApproverIds) if err != nil { - if errors.Is(err, coredata.ErrResourceNotFound) { - return nil, gqlutils.NotFound(ctx, err) + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) } - r.logger.ErrorCtx(ctx, "cannot export processing activities PDF", log.Error(err)) + r.logger.ErrorCtx(ctx, "cannot publish processing activity list", log.Error(err)) return nil, gqlutils.Internal(ctx) } - return &types.ExportProcessingActivitiesPDFPayload{ - Data: fmt.Sprintf("data:application/pdf;base64,%s", base64.StdEncoding.EncodeToString(pdf)), + return &types.PublishProcessingActivityListPayload{ + DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt), + DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt), }, nil } @@ -286,12 +278,7 @@ func (r *processingActivityConnectionResolver) TotalCount(ctx context.Context, o switch obj.Resolver.(type) { case *organizationResolver: - processingActivityFilter := coredata.NewProcessingActivityFilter(nil) - if obj.Filter != nil { - processingActivityFilter = coredata.NewProcessingActivityFilter(&obj.Filter.SnapshotID) - } - - count, err := prb.ProcessingActivities.CountForOrganizationID(ctx, obj.ParentID, processingActivityFilter) + count, err := prb.ProcessingActivities.CountForOrganizationID(ctx, obj.ParentID) if err != nil { r.logger.ErrorCtx(ctx, "cannot count organization processing activities", log.Error(err)) return 0, gqlutils.Internal(ctx) diff --git a/pkg/server/api/console/v1/types/data_protection_impact_assessment.go b/pkg/server/api/console/v1/types/data_protection_impact_assessment.go index 24b70de85..1ccd4a533 100644 --- a/pkg/server/api/console/v1/types/data_protection_impact_assessment.go +++ b/pkg/server/api/console/v1/types/data_protection_impact_assessment.go @@ -30,7 +30,6 @@ type ( Resolver any ParentID gid.GID - Filter *coredata.DataProtectionImpactAssessmentFilter } ) @@ -38,7 +37,6 @@ func NewDataProtectionImpactAssessmentConnection( p *page.Page[*coredata.DataProtectionImpactAssessment, coredata.DataProtectionImpactAssessmentOrderField], parentType any, parentID gid.GID, - filter *coredata.DataProtectionImpactAssessmentFilter, ) *DataProtectionImpactAssessmentConnection { edges := make([]*DataProtectionImpactAssessmentEdge, len(p.Data)) for i, dpia := range p.Data { @@ -51,7 +49,6 @@ func NewDataProtectionImpactAssessmentConnection( Resolver: parentType, ParentID: parentID, - Filter: filter, } } diff --git a/pkg/server/api/console/v1/types/processing_activity.go b/pkg/server/api/console/v1/types/processing_activity.go index 054728597..ee576cce2 100644 --- a/pkg/server/api/console/v1/types/processing_activity.go +++ b/pkg/server/api/console/v1/types/processing_activity.go @@ -30,7 +30,6 @@ type ( Resolver any ParentID gid.GID - Filter *ProcessingActivityFilter } ) @@ -38,7 +37,6 @@ func NewProcessingActivityConnection( p *page.Page[*coredata.ProcessingActivity, coredata.ProcessingActivityOrderField], parentType any, parentID gid.GID, - filter *ProcessingActivityFilter, ) *ProcessingActivityConnection { edges := make([]*ProcessingActivityEdge, len(p.Data)) for i, processingActivity := range p.Data { @@ -51,7 +49,6 @@ func NewProcessingActivityConnection( Resolver: parentType, ParentID: parentID, - Filter: filter, } } @@ -68,8 +65,6 @@ func NewProcessingActivity(par *coredata.ProcessingActivity) *ProcessingActivity Organization: &Organization{ ID: par.OrganizationID, }, - SnapshotID: par.SnapshotID, - SourceID: par.SourceID, Name: par.Name, Purpose: par.Purpose, DataSubjectCategory: par.DataSubjectCategory, diff --git a/pkg/server/api/console/v1/types/transfer_impact_assessment.go b/pkg/server/api/console/v1/types/transfer_impact_assessment.go index 24bf9ec00..89d0086b4 100644 --- a/pkg/server/api/console/v1/types/transfer_impact_assessment.go +++ b/pkg/server/api/console/v1/types/transfer_impact_assessment.go @@ -30,7 +30,6 @@ type ( Resolver any ParentID gid.GID - Filter *coredata.TransferImpactAssessmentFilter } ) @@ -38,7 +37,6 @@ func NewTransferImpactAssessmentConnection( p *page.Page[*coredata.TransferImpactAssessment, coredata.TransferImpactAssessmentOrderField], parentType any, parentID gid.GID, - filter *coredata.TransferImpactAssessmentFilter, ) *TransferImpactAssessmentConnection { edges := make([]*TransferImpactAssessmentEdge, len(p.Data)) for i, tia := range p.Data { @@ -51,7 +49,6 @@ func NewTransferImpactAssessmentConnection( Resolver: parentType, ParentID: parentID, - Filter: filter, } } diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 17a7e2876..5286185a0 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -956,13 +956,7 @@ func (r *Resolver) ListProcessingActivitiesTool(ctx context.Context, req *mcp.Ca cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) - noSnapshot := (*gid.GID)(nil) - filter := coredata.NewProcessingActivityFilter(&noSnapshot) - if input.Filter != nil { - filter = coredata.NewProcessingActivityFilter(&input.Filter.SnapshotID) - } - - page, err := prb.ProcessingActivities.ListForOrganizationID(ctx, input.OrganizationID, cursor, filter) + page, err := prb.ProcessingActivities.ListForOrganizationID(ctx, input.OrganizationID, cursor) if err != nil { panic(fmt.Errorf("cannot list organization processing activities: %w", err)) } @@ -1103,13 +1097,7 @@ func (r *Resolver) ListDataProtectionImpactAssessmentsTool(ctx context.Context, cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) - noSnapshot := (*gid.GID)(nil) - filter := coredata.NewDataProtectionImpactAssessmentFilter(&noSnapshot) - if input.Filter != nil { - filter = coredata.NewDataProtectionImpactAssessmentFilter(&input.Filter.SnapshotID) - } - - page, err := prb.DataProtectionImpactAssessments.ListForOrganizationID(ctx, input.OrganizationID, cursor, filter) + page, err := prb.DataProtectionImpactAssessments.ListForOrganizationID(ctx, input.OrganizationID, cursor) if err != nil { panic(fmt.Errorf("cannot list organization data protection impact assessments: %w", err)) } @@ -1200,13 +1188,7 @@ func (r *Resolver) ListTransferImpactAssessmentsTool(ctx context.Context, req *m cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) - noSnapshot := (*gid.GID)(nil) - filter := coredata.NewTransferImpactAssessmentFilter(&noSnapshot) - if input.Filter != nil { - filter = coredata.NewTransferImpactAssessmentFilter(&input.Filter.SnapshotID) - } - - page, err := prb.TransferImpactAssessments.ListForOrganizationID(ctx, input.OrganizationID, cursor, filter) + page, err := prb.TransferImpactAssessments.ListForOrganizationID(ctx, input.OrganizationID, cursor) if err != nil { panic(fmt.Errorf("cannot list organization transfer impact assessments: %w", err)) } @@ -4810,3 +4792,51 @@ func (r *Resolver) PublishObligationListTool(ctx context.Context, req *mcp.CallT DocumentVersionID: documentVersion.ID, }, nil } + +func (r *Resolver) PublishProcessingActivityListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishProcessingActivityListInput) (*mcp.CallToolResult, types.PublishProcessingActivityListOutput, error) { + r.MustAuthorize(ctx, input.OrganizationID, probo.ActionProcessingActivityPublish) + + svc := r.ProboService(ctx, input.OrganizationID) + + document, documentVersion, err := svc.GeneratedDocuments.PublishProcessingActivityList(ctx, input.OrganizationID, input.ApproverIds) + if err != nil { + return nil, types.PublishProcessingActivityListOutput{}, fmt.Errorf("cannot publish processing activity list: %w", err) + } + + return nil, types.PublishProcessingActivityListOutput{ + DocumentID: document.ID, + DocumentVersionID: documentVersion.ID, + }, nil +} + +func (r *Resolver) PublishDataProtectionImpactAssessmentListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishDataProtectionImpactAssessmentListInput) (*mcp.CallToolResult, types.PublishDataProtectionImpactAssessmentListOutput, error) { + r.MustAuthorize(ctx, input.OrganizationID, probo.ActionDataProtectionImpactAssessmentPublish) + + svc := r.ProboService(ctx, input.OrganizationID) + + document, documentVersion, err := svc.GeneratedDocuments.PublishDataProtectionImpactAssessmentList(ctx, input.OrganizationID, input.ApproverIds) + if err != nil { + return nil, types.PublishDataProtectionImpactAssessmentListOutput{}, fmt.Errorf("cannot publish DPIA list: %w", err) + } + + return nil, types.PublishDataProtectionImpactAssessmentListOutput{ + DocumentID: document.ID, + DocumentVersionID: documentVersion.ID, + }, nil +} + +func (r *Resolver) PublishTransferImpactAssessmentListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishTransferImpactAssessmentListInput) (*mcp.CallToolResult, types.PublishTransferImpactAssessmentListOutput, error) { + r.MustAuthorize(ctx, input.OrganizationID, probo.ActionTransferImpactAssessmentPublish) + + svc := r.ProboService(ctx, input.OrganizationID) + + document, documentVersion, err := svc.GeneratedDocuments.PublishTransferImpactAssessmentList(ctx, input.OrganizationID, input.ApproverIds) + if err != nil { + return nil, types.PublishTransferImpactAssessmentListOutput{}, fmt.Errorf("cannot publish TIA list: %w", err) + } + + return nil, types.PublishTransferImpactAssessmentListOutput{ + DocumentID: document.ID, + DocumentVersionID: documentVersion.ID, + }, nil +} diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 9512a568a..c6f4a1d32 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -3709,15 +3709,6 @@ components: cursor: $ref: "#/components/schemas/CursorKey" description: Page cursor - filter: - type: object - properties: - snapshot_id: - anyOf: - - $ref: "#/components/schemas/GID" - - type: "null" - description: Filter by snapshot ID. Defaults to null, which returns only processing activities with no snapshot (current live data). Pass a specific snapshot ID to retrieve processing activities as they were at that snapshot. - default: null ListProcessingActivitiesOutput: type: object @@ -4016,15 +4007,6 @@ components: type: integer cursor: $ref: "#/components/schemas/CursorKey" - filter: - type: object - properties: - snapshot_id: - anyOf: - - $ref: "#/components/schemas/GID" - - type: "null" - description: Filter by snapshot ID. Defaults to null, which returns only DPIAs with no snapshot (current live data). Pass a specific snapshot ID to retrieve DPIAs as they were at that snapshot. - default: null ListDataProtectionImpactAssessmentsOutput: type: object @@ -4200,15 +4182,6 @@ components: type: integer cursor: $ref: "#/components/schemas/CursorKey" - filter: - type: object - properties: - snapshot_id: - anyOf: - - $ref: "#/components/schemas/GID" - - type: "null" - description: Filter by snapshot ID. Defaults to null, which returns only TIAs with no snapshot (current live data). Pass a specific snapshot ID to retrieve TIAs as they were at that snapshot. - default: null ListTransferImpactAssessmentsOutput: type: object @@ -7065,6 +7038,87 @@ components: $ref: "#/components/schemas/GID" description: Created document version ID + PublishProcessingActivityListInput: + type: object + required: + - organization_id + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + approver_ids: + type: array + items: + $ref: "#/components/schemas/GID" + description: Optional approver profile IDs. If provided, creates a draft pending approval instead of publishing immediately. + + PublishProcessingActivityListOutput: + type: object + required: + - document_id + - document_version_id + properties: + document_id: + $ref: "#/components/schemas/GID" + description: Created or updated document ID + document_version_id: + $ref: "#/components/schemas/GID" + description: Created document version ID + + PublishDataProtectionImpactAssessmentListInput: + type: object + required: + - organization_id + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + approver_ids: + type: array + items: + $ref: "#/components/schemas/GID" + description: Optional approver profile IDs. If provided, creates a draft pending approval instead of publishing immediately. + + PublishDataProtectionImpactAssessmentListOutput: + type: object + required: + - document_id + - document_version_id + properties: + document_id: + $ref: "#/components/schemas/GID" + description: Created or updated document ID + document_version_id: + $ref: "#/components/schemas/GID" + description: Created document version ID + + PublishTransferImpactAssessmentListInput: + type: object + required: + - organization_id + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + approver_ids: + type: array + items: + $ref: "#/components/schemas/GID" + description: Optional approver profile IDs. If provided, creates a draft pending approval instead of publishing immediately. + + PublishTransferImpactAssessmentListOutput: + type: object + required: + - document_id + - document_version_id + properties: + document_id: + $ref: "#/components/schemas/GID" + description: Created or updated document ID + document_version_id: + $ref: "#/components/schemas/GID" + description: Created document version ID + PublishStatementOfApplicabilityInput: type: object required: @@ -10318,6 +10372,30 @@ tools: $ref: "#/components/schemas/PublishObligationListInput" outputSchema: $ref: "#/components/schemas/PublishObligationListOutput" + - name: publishProcessingActivityList + description: Publish the processing activity register for an organization as a document. If a document already exists, a new version is created. + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/PublishProcessingActivityListInput" + outputSchema: + $ref: "#/components/schemas/PublishProcessingActivityListOutput" + - name: publishDataProtectionImpactAssessmentList + description: Publish the Data Protection Impact Assessment register for an organization as a document. If a document already exists, a new version is created. + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/PublishDataProtectionImpactAssessmentListInput" + outputSchema: + $ref: "#/components/schemas/PublishDataProtectionImpactAssessmentListOutput" + - name: publishTransferImpactAssessmentList + description: Publish the Transfer Impact Assessment register for an organization as a document. If a document already exists, a new version is created. + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/PublishTransferImpactAssessmentListInput" + outputSchema: + $ref: "#/components/schemas/PublishTransferImpactAssessmentListOutput" - name: publishStatementOfApplicability description: Publish a statement of applicability as a document. If a document already exists, a new version is created. hints: