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 <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-28 16:07:18 +02:00
parent be294599ec
commit ba8bce2ad3
60 changed files with 5461 additions and 3601 deletions

View File

@@ -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

View File

@@ -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<ProcessingActivityGraphListQuery>;
@@ -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 (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<PageHeader
title={__("Processing Activities")}
description={__("Manage your processing activities under GDPR")}
>
{(canExportPDF || canExportDPIAPDF || canExportTIAPDF) && (
<Dropdown
toggle={(
<Button
variant="secondary"
icon={IconArrowDown}
iconAfter={IconChevronDown}
>
{__("Export")}
</Button>
)}
>
{canExportPDF && (
<DropdownItem
onClick={() => void handleExportPDF()}
disabled={isExportingPDF}
icon={isExportingPDF ? Spinner : undefined}
>
{__("Processing Activities")}
</DropdownItem>
)}
{canExportDPIAPDF && (
<DropdownItem
onClick={() => void handleExportDPIAPDF()}
disabled={isExportingDPIAPDF}
icon={isExportingDPIAPDF ? Spinner : undefined}
>
{__("Data Protection Impact Assessments")}
</DropdownItem>
)}
{canExportTIAPDF && (
<DropdownItem
onClick={() => void handleExportTIAPDF()}
disabled={isExportingTIAPDF}
icon={isExportingTIAPDF ? Spinner : undefined}
>
{__("Transfer Impact Assessments")}
</DropdownItem>
)}
</Dropdown>
)}
{!isSnapshotMode
&& activeTab === "activities"
{activeTab === "activities"
&& organization.node.canCreateProcessingActivity && (
<CreateProcessingActivityDialog
organizationId={organizationId}
@@ -505,6 +305,84 @@ export default function ProcessingActivitiesPage({
</TabItem>
</Tabs>
<div className="flex justify-end gap-2">
{activeTab === "activities" && (
<>
{paDocument?.id && (
<Button variant="secondary" asChild>
<Link
to={`/organizations/${organizationId}/documents/${paDocument.id}`}
>
<IconPageTextLine size={16} />
{__("Document")}
</Link>
</Button>
)}
{canPublishProcessingActivities && (
<PublishProcessingActivityListDialog
organizationId={organizationId}
defaultApproverIds={paDefaultApproverIds}
onPublished={goToDocument}
>
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishProcessingActivityListDialog>
)}
</>
)}
{activeTab === "dpia" && (
<>
{dpiaDocument?.id && (
<Button variant="secondary" asChild>
<Link
to={`/organizations/${organizationId}/documents/${dpiaDocument.id}`}
>
<IconPageTextLine size={16} />
{__("Document")}
</Link>
</Button>
)}
{canPublishDPIA && (
<PublishDataProtectionImpactAssessmentListDialog
organizationId={organizationId}
defaultApproverIds={dpiaDefaultApproverIds}
onPublished={goToDocument}
>
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishDataProtectionImpactAssessmentListDialog>
)}
</>
)}
{activeTab === "tia" && (
<>
{tiaDocument?.id && (
<Button variant="secondary" asChild>
<Link
to={`/organizations/${organizationId}/documents/${tiaDocument.id}`}
>
<IconPageTextLine size={16} />
{__("Document")}
</Link>
</Button>
)}
{canPublishTIA && (
<PublishTransferImpactAssessmentListDialog
organizationId={organizationId}
defaultApproverIds={tiaDefaultApproverIds}
onPublished={goToDocument}
>
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishTransferImpactAssessmentListDialog>
)}
</>
)}
</div>
{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<ProcessingActivityGraphDeleteMutation>(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 (
<Tr to={activityUrl}>
@@ -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 (
<Tr to={activityUrl}>
@@ -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 (
<Tr to={activityUrl}>

View File

@@ -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 (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<div className="flex items-center justify-between">
<Breadcrumb
items={[
@@ -444,7 +431,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
{ label: activity.name! },
]}
/>
{!isSnapshotMode && activity.canDelete && (
{activity.canDelete && (
<ActionDropdown>
<DropdownItem onClick={deleteActivity} variant="danger">
{__("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}
/>
<div>
@@ -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}
>
<RoleOptions />
</Select>
@@ -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}
/>
</div>
@@ -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}
/>
<Field
label={__("Personal Data Category")}
{...register("personalDataCategory")}
placeholder={__("e.g., contact details, financial data")}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
<div>
@@ -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}
>
<SpecialOrCriminalDataOptions />
</Select>
@@ -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}
/>
<div>
@@ -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}
>
<LawfulBasisOptions />
</Select>
@@ -620,7 +607,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
id="lastReviewDate"
type="date"
{...register("lastReviewDate")}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
</div>
@@ -632,7 +619,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
id="nextReviewDate"
type="date"
{...register("nextReviewDate")}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
</div>
@@ -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}
/>
</div>
@@ -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}
/>
<Field
label={__("Location")}
{...register("location")}
placeholder={__("Where is the data processed")}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
<Controller
@@ -670,7 +657,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
<Checkbox
checked={field.value ?? false}
onChange={field.onChange}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
<span>
{__("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}
>
<TransferSafeguardsOptions />
</Select>
@@ -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}
/>
<div>
@@ -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}
/>
</div>
@@ -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}
>
<DataProtectionImpactAssessmentOptions />
</Select>
@@ -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}
>
<TransferImpactAssessmentOptions />
</Select>
@@ -796,24 +783,22 @@ export default function ProcessingActivityDetailsPage(props: Props) {
name="vendorIds"
selectedVendors={vendors}
label={__("Vendors")}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
{!isSnapshotMode && (
<div className="flex justify-end pt-4">
{activity.canUpdate && (
<Button
type="submit"
variant="primary"
disabled={formState.isSubmitting}
>
{formState.isSubmitting
? __("Saving...")
: __("Save Changes")}
</Button>
)}
</div>
)}
<div className="flex justify-end pt-4">
{activity.canUpdate && (
<Button
type="submit"
variant="primary"
disabled={formState.isSubmitting}
>
{formState.isSubmitting
? __("Saving...")
: __("Save Changes")}
</Button>
)}
</div>
</form>
</div>
</Card>
@@ -829,7 +814,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
<h2 className="text-xl font-semibold mb-6 text-center">
{__("Data Protection Impact Assessment")}
</h2>
{!isSnapshotMode && activity.canCreateDPIA && (
{activity.canCreateDPIA && (
<Button
variant="primary"
onClick={() => setShowDpiaForm(true)}
@@ -845,8 +830,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
<h2 className="text-xl font-semibold">
{__("Data Protection Impact Assessment")}
</h2>
{!isSnapshotMode
&& activity?.dataProtectionImpactAssessment?.id
{activity?.dataProtectionImpactAssessment?.id
&& !dpiaDeleted
&& activity.dataProtectionImpactAssessment.canDelete && (
<Button variant="danger" onClick={deleteDPIA}>
@@ -867,7 +851,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Describe the processing activity and its purpose",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateDPIA}
disabled={!canCreateOrUpdateDPIA}
/>
</div>
@@ -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}
/>
</div>
@@ -897,7 +881,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Describe the potential risks to data subjects",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateDPIA}
disabled={!canCreateOrUpdateDPIA}
/>
</div>
@@ -912,7 +896,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Describe measures to mitigate the identified risks",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateDPIA}
disabled={!canCreateOrUpdateDPIA}
/>
</div>
@@ -930,7 +914,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
onValueChange={field.onChange}
value={field.value}
className="w-full"
disabled={isSnapshotMode || !canCreateOrUpdateDPIA}
disabled={!canCreateOrUpdateDPIA}
>
<Option value="LOW">{__("Low")}</Option>
<Option value="MEDIUM">{__("Medium")}</Option>
@@ -940,37 +924,35 @@ export default function ProcessingActivityDetailsPage(props: Props) {
/>
</div>
{!isSnapshotMode && (
<div className="flex justify-end gap-3 pt-4">
{(!activity?.dataProtectionImpactAssessment?.id
|| dpiaDeleted) && (
<Button
type="button"
variant="secondary"
onClick={() => setShowDpiaForm(false)}
>
{__("Cancel")}
</Button>
)}
{(activity?.dataProtectionImpactAssessment?.id
&& !dpiaDeleted
? activity.dataProtectionImpactAssessment.canUpdate
: activity.canCreateDPIA) && (
<Button
type="submit"
variant="primary"
disabled={dpiaSubmitting}
>
{dpiaSubmitting
? __("Saving...")
: activity?.dataProtectionImpactAssessment?.id
&& !dpiaDeleted
? __("Update DPIA")
: __("Create DPIA")}
</Button>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-4">
{(!activity?.dataProtectionImpactAssessment?.id
|| dpiaDeleted) && (
<Button
type="button"
variant="secondary"
onClick={() => setShowDpiaForm(false)}
>
{__("Cancel")}
</Button>
)}
{(activity?.dataProtectionImpactAssessment?.id
&& !dpiaDeleted
? activity.dataProtectionImpactAssessment.canUpdate
: activity.canCreateDPIA) && (
<Button
type="submit"
variant="primary"
disabled={dpiaSubmitting}
>
{dpiaSubmitting
? __("Saving...")
: activity?.dataProtectionImpactAssessment?.id
&& !dpiaDeleted
? __("Update DPIA")
: __("Create DPIA")}
</Button>
)}
</div>
</form>
</>
)}
@@ -988,7 +970,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
<h2 className="text-xl font-semibold mb-6 text-center">
{__("Transfer Impact Assessment")}
</h2>
{!isSnapshotMode && activity.canCreateTIA && (
{activity.canCreateTIA && (
<Button
variant="primary"
onClick={() => setShowTiaForm(true)}
@@ -1004,8 +986,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
<h2 className="text-xl font-semibold">
{__("Transfer Impact Assessment")}
</h2>
{!isSnapshotMode
&& activity?.transferImpactAssessment?.id
{activity?.transferImpactAssessment?.id
&& !tiaDeleted
&& activity.transferImpactAssessment.canDelete && (
<Button variant="danger" onClick={deleteTIA}>
@@ -1026,7 +1007,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Describe the data subjects involved in the transfer",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateTIA}
disabled={!canCreateOrUpdateTIA}
/>
</div>
@@ -1041,7 +1022,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Describe the legal mechanism for the transfer (e.g., SCCs, BCRs, adequacy decision)",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateTIA}
disabled={!canCreateOrUpdateTIA}
/>
</div>
@@ -1054,7 +1035,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Describe the nature and details of the data transfer",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateTIA}
disabled={!canCreateOrUpdateTIA}
/>
</div>
@@ -1069,7 +1050,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Assess the risks related to the local laws of the destination country",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateTIA}
disabled={!canCreateOrUpdateTIA}
/>
</div>
@@ -1084,40 +1065,38 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Describe any supplementary measures taken to ensure adequate protection",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateTIA}
disabled={!canCreateOrUpdateTIA}
/>
</div>
{!isSnapshotMode && (
<div className="flex justify-end gap-3 pt-4">
{(!activity?.transferImpactAssessment?.id
|| tiaDeleted) && (
<Button
type="button"
variant="secondary"
onClick={() => setShowTiaForm(false)}
>
{__("Cancel")}
</Button>
)}
{(activity?.transferImpactAssessment?.id && !tiaDeleted
? activity.transferImpactAssessment.canUpdate
: activity.canCreateTIA) && (
<Button
type="submit"
variant="primary"
disabled={tiaSubmitting}
>
{tiaSubmitting
? __("Saving...")
: activity?.transferImpactAssessment?.id
&& !tiaDeleted
? __("Update TIA")
: __("Create TIA")}
</Button>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-4">
{(!activity?.transferImpactAssessment?.id
|| tiaDeleted) && (
<Button
type="button"
variant="secondary"
onClick={() => setShowTiaForm(false)}
>
{__("Cancel")}
</Button>
)}
{(activity?.transferImpactAssessment?.id && !tiaDeleted
? activity.transferImpactAssessment.canUpdate
: activity.canCreateTIA) && (
<Button
type="submit"
variant="primary"
disabled={tiaSubmitting}
>
{tiaSubmitting
? __("Saving...")
: activity?.transferImpactAssessment?.id
&& !tiaDeleted
? __("Update TIA")
: __("Create TIA")}
</Button>
)}
</div>
</form>
</>
)}

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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<PublishDataProtectionImpactAssessmentListDialogMutation>(publishMutation);
const approverIds = watch("approverIds");
const hasApprovers = approverIds.length > 0;
const onSubmit = (data: z.infer<typeof schema>) => {
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 (
<Dialog
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Data Protection Impact Assessments")}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>
<div className="space-y-4">
<p className="text-sm text-txt-secondary">
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
</p>
<PeopleMultiSelectField
name="approverIds"
label={__("Approvers")}
control={control}
organizationId={organizationId}
placeholder={__("Add approvers...")}
/>
</div>
</DialogContent>
<DialogFooter>
<Button
type="submit"
icon={hasApprovers ? IconSend : IconUpload}
disabled={isPublishing}
>
{hasApprovers ? __("Request approval") : __("Publish")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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<PublishProcessingActivityListDialogMutation>(publishMutation);
const approverIds = watch("approverIds");
const hasApprovers = approverIds.length > 0;
const onSubmit = (data: z.infer<typeof schema>) => {
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 (
<Dialog
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Processing Activities")}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>
<div className="space-y-4">
<p className="text-sm text-txt-secondary">
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
</p>
<PeopleMultiSelectField
name="approverIds"
label={__("Approvers")}
control={control}
organizationId={organizationId}
placeholder={__("Add approvers...")}
/>
</div>
</DialogContent>
<DialogFooter>
<Button
type="submit"
icon={hasApprovers ? IconSend : IconUpload}
disabled={isPublishing}
>
{hasApprovers ? __("Request approval") : __("Publish")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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<PublishTransferImpactAssessmentListDialogMutation>(publishMutation);
const approverIds = watch("approverIds");
const hasApprovers = approverIds.length > 0;
const onSubmit = (data: z.infer<typeof schema>) => {
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 (
<Dialog
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Transfer Impact Assessments")}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>
<div className="space-y-4">
<p className="text-sm text-txt-secondary">
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
</p>
<PeopleMultiSelectField
name="approverIds"
label={__("Approvers")}
control={control}
organizationId={organizationId}
placeholder={__("Add approvers...")}
/>
</div>
</DialogContent>
<DialogFooter>
<Button
type="submit"
icon={hasApprovers ? IconSend : IconUpload}
disabled={isPublishing}
>
{hasApprovers ? __("Request approval") : __("Publish")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -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<ProcessingActivityGraphListQuery>(
coreEnvironment,
processingActivitiesQuery,
{
organizationId: organizationId,
snapshotId,
},
),
),
@@ -89,23 +68,4 @@ export const processingActivityRoutes = [
),
),
},
{
path: "snapshots/:snapshotId/processing-activities/:activityId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ activityId }) =>
loadQuery<ProcessingActivityGraphNodeQuery>(
coreEnvironment,
processingActivityNodeQuery,
{
processingActivityId: activityId,
},
),
),
Component: withQueryRef(
lazy(
() =>
import("#/pages/organizations/processingActivities/ProcessingActivityDetailsPage"),
),
),
},
] satisfies AppRoute[];

View File

@@ -0,0 +1,758 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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...)
}

View File

@@ -0,0 +1,305 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,348 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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)
},
)
}

View File

@@ -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)
})
}

View File

@@ -0,0 +1,304 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -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,
};

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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<INodeExecutionData> {
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<string, unknown> = { 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 },
};
}

View File

@@ -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,
};

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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<INodeExecutionData> {
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<string, unknown> = { 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 },
};
}

View File

@@ -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,
};

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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<INodeExecutionData> {
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<string, unknown> = { 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 },
};
}

View File

@@ -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
}

View File

@@ -0,0 +1,148 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -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
}

View File

@@ -0,0 +1,148 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,148 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,18 @@
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
--
-- 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;

View File

@@ -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
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -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
}

View File

@@ -38,7 +38,6 @@ func SnapshotsTypes() []SnapshotsType {
return []SnapshotsType{
SnapshotsTypeRisks,
SnapshotsTypeVendors,
SnapshotsTypeProcessingActivities,
}
}

View File

@@ -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:

View File

@@ -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
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -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{},

View File

@@ -1,316 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Data Protection Impact Assessments Export</title>
<style>
@page {
size: A4;
margin: 2.5cm;
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-family: Arial, sans-serif;
font-size: 9pt;
color: #666;
}
}
body {
font-family: Arial, sans-serif;
font-size: 7.5pt;
line-height: 1.4;
color: #333;
margin: 0;
padding: 0;
background: white;
}
/* Cover page */
.cover-page {
page-break-after: always;
}
.company-header {
margin-bottom: 30px;
page-break-after: avoid;
}
.company-logo {
max-height: 50px;
max-width: 100%;
height: auto;
width: auto;
display: block;
}
.company-name {
font-size: 16pt;
color: #1a1a1a;
margin-top: 10px;
font-weight: 600;
}
.export-title {
font-size: 22pt;
font-weight: normal;
color: #1a1a1a;
margin: 0 0 25px 0;
text-align: left;
}
.document-meta {
margin: 0 0 30px 0;
font-size: 9pt;
}
.meta-table {
width: 100%;
border-collapse: collapse;
border: 1px solid #333;
}
.meta-table td {
padding: 6px 8px;
border: 1px solid #333;
vertical-align: middle;
}
.meta-table td:first-child {
font-weight: 600;
width: 25%;
background: #f8f8f8;
color: #333;
}
.classification {
font-weight: bold;
text-transform: uppercase;
}
.purpose-section {
margin: 30px 0;
page-break-after: avoid;
}
.purpose-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.purpose-text {
font-size: 10pt;
color: #333;
line-height: 1.5;
text-align: justify;
}
/* Assessment page */
.assessment-page {
page-break-before: always;
page-break-after: always;
}
.assessment-page:last-child {
page-break-after: auto;
}
.assessment-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.assessment-field {
margin-bottom: 8px;
page-break-inside: avoid;
}
.assessment-field-label {
font-size: 10pt;
font-weight: bold;
color: #333;
margin: 12px 0 6px 0;
page-break-after: avoid;
page-break-inside: avoid;
}
.assessment-field-value {
color: #000;
font-size: 7.5pt;
line-height: 1.4;
}
.empty-value {
color: #999;
font-style: italic;
}
/* Annex page */
.annex-page {
page-break-before: always;
}
.annex-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.annex-section {
margin-bottom: 20px;
}
.annex-section-title {
font-size: 13pt;
font-weight: bold;
color: #000;
margin: 15px 0 10px 0;
}
.annex-subsection-title {
font-size: 10pt;
font-weight: bold;
color: #333;
margin: 12px 0 8px 0;
}
.annex-enum-list {
margin: 10px 0;
padding-left: 20px;
}
.annex-enum-item {
margin-bottom: 8px;
font-size: 10pt;
line-height: 1.5;
}
.annex-enum-name {
font-weight: 600;
color: #333;
}
.annex-enum-description {
color: #000;
margin-left: 5px;
}
@media print {
body {
background: white;
}
}
</style>
</head>
<body>
<div class="cover-page">
<div class="company-header">
{{- if .CompanyHorizontalLogoBase64}}
{{imgTag .CompanyHorizontalLogoBase64 "Company Logo" "company-logo"}}
{{- else}}
<div class="company-name">{{.CompanyName}}</div>
{{- end}}
</div>
<h1 class="export-title">Data Protection Impact Assessments</h1>
<div class="document-meta">
<table class="meta-table">
<tr>
<td>Classification</td>
<td>
<span class="classification">CONFIDENTIAL</span>
</td>
</tr>
<tr>
<td>Version</td>
<td>{{.Version}}</td>
</tr>
<tr>
<td>Published</td>
<td>{{.PublishedAt.Format "January 2, 2006"}}</td>
</tr>
</table>
</div>
<div class="purpose-section">
<div class="purpose-title">1. Purpose</div>
<div class="purpose-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.
</div>
</div>
</div>
{{- range $index, $assessment := .Assessments}}
<div class="assessment-page">
{{if eq $index 0}}
<h1 class="annex-title">2. Records</h1>
{{end}}
<h1 class="annex-section-title">2.{{add $index 1}} {{$assessment.ProcessingActivityName}}</h1>
<div class="assessment-field">
<div class="assessment-field-label">Description</div>
<div class="assessment-field-value">{{if .Description}}{{.Description}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Necessity and Proportionality</div>
<div class="assessment-field-value">{{if .NecessityAndProportionality}}{{.NecessityAndProportionality}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Potential Risk</div>
<div class="assessment-field-value">{{if .PotentialRisk}}{{.PotentialRisk}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Mitigations</div>
<div class="assessment-field-value">{{if .Mitigations}}{{.Mitigations}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Residual Risk</div>
<div class="assessment-field-value">{{if .ResidualRisk}}{{.ResidualRisk | formatResidualRisk}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
</div>
{{- end}}
<div class="annex-page">
<h1 class="annex-title">3. Annexes</h1>
<div class="annex-section">
<div class="annex-section-title">3.1 Lexicon</div>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Residual Risk</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Low:</span>
<span class="annex-enum-description">The residual risk after implementing mitigation measures is considered low. The processing activity poses minimal risk to individuals' rights and freedoms.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Medium:</span>
<span class="annex-enum-description">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.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">High:</span>
<span class="annex-enum-description">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.</span>
</li>
</ul>
</div>
</div>
</body>
</html>

View File

@@ -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
}

View File

@@ -1,536 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Processing Activities Export</title>
<style>
@page {
size: A4;
margin: 2.5cm;
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-family: Arial, sans-serif;
font-size: 9pt;
color: #666;
}
}
body {
font-family: Arial, sans-serif;
font-size: 7.5pt;
line-height: 1.4;
color: #333;
margin: 0;
padding: 0;
background: white;
}
/* Cover page */
.cover-page {
page-break-after: always;
}
.company-header {
margin-bottom: 30px;
page-break-after: avoid;
}
.company-logo {
max-height: 50px;
max-width: 100%;
height: auto;
width: auto;
display: block;
}
.company-name {
font-size: 16pt;
color: #1a1a1a;
margin-top: 10px;
font-weight: 600;
}
.export-title {
font-size: 22pt;
font-weight: normal;
color: #1a1a1a;
margin: 0 0 25px 0;
text-align: left;
}
.document-meta {
margin: 0 0 30px 0;
font-size: 9pt;
}
.meta-table {
width: 100%;
border-collapse: collapse;
border: 1px solid #333;
}
.meta-table td {
padding: 6px 8px;
border: 1px solid #333;
vertical-align: middle;
}
.meta-table td:first-child {
font-weight: 600;
width: 25%;
background: #f8f8f8;
color: #333;
}
.classification {
font-weight: bold;
text-transform: uppercase;
}
.purpose-section {
margin: 30px 0;
page-break-after: avoid;
}
.purpose-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.purpose-text {
font-size: 10pt;
color: #333;
line-height: 1.5;
text-align: justify;
}
/* Activity page */
.activity-page {
page-break-before: always;
page-break-after: always;
}
.activity-page:last-child {
page-break-after: auto;
}
.activity-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.activity-section {
margin-bottom: 8px;
page-break-inside: avoid;
}
.activity-section-title {
font-size: 10pt;
font-weight: bold;
color: #333;
margin: 12px 0 6px 0;
page-break-after: avoid;
page-break-inside: avoid;
}
.activity-field {
margin-bottom: 8px;
page-break-inside: avoid;
}
.activity-field-label {
font-weight: 600;
color: #333;
margin-bottom: 3px;
font-size: 7.5pt;
}
.activity-field-value {
color: #000;
font-size: 7.5pt;
line-height: 1.4;
}
.empty-value {
color: #999;
font-style: italic;
}
.activity-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 10px;
}
.activity-grid-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
margin-bottom: 10px;
}
/* Annex page */
.annex-page {
page-break-before: always;
}
.annex-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.annex-section {
margin-bottom: 20px;
}
.annex-section-title {
font-size: 13pt;
font-weight: bold;
color: #000;
margin: 15px 0 10px 0;
}
.annex-subsection-title {
font-size: 10pt;
font-weight: bold;
color: #333;
margin: 12px 0 8px 0;
}
.annex-enum-list {
margin: 10px 0;
padding-left: 20px;
}
.annex-enum-item {
margin-bottom: 8px;
font-size: 10pt;
line-height: 1.5;
}
.annex-enum-name {
font-weight: 600;
color: #333;
}
.annex-enum-description {
color: #000;
margin-left: 5px;
}
@media print {
body {
background: white;
}
}
</style>
</head>
<body>
<div class="cover-page">
<div class="company-header">
{{- if .CompanyHorizontalLogoBase64}}
{{imgTag .CompanyHorizontalLogoBase64 "Company Logo" "company-logo"}}
{{- else}}
<div class="company-name">{{.CompanyName}}</div>
{{- end}}
</div>
<h1 class="export-title">Processing Activities</h1>
<div class="document-meta">
<table class="meta-table">
<tr>
<td>Classification</td>
<td>
<span class="classification">CONFIDENTIAL</span>
</td>
</tr>
<tr>
<td>Version</td>
<td>{{.Version}}</td>
</tr>
<tr>
<td>Published</td>
<td>{{.PublishedAt.Format "January 2, 2006"}}</td>
</tr>
</table>
</div>
<div class="purpose-section">
<div class="purpose-title">1. Purpose</div>
<div class="purpose-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.
</div>
</div>
</div>
{{- range $index, $activity := .Activities}}
<div class="activity-page">
{{if eq $index 0}}
<h1 class="annex-title">2. Records</h1>
{{end}}
<h1 class="annex-section-title">2.{{add $index 1}} {{$activity.Name}}</h1>
<div class="activity-section">
<div class="activity-section-title">2.{{add $index 1}}.1 General Information</div>
<div class="activity-field">
<div class="activity-field-label">Purpose</div>
<div class="activity-field-value">{{if .Purpose}}{{.Purpose}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="activity-grid">
<div class="activity-field">
<div class="activity-field-label">Role</div>
<div class="activity-field-value">{{.Role | formatRole}}</div>
</div>
</div>
</div>
<div class="activity-section">
<div class="activity-section-title">2.{{add $index 1}}.2 Data Categories</div>
<div class="activity-grid-3">
<div class="activity-field">
<div class="activity-field-label">Data Subject Category</div>
<div class="activity-field-value">{{if .DataSubjectCategory}}{{.DataSubjectCategory}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Personal Data Category</div>
<div class="activity-field-value">{{if .PersonalDataCategory}}{{.PersonalDataCategory}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Special/Criminal Data</div>
<div class="activity-field-value">{{.SpecialOrCriminalData | formatSpecialOrCriminalData}}</div>
</div>
</div>
</div>
<div class="activity-section">
<div class="activity-section-title">2.{{add $index 1}}.3 Legal Basis</div>
<div class="activity-grid">
<div class="activity-field">
<div class="activity-field-label">Lawful Basis</div>
<div class="activity-field-value">{{.LawfulBasis | formatLawfulBasis}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Consent Evidence Link</div>
<div class="activity-field-value">{{if .ConsentEvidenceLink}}{{.ConsentEvidenceLink}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
</div>
</div>
<div class="activity-section">
<div class="activity-section-title">2.{{add $index 1}}.4 Data Sharing & Transfers</div>
<div class="activity-grid">
<div class="activity-field">
<div class="activity-field-label">Recipients</div>
<div class="activity-field-value">{{if .Recipients}}{{.Recipients}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Location</div>
<div class="activity-field-value">{{if .Location}}{{.Location}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">International Transfers</div>
<div class="activity-field-value">{{if .InternationalTransfers}}Yes{{else}}No{{end}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Transfer Safeguards</div>
<div class="activity-field-value">{{if .TransferSafeguards}}{{.TransferSafeguards | formatTransferSafeguard}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
</div>
</div>
<div class="activity-section">
<div class="activity-section-title">2.{{add $index 1}}.5 Retention & Security</div>
<div class="activity-grid">
<div class="activity-field">
<div class="activity-field-label">Retention Period</div>
<div class="activity-field-value">{{if .RetentionPeriod}}{{.RetentionPeriod}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Security Measures</div>
<div class="activity-field-value">{{if .SecurityMeasures}}{{.SecurityMeasures}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
</div>
<div class="activity-section">
<div class="activity-section-title">2.{{add $index 1}}.6 Assessments & Reviews</div>
<div class="activity-grid">
<div class="activity-field">
<div class="activity-field-label">DPIA Needed</div>
<div class="activity-field-value">{{.DataProtectionImpactAssessmentNeeded | formatDPIANeeded}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">TIA Needed</div>
<div class="activity-field-value">{{.TransferImpactAssessmentNeeded | formatTIANeeded}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Last Review Date</div>
<div class="activity-field-value">{{if .LastReviewDate}}{{.LastReviewDate.Format "January 2, 2006"}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Next Review Date</div>
<div class="activity-field-value">{{if .NextReviewDate}}{{.NextReviewDate.Format "January 2, 2006"}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
</div>
</div>
<div class="activity-section">
<div class="activity-section-title">2.{{add $index 1}}.7 Responsible Parties</div>
<div class="activity-grid">
<div class="activity-field">
<div class="activity-field-label">Data Protection Officer</div>
<div class="activity-field-value">{{if .DataProtectionOfficerFullName}}{{.DataProtectionOfficerFullName}}{{else}}<span class="empty-value">Not assigned</span>{{end}}</div>
</div>
<div class="activity-field">
<div class="activity-field-label">Vendors</div>
<div class="activity-field-value">{{if .Vendors}}{{.Vendors}}{{else}}<span class="empty-value">None</span>{{end}}</div>
</div>
</div>
</div>
</div>
{{- end}}
<div class="annex-page">
<h1 class="annex-title">3. Annexes</h1>
<div class="annex-section">
<div class="annex-section-title">3.1 Lexicon</div>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Role</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Controller:</span>
<span class="annex-enum-description">The entity that determines the purposes and means of processing personal data.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Processor:</span>
<span class="annex-enum-description">The entity that processes personal data on behalf of the controller.</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Lawful Basis</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Consent:</span>
<span class="annex-enum-description">The data subject has given consent to the processing of their personal data.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Contractual Necessity:</span>
<span class="annex-enum-description">Processing is necessary for the performance of a contract to which the data subject is party.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Legal Obligation:</span>
<span class="annex-enum-description">Processing is necessary for compliance with a legal obligation to which the controller is subject.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Legitimate Interest:</span>
<span class="annex-enum-description">Processing is necessary for the purposes of the legitimate interests pursued by the controller or a third party.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Public Task:</span>
<span class="annex-enum-description">Processing is necessary for the performance of a task carried out in the public interest or in the exercise of official authority.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Vital Interests:</span>
<span class="annex-enum-description">Processing is necessary to protect the vital interests of the data subject or of another natural person.</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Special/Criminal Data</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Yes:</span>
<span class="annex-enum-description">The processing activity involves special categories of personal data or data relating to criminal convictions and offences.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">No:</span>
<span class="annex-enum-description">The processing activity does not involve special categories of personal data or data relating to criminal convictions and offences.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Possible:</span>
<span class="annex-enum-description">The processing activity may involve special categories of personal data or data relating to criminal convictions and offences.</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">Transfer Safeguards</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Standard Contractual Clauses:</span>
<span class="annex-enum-description">Standard contractual clauses approved by the European Commission are used to ensure adequate protection for international transfers.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Binding Corporate Rules:</span>
<span class="annex-enum-description">Binding corporate rules approved by a supervisory authority are used to ensure adequate protection for international transfers.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Adequacy Decision:</span>
<span class="annex-enum-description">The European Commission has determined that the third country ensures an adequate level of protection.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Derogations:</span>
<span class="annex-enum-description">A derogation under Article 49 of the GDPR is used for the international transfer.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Codes of Conduct:</span>
<span class="annex-enum-description">An approved code of conduct together with binding and enforceable commitments is used to ensure adequate protection.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">Certification Mechanisms:</span>
<span class="annex-enum-description">An approved certification mechanism together with binding and enforceable commitments is used to ensure adequate protection.</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">DPIA Needed</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Yes:</span>
<span class="annex-enum-description">A Data Protection Impact Assessment is required for this processing activity.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">No:</span>
<span class="annex-enum-description">A Data Protection Impact Assessment is not required for this processing activity.</span>
</li>
</ul>
</div>
<div class="annex-section">
<div class="annex-subsection-title">TIA Needed</div>
<ul class="annex-enum-list">
<li class="annex-enum-item">
<span class="annex-enum-name">Yes:</span>
<span class="annex-enum-description">A Transfer Impact Assessment is required for this processing activity.</span>
</li>
<li class="annex-enum-item">
<span class="annex-enum-name">No:</span>
<span class="annex-enum-description">A Transfer Impact Assessment is not required for this processing activity.</span>
</li>
</ul>
</div>
</div>
</body>
</html>

View File

@@ -1,289 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Transfer Impact Assessments Export</title>
<style>
@page {
size: A4;
margin: 2.5cm;
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-family: Arial, sans-serif;
font-size: 9pt;
color: #666;
}
}
body {
font-family: Arial, sans-serif;
font-size: 7.5pt;
line-height: 1.4;
color: #333;
margin: 0;
padding: 0;
background: white;
}
/* Cover page */
.cover-page {
page-break-after: always;
}
.company-header {
margin-bottom: 30px;
page-break-after: avoid;
}
.company-logo {
max-height: 50px;
max-width: 100%;
height: auto;
width: auto;
display: block;
}
.company-name {
font-size: 16pt;
color: #1a1a1a;
margin-top: 10px;
font-weight: 600;
}
.export-title {
font-size: 22pt;
font-weight: normal;
color: #1a1a1a;
margin: 0 0 25px 0;
text-align: left;
}
.document-meta {
margin: 0 0 30px 0;
font-size: 9pt;
}
.meta-table {
width: 100%;
border-collapse: collapse;
border: 1px solid #333;
}
.meta-table td {
padding: 6px 8px;
border: 1px solid #333;
vertical-align: middle;
}
.meta-table td:first-child {
font-weight: 600;
width: 25%;
background: #f8f8f8;
color: #333;
}
.classification {
font-weight: bold;
text-transform: uppercase;
}
.purpose-section {
margin: 30px 0;
page-break-after: avoid;
}
.purpose-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.purpose-text {
font-size: 10pt;
color: #333;
line-height: 1.5;
text-align: justify;
}
/* Assessment page */
.assessment-page {
page-break-before: always;
page-break-after: always;
}
.assessment-page:last-child {
page-break-after: auto;
}
.assessment-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.assessment-field {
margin-bottom: 8px;
page-break-inside: avoid;
}
.assessment-field-label {
font-size: 10pt;
font-weight: bold;
color: #333;
margin: 12px 0 6px 0;
page-break-after: avoid;
page-break-inside: avoid;
}
.assessment-field-value {
color: #000;
font-size: 7.5pt;
line-height: 1.4;
}
.empty-value {
color: #999;
font-style: italic;
}
.annex-page {
page-break-before: always;
}
.annex-title {
font-size: 15pt;
font-weight: bold;
color: #000;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.annex-section {
margin-bottom: 20px;
}
.annex-section-title {
font-size: 13pt;
font-weight: bold;
color: #000;
margin: 15px 0 10px 0;
}
.annex-subsection-title {
font-size: 10pt;
font-weight: bold;
color: #333;
margin: 12px 0 8px 0;
}
.annex-enum-list {
margin: 10px 0;
padding-left: 20px;
}
.annex-enum-item {
margin-bottom: 8px;
font-size: 10pt;
line-height: 1.5;
}
.annex-enum-name {
font-weight: 600;
color: #333;
}
.annex-enum-description {
color: #000;
margin-left: 5px;
}
@media print {
body {
background: white;
}
}
</style>
</head>
<body>
<div class="cover-page">
<div class="company-header">
{{- if .CompanyHorizontalLogoBase64}}
{{imgTag .CompanyHorizontalLogoBase64 "Company Logo" "company-logo"}}
{{- else}}
<div class="company-name">{{.CompanyName}}</div>
{{- end}}
</div>
<h1 class="export-title">Transfer Impact Assessments</h1>
<div class="document-meta">
<table class="meta-table">
<tr>
<td>Classification</td>
<td>
<span class="classification">CONFIDENTIAL</span>
</td>
</tr>
<tr>
<td>Version</td>
<td>{{.Version}}</td>
</tr>
<tr>
<td>Published</td>
<td>{{.PublishedAt.Format "January 2, 2006"}}</td>
</tr>
</table>
</div>
<div class="purpose-section">
<div class="purpose-title">1. Purpose</div>
<div class="purpose-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.
</div>
</div>
</div>
{{- range $index, $assessment := .Assessments}}
<div class="assessment-page">
{{if eq $index 0}}
<h1 class="annex-title">2. Records</h1>
{{end}}
<h1 class="annex-section-title">2.{{add $index 1}} {{$assessment.ProcessingActivityName}}</h1>
<div class="assessment-field">
<div class="assessment-field-label">Data Subjects</div>
<div class="assessment-field-value">{{if .DataSubjects}}{{.DataSubjects}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Transfer</div>
<div class="assessment-field-value">{{if .Transfer}}{{.Transfer}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Legal Mechanism</div>
<div class="assessment-field-value">{{if .LegalMechanism}}{{.LegalMechanism}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Local Law Risk</div>
<div class="assessment-field-value">{{if .LocalLawRisk}}{{.LocalLawRisk}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
<div class="assessment-field">
<div class="assessment-field-label">Supplementary Measures</div>
<div class="assessment-field-value">{{if .SupplementaryMeasures}}{{.SupplementaryMeasures}}{{else}}<span class="empty-value">Not specified</span>{{end}}</div>
</div>
</div>
{{- end}}
</body>
</html>

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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." }] }] }
]
}
]
}

View File

@@ -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." }] }] }
]
}
]
}

View File

@@ -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." }] }] }
]
}
]
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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!
}

View File

@@ -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

View File

@@ -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!
}

View File

@@ -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.

View File

@@ -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)

View File

@@ -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,
}
}

View File

@@ -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,

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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: