From c635492f7588a1a635c0a04c437efdad80515939 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Fri, 10 Apr 2026 13:38:42 +0200 Subject: [PATCH] SOA as document: replace export with publish workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Statements of Applicability are no longer exported as one-off PDFs. Instead, each SOA owns a persistent document that accumulates versions over time, following the same publish/approve lifecycle as authored documents. Publishing without approvers publishes immediately; publishing with approvers creates a draft pending approval via the existing quorum system. SOAs can also store default approvers that are pre-populated in the publish dialog. The SOA is removed from the snapshot system — applicability statements are now queried directly (snapshot_id IS NULL) rather than through snapshot copies. A standalone migration script (cmd/migrate-soa-snapshots-to-documents) converts existing SOA snapshots into documents with proper ProseMirror content, preserving version history and approval decisions. Signed-off-by: Sacha Al Himdani --- .../documents/DocumentLayout.tsx | 7 +- .../_components/DocumentDetailsCard.tsx | 125 ++-- .../documents/_components/DocumentList.tsx | 47 +- .../description/DocumentDescriptionPage.tsx | 4 +- .../StatementOfApplicabilityDetailPage.tsx | 242 ++------ .../StatementsOfApplicabilityPage.tsx | 23 +- .../StatementOfApplicabilityRow.tsx | 9 +- .../CreateStatementOfApplicabilityDialog.tsx | 56 +- .../PublishStatementOfApplicabilityDialog.tsx | 161 ++++++ .../StatementOfApplicabilityControlsTab.tsx | 11 +- .../routes/statementsOfApplicabilityRoutes.ts | 14 - .../main.go | 538 ++++++++++++++++++ .../statement_of_applicability_test.go | 475 ++++++++++++++++ e2e/internal/factory/factory.go | 89 +++ packages/helpers/src/documents.ts | 15 +- packages/helpers/src/index.ts | 2 + packages/helpers/src/snapshots.ts | 5 - packages/n8n-node/nodes/Probo/Probo.node.ts | 5 + .../actions/document/getAll.operation.ts | 45 +- .../n8n-node/nodes/Probo/actions/index.ts | 2 + .../create.operation.ts | 114 ++++ .../delete.operation.ts | 57 ++ .../statementOfApplicability/get.operation.ts | 62 ++ .../getAll.operation.ts | 113 ++++ .../actions/statementOfApplicability/index.ts | 89 +++ .../publish.operation.ts | 101 ++++ .../update.operation.ts | 102 ++++ packages/ui/src/RichEditor/RichEditor.tsx | 2 +- packages/ui/src/rich-editor.css | 4 +- pkg/cmd/document/list/list.go | 16 +- pkg/cmd/soa/publish/publish.go | 137 +++++ pkg/cmd/soa/soa.go | 2 + pkg/coredata/applicability_statement.go | 55 +- pkg/coredata/control_obligation.go | 50 ++ pkg/coredata/document.go | 13 + pkg/coredata/document_filter.go | 21 + pkg/coredata/document_type.go | 22 +- pkg/coredata/document_version.go | 42 +- pkg/coredata/document_version_orientation.go | 72 +++ pkg/coredata/document_write_mode.go | 48 ++ .../electronic_signature_document_type.go | 42 +- pkg/coredata/migrations/20260410T120000Z.sql | 45 ++ pkg/coredata/snapshot.go | 2 + pkg/coredata/snapshots_type.go | 1 - pkg/coredata/snapshottable.go | 2 - pkg/coredata/statement_of_applicability.go | 179 +----- .../statement_of_applicability_filter.go | 61 -- pkg/docgen/generator.go | 65 +-- pkg/docgen/soa_template.html | 523 ----------------- pkg/docgen/template.html | 12 +- pkg/probo/actions.go | 12 +- pkg/probo/document_approval_service.go | 6 +- pkg/probo/document_service.go | 31 +- pkg/probo/generated_document_service.go | 362 ++++++++++++ pkg/probo/policies.go | 4 +- pkg/probo/service.go | 7 +- .../statement_of_applicability_service.go | 264 +-------- .../statement_of_applicability.json.tmpl | 189 ++++++ .../api/console/v1/control_resolvers.go | 72 +-- .../api/console/v1/graphql/control.graphql | 24 +- .../api/console/v1/graphql/document.graphql | 33 ++ .../v1/graphql/electronic_signature.graphql | 4 + .../console/v1/graphql/organization.graphql | 1 - .../api/console/v1/measure_resolvers.go | 1 + .../api/console/v1/organization_resolvers.go | 12 +- pkg/server/api/console/v1/risk_resolvers.go | 1 + pkg/server/api/console/v1/types/document.go | 1 + .../api/console/v1/types/document_version.go | 1 + .../v1/types/statement_of_applicability.go | 24 +- pkg/server/api/mcp/v1/schema.resolvers.go | 49 +- pkg/server/api/mcp/v1/specification.yaml | 69 ++- pkg/server/api/mcp/v1/types/document.go | 1 + .../v1/types/statement_of_applicability.go | 3 +- .../api/trust/v1/graphql/trust_center.graphql | 4 + 74 files changed, 3509 insertions(+), 1595 deletions(-) create mode 100644 apps/console/src/pages/organizations/statements-of-applicability/dialogs/PublishStatementOfApplicabilityDialog.tsx create mode 100644 cmd/migrate-soa-snapshots-to-documents/main.go create mode 100644 e2e/console/statement_of_applicability_test.go create mode 100644 packages/n8n-node/nodes/Probo/actions/statementOfApplicability/create.operation.ts create mode 100644 packages/n8n-node/nodes/Probo/actions/statementOfApplicability/delete.operation.ts create mode 100644 packages/n8n-node/nodes/Probo/actions/statementOfApplicability/get.operation.ts create mode 100644 packages/n8n-node/nodes/Probo/actions/statementOfApplicability/getAll.operation.ts create mode 100644 packages/n8n-node/nodes/Probo/actions/statementOfApplicability/index.ts create mode 100644 packages/n8n-node/nodes/Probo/actions/statementOfApplicability/publish.operation.ts create mode 100644 packages/n8n-node/nodes/Probo/actions/statementOfApplicability/update.operation.ts create mode 100644 pkg/cmd/soa/publish/publish.go create mode 100644 pkg/coredata/document_version_orientation.go create mode 100644 pkg/coredata/document_write_mode.go create mode 100644 pkg/coredata/migrations/20260410T120000Z.sql delete mode 100644 pkg/coredata/statement_of_applicability_filter.go delete mode 100644 pkg/docgen/soa_template.html create mode 100644 pkg/probo/generated_document_service.go create mode 100644 pkg/probo/templates/statement_of_applicability.json.tmpl diff --git a/apps/console/src/pages/organizations/documents/DocumentLayout.tsx b/apps/console/src/pages/organizations/documents/DocumentLayout.tsx index 1c166576d..d8760c2d9 100644 --- a/apps/console/src/pages/organizations/documents/DocumentLayout.tsx +++ b/apps/console/src/pages/organizations/documents/DocumentLayout.tsx @@ -67,6 +67,7 @@ export const documentLayoutQuery = graphql` ... on Document { id status + writeMode canPublish: permission(action: "core:document-version:publish") ...PublishDialog_documentFragment controlInfo: controls(first: 0) { @@ -146,6 +147,7 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery )} > + {isGenerated && {__("Generated")}} @@ -239,6 +242,8 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery diff --git a/apps/console/src/pages/organizations/documents/_components/DocumentDetailsCard.tsx b/apps/console/src/pages/organizations/documents/_components/DocumentDetailsCard.tsx index d795941f4..c5e0ce991 100644 --- a/apps/console/src/pages/organizations/documents/_components/DocumentDetailsCard.tsx +++ b/apps/console/src/pages/organizations/documents/_components/DocumentDetailsCard.tsx @@ -124,9 +124,18 @@ export function DocumentDetailsCard(props: { documentFragmentRef: DocumentDetailsCard_documentFragment$key; versionFragmentRef: DocumentDetailsCard_versionFragment$key; isEditable: boolean; + isGenerated?: boolean; + isLatestVersion?: boolean; onDocumentUpdated: () => void; }) { - const { documentFragmentRef, versionFragmentRef, isEditable, onDocumentUpdated } = props; + const { + documentFragmentRef, + versionFragmentRef, + isEditable, + isGenerated = false, + isLatestVersion = true, + onDocumentUpdated, + } = props; const { __ } = useTranslate(); const organizationId = useOrganizationId(); @@ -140,6 +149,8 @@ export function DocumentDetailsCard(props: { const version = useFragment(versionFragment, versionFragmentRef); const canEdit = document.canUpdate && isEditable; + const canEditVersionFields = canEdit && !isGenerated; + const canEditApprovers = document.canUpdate && isLatestVersion; const { control, handleSubmit, reset } = useFormWithSchema( schema, @@ -272,59 +283,6 @@ export function DocumentDetailsCard(props: { return (
-
-
- {__("Approvers")} -
- {isEditingApprovers - ? ( -
-
- ({ - id: a.id, - fullName: a.fullName, - emailAddress: a.emailAddress, - }))} - placeholder={__("Add approvers...")} - /> -
-
- ) - : ( -
-
- {document.defaultApprovers.length > 0 - ? document.defaultApprovers.map(a => a.fullName).join(", ") - : __("None")} -
- {canEdit && ( -
- )} -
{__("Type")} @@ -362,7 +320,7 @@ export function DocumentDetailsCard(props: {
{getDocumentTypeLabel(__, version.documentType)}
- {canEdit && ( + {canEditVersionFields && (
- {canEdit && ( + {canEditVersionFields && (
+ {isLatestVersion && ( +
+
+ {__("Approvers")} +
+ {isEditingApprovers + ? ( +
+
+ ({ + id: a.id, + fullName: a.fullName, + emailAddress: a.emailAddress, + }))} + placeholder={__("Add approvers...")} + /> +
+
+ ) + : ( +
+
+ {document.defaultApprovers.length > 0 + ? document.defaultApprovers.map(a => a.fullName).join(", ") + : __("None")} +
+ {canEditApprovers && ( +
+ )} +
+ )}
diff --git a/apps/console/src/pages/organizations/documents/_components/DocumentList.tsx b/apps/console/src/pages/organizations/documents/_components/DocumentList.tsx index 0d8866d64..df5ba7b82 100644 --- a/apps/console/src/pages/organizations/documents/_components/DocumentList.tsx +++ b/apps/console/src/pages/organizations/documents/_components/DocumentList.tsx @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -import { documentClassifications, documentTypes, getDocumentClassificationLabel, getDocumentTypeLabel, sprintf } from "@probo/helpers"; +import { documentClassifications, documentTypes, documentWriteModes, getDocumentClassificationLabel, getDocumentTypeLabel, getDocumentWriteModeLabel, sprintf } from "@probo/helpers"; import { useList } from "@probo/hooks"; import { useTranslate } from "@probo/i18n"; import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCrossLargeX, IconSignature, IconTrashCan, IconUpload, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui"; @@ -23,7 +23,7 @@ import { ConnectionHandler, graphql } from "relay-runtime"; import type { DocumentListBulkArchiveMutation } from "#/__generated__/core/DocumentListBulkArchiveMutation.graphql"; import type { DocumentListBulkUnarchiveMutation } from "#/__generated__/core/DocumentListBulkUnarchiveMutation.graphql"; import type { DocumentListFragment$key } from "#/__generated__/core/DocumentListFragment.graphql"; -import type { DocumentClassification, DocumentOrderField, DocumentsListQuery, DocumentType } from "#/__generated__/core/DocumentsListQuery.graphql"; +import type { DocumentClassification, DocumentOrderField, DocumentsListQuery, DocumentType, DocumentWriteMode } from "#/__generated__/core/DocumentsListQuery.graphql"; import { BulkExportDialog, type BulkExportDialogRef } from "#/components/documents/BulkExportDialog"; import { type Order, SortableTable, SortableTh } from "#/components/SortableTable"; import { useBulkDeleteDocumentsMutation, useBulkExportDocumentsMutation } from "#/hooks/graph/DocumentGraph"; @@ -50,6 +50,7 @@ const fragment = graphql` status: { type: "[DocumentStatus!]", defaultValue: [ACTIVE] } documentTypes: { type: "[DocumentType!]", defaultValue: null } classifications: { type: "[DocumentClassification!]", defaultValue: null } + writeModes: { type: "[DocumentWriteMode!]", defaultValue: null } ) { documents( first: $first @@ -57,7 +58,7 @@ const fragment = graphql` last: $last before: $before orderBy: $order - filter: { status: $status documentTypes: $documentTypes classifications: $classifications } + filter: { status: $status documentTypes: $documentTypes classifications: $classifications writeModes: $writeModes } ) @connection(key: "DocumentsListQuery_documents" filters: ["orderBy", "filter"]) { __id edges { @@ -130,6 +131,7 @@ export function DocumentList(props: { const [documentTypeFilter, setDocumentTypeFilter] = useState(null); const [classificationFilter, setClassificationFilter] = useState(null); + const [writeModeFilter, setWriteModeFilter] = useState(null); const [isPending, startTransition] = useTransition(); const refetch = pagination.refetch; @@ -140,11 +142,12 @@ export function DocumentList(props: { status: [tab], documentTypes: documentTypeFilter ? [documentTypeFilter] : null, classifications: classificationFilter ? [classificationFilter] : null, + writeModes: writeModeFilter ? [writeModeFilter] : null, }, { fetchPolicy: "store-and-network" }, ); }); - }, [tab, refetch, documentTypeFilter, classificationFilter]); + }, [tab, refetch, documentTypeFilter, classificationFilter, writeModeFilter]); const documents = pagination.data.documents.edges.map(({ node }) => node); const connectionId = pagination.data.documents.__id; @@ -194,6 +197,7 @@ export function DocumentList(props: { status: [tab], documentTypes: newType ? [newType] : null, classifications: classificationFilter ? [classificationFilter] : null, + writeModes: writeModeFilter ? [writeModeFilter] : null, }, }, ), @@ -214,6 +218,28 @@ export function DocumentList(props: { status: [tab], documentTypes: documentTypeFilter ? [documentTypeFilter] : null, classifications: newClassification ? [newClassification] : null, + writeModes: writeModeFilter ? [writeModeFilter] : null, + }, + }, + ), + ); + }; + + const handleWriteModeFilterChange = (value: string) => { + const newWriteMode = value === "ALL" ? null : (value as DocumentWriteMode); + clear(); + setWriteModeFilter(newWriteMode); + onConnectionIdChange( + ConnectionHandler.getConnectionID( + organizationId, + "DocumentsListQuery_documents", + { + orderBy: { direction: "ASC", field: "TITLE" }, + filter: { + status: [tab], + documentTypes: documentTypeFilter ? [documentTypeFilter] : null, + classifications: classificationFilter ? [classificationFilter] : null, + writeModes: newWriteMode ? [newWriteMode] : null, }, }, ), @@ -297,6 +323,7 @@ export function DocumentList(props: { status: [tab], documentTypes: documentTypeFilter ? [documentTypeFilter] : null, classifications: classificationFilter ? [classificationFilter] : null, + writeModes: writeModeFilter ? [writeModeFilter] : null, }, }, ), @@ -309,12 +336,24 @@ export function DocumentList(props: { status: [tab], documentTypes: documentTypeFilter ? [documentTypeFilter] : null, classifications: classificationFilter ? [classificationFilter] : null, + writeModes: writeModeFilter ? [writeModeFilter] : null, }); }; return (
+