Allow editing metadata of generated document versions

Title, document type, and classification on generated documents could
not be changed: any version-tracked field on a GENERATED write-mode
document was rejected with ErrDocumentVersionGenerated. The error now
fires only when content is being changed, so manual metadata edits
flow through the same draft-on-edit path as authored documents and
produce a draft version that the user can review and publish.

The CLI document update --document-type enum gains
STATEMENT_OF_APPLICABILITY (which generated SoA documents already
use), and the GraphQL resolver maps the content-edit rejection to a
Conflict instead of falling through to a generic Internal error.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-06 15:17:29 +02:00
parent 4e5958502c
commit 8bdab65963
7 changed files with 299 additions and 10 deletions

View File

@@ -225,7 +225,7 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
fKey={currentVersion}
documentId={document.id}
documentStatus={document.status}
isEditable={isEditable && !isGenerated}
isEditable={isEditable}
onDocumentUpdated={handleDocumentUpdated}
/>
)}
@@ -242,7 +242,6 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
documentFragmentRef={document}
versionFragmentRef={currentVersion}
isEditable={isEditable}
isGenerated={isGenerated}
isLatestVersion={isLatestVersion}
onDocumentUpdated={handleDocumentUpdated}
/>

View File

@@ -124,7 +124,6 @@ export function DocumentDetailsCard(props: {
documentFragmentRef: DocumentDetailsCard_documentFragment$key;
versionFragmentRef: DocumentDetailsCard_versionFragment$key;
isEditable: boolean;
isGenerated?: boolean;
isLatestVersion?: boolean;
onDocumentUpdated: () => void;
}) {
@@ -132,7 +131,6 @@ export function DocumentDetailsCard(props: {
documentFragmentRef,
versionFragmentRef,
isEditable,
isGenerated = false,
isLatestVersion = true,
onDocumentUpdated,
} = props;
@@ -149,7 +147,7 @@ export function DocumentDetailsCard(props: {
const version = useFragment<DocumentDetailsCard_versionFragment$key>(versionFragment, versionFragmentRef);
const canEdit = document.canUpdate && isEditable;
const canEditVersionFields = canEdit && !isGenerated;
const canEditVersionFields = canEdit;
const canEditApprovers = document.canUpdate && isLatestVersion;
const { control, handleSubmit, reset } = useFormWithSchema(

View File

@@ -440,6 +440,275 @@ func TestStatementOfApplicability_CreateDocument_RBAC(t *testing.T) {
)
}
func TestStatementOfApplicability_UpdateDocumentMetadata(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
publishSOADocument := func(t *testing.T) (documentID, documentVersionID string) {
t.Helper()
frameworkID := factory.NewFramework(owner).Create()
controlID := factory.NewControl(owner, frameworkID).Create()
soaID := factory.NewStatementOfApplicability(owner).Create()
factory.CreateApplicabilityStatement(owner, soaID, controlID, true, nil)
const publishQuery = `
mutation($input: PublishStatementOfApplicabilityInput!) {
publishStatementOfApplicability(input: $input) {
documentEdge { node { id } }
documentVersionEdge { node { id } }
}
}
`
var publishResult struct {
PublishStatementOfApplicability struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"documentEdge"`
DocumentVersionEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"publishStatementOfApplicability"`
}
err := owner.Execute(
publishQuery,
map[string]any{
"input": map[string]any{
"minor": false,
"statementOfApplicabilityId": soaID,
},
},
&publishResult,
)
require.NoError(t, err)
return publishResult.PublishStatementOfApplicability.DocumentEdge.Node.ID,
publishResult.PublishStatementOfApplicability.DocumentVersionEdge.Node.ID
}
const updateQuery = `
mutation($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document { id writeMode }
documentVersion {
id
title
documentType
classification
status
}
}
}
`
type updateResult struct {
UpdateDocument struct {
Document struct {
ID string `json:"id"`
WriteMode string `json:"writeMode"`
} `json:"document"`
DocumentVersion *struct {
ID string `json:"id"`
Title string `json:"title"`
DocumentType string `json:"documentType"`
Classification string `json:"classification"`
Status string `json:"status"`
} `json:"documentVersion"`
} `json:"updateDocument"`
}
t.Run(
"editing title, type, and classification on generated document creates a draft",
func(t *testing.T) {
t.Parallel()
documentID, publishedVersionID := publishSOADocument(t)
var result updateResult
err := owner.Execute(
updateQuery,
map[string]any{
"input": map[string]any{
"id": documentID,
"title": "Renamed SOA",
"documentType": "POLICY",
"classification": "INTERNAL",
},
},
&result,
)
require.NoError(t, err)
assert.Equal(t, "GENERATED", result.UpdateDocument.Document.WriteMode)
require.NotNil(t, result.UpdateDocument.DocumentVersion)
assert.NotEqual(t, publishedVersionID, result.UpdateDocument.DocumentVersion.ID,
"should create a new draft, not update the published version")
assert.Equal(t, "DRAFT", result.UpdateDocument.DocumentVersion.Status)
assert.Equal(t, "Renamed SOA", result.UpdateDocument.DocumentVersion.Title)
assert.Equal(t, "POLICY", result.UpdateDocument.DocumentVersion.DocumentType)
assert.Equal(t, "INTERNAL", result.UpdateDocument.DocumentVersion.Classification)
},
)
t.Run(
"cannot edit content of a generated document",
func(t *testing.T) {
t.Parallel()
documentID, _ := publishSOADocument(t)
err := owner.ExecuteShouldFail(
updateQuery,
map[string]any{
"input": map[string]any{
"id": documentID,
"content": `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hand-edited"}]}]}`,
},
},
)
require.Error(t, err)
assert.Contains(t, err.Error(), "generated")
},
)
t.Run(
"re-publish preserves edited title, type, and classification",
func(t *testing.T) {
t.Parallel()
frameworkID := factory.NewFramework(owner).Create()
controlID := factory.NewControl(owner, frameworkID).Create()
soaID := factory.NewStatementOfApplicability(owner).Create()
factory.CreateApplicabilityStatement(owner, soaID, controlID, true, nil)
const publishSOAQuery = `
mutation($input: PublishStatementOfApplicabilityInput!) {
publishStatementOfApplicability(input: $input) {
documentEdge { node { id } }
documentVersionEdge { node { id title documentType classification major minor } }
}
}
`
type publishSOAResult struct {
PublishStatementOfApplicability struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"documentEdge"`
DocumentVersionEdge struct {
Node struct {
ID string `json:"id"`
Title string `json:"title"`
DocumentType string `json:"documentType"`
Classification string `json:"classification"`
Major int `json:"major"`
Minor int `json:"minor"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"publishStatementOfApplicability"`
}
var firstPublish publishSOAResult
err := owner.Execute(
publishSOAQuery,
map[string]any{
"input": map[string]any{
"minor": false,
"statementOfApplicabilityId": soaID,
},
},
&firstPublish,
)
require.NoError(t, err)
documentID := firstPublish.PublishStatementOfApplicability.DocumentEdge.Node.ID
require.Equal(t, "STATEMENT_OF_APPLICABILITY", firstPublish.PublishStatementOfApplicability.DocumentVersionEdge.Node.DocumentType)
require.Equal(t, "CONFIDENTIAL", firstPublish.PublishStatementOfApplicability.DocumentVersionEdge.Node.Classification)
var editResult updateResult
err = owner.Execute(
updateQuery,
map[string]any{
"input": map[string]any{
"id": documentID,
"title": "Custom SOA Name",
"documentType": "POLICY",
"classification": "INTERNAL",
},
},
&editResult,
)
require.NoError(t, err)
require.NotNil(t, editResult.UpdateDocument.DocumentVersion)
require.Equal(t, "DRAFT", editResult.UpdateDocument.DocumentVersion.Status)
const publishDraftQuery = `
mutation($input: PublishDocumentInput!) {
publishDocument(input: $input) {
documentVersion { id title documentType classification major minor status }
}
}
`
var publishDraft struct {
PublishDocument struct {
DocumentVersion struct {
ID string `json:"id"`
Title string `json:"title"`
DocumentType string `json:"documentType"`
Classification string `json:"classification"`
Major int `json:"major"`
Minor int `json:"minor"`
Status string `json:"status"`
} `json:"documentVersion"`
} `json:"publishDocument"`
}
err = owner.Execute(
publishDraftQuery,
map[string]any{
"input": map[string]any{
"documentId": documentID,
"minor": true,
"changelog": "rename",
},
},
&publishDraft,
)
require.NoError(t, err)
require.Equal(t, "PUBLISHED", publishDraft.PublishDocument.DocumentVersion.Status)
require.Equal(t, "Custom SOA Name", publishDraft.PublishDocument.DocumentVersion.Title)
require.Equal(t, "POLICY", publishDraft.PublishDocument.DocumentVersion.DocumentType)
require.Equal(t, "INTERNAL", publishDraft.PublishDocument.DocumentVersion.Classification)
var rePublish publishSOAResult
err = owner.Execute(
publishSOAQuery,
map[string]any{
"input": map[string]any{
"minor": true,
"statementOfApplicabilityId": soaID,
},
},
&rePublish,
)
require.NoError(t, err)
node := rePublish.PublishStatementOfApplicability.DocumentVersionEdge.Node
assert.Equal(t, "Custom SOA Name", node.Title, "re-publish should preserve edited title")
assert.Equal(t, "POLICY", node.DocumentType, "re-publish should preserve edited type")
assert.Equal(t, "INTERNAL", node.Classification, "re-publish should preserve edited classification")
},
)
}
func TestStatementOfApplicability_TenantIsolation(t *testing.T) {
t.Parallel()

View File

@@ -106,7 +106,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
if err := cmdutil.ValidateEnum(
"document-type",
flagDocumentType,
[]string{"OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"},
[]string{"OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE", "STATEMENT_OF_APPLICABILITY"},
); err != nil {
return err
}
@@ -174,7 +174,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().StringVar(&flagTitle, "title", "", "Document title")
cmd.Flags().StringVar(&flagContent, "content", "", "Document content")
cmd.Flags().StringVar(&flagDocumentType, "document-type", "", "Document type: OTHER, GOVERNANCE, POLICY, PROCEDURE, PLAN, REGISTER, RECORD, REPORT, TEMPLATE")
cmd.Flags().StringVar(&flagDocumentType, "document-type", "", "Document type: OTHER, GOVERNANCE, POLICY, PROCEDURE, PLAN, REGISTER, RECORD, REPORT, TEMPLATE, STATEMENT_OF_APPLICABILITY")
cmd.Flags().StringVar(&flagClassification, "classification", "", "Classification: PUBLIC, INTERNAL, CONFIDENTIAL, SECRET")
cmd.Flags().StringVar(&flagTrustCenterVisibility, "trust-center-visibility", "", "Trust center visibility: NONE, PRIVATE, PUBLIC")

View File

@@ -267,7 +267,7 @@ func (e ErrDocumentGenerated) Error() string {
}
func (e ErrDocumentVersionGenerated) Error() string {
return "cannot edit a generated document version"
return "cannot edit content of a generated document version"
}
func (e ErrDocumentVersionSignatureAlreadySigned) Error() string {
@@ -1800,7 +1800,7 @@ func (s *DocumentService) Update(
hasVersionChanges := req.Title != nil || req.Content != nil || req.Classification != nil || req.DocumentType != nil
if hasVersionChanges && document.WriteMode == coredata.DocumentWriteModeGenerated {
if req.Content != nil && document.WriteMode == coredata.DocumentWriteModeGenerated {
return &ErrDocumentVersionGenerated{}
}

View File

@@ -2909,6 +2909,19 @@ func (s *GeneratedDocumentService) publishOrRequestApproval(
minor bool,
now time.Time,
) error {
previousVersion := &coredata.DocumentVersion{}
err := previousVersion.LoadLatestVersion(ctx, tx, s.svc.scope, document.ID)
switch {
case err == nil:
version.Title = previousVersion.Title
version.Classification = previousVersion.Classification
version.DocumentType = previousVersion.DocumentType
case errors.Is(err, coredata.ErrResourceNotFound):
// First publish: keep the caller-provided defaults.
default:
return fmt.Errorf("cannot load previous document version: %w", err)
}
if minor {
if document.CurrentPublishedMajor == nil || document.CurrentPublishedMinor == nil {
return &ErrCannotPublishMinorWithoutMajor{}
@@ -2936,7 +2949,14 @@ func (s *GeneratedDocumentService) publishOrRequestApproval(
if err := version.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)
switch previousVersion.Status {
case coredata.DocumentVersionStatusDraft:
return fmt.Errorf("a draft version exists, publish or delete it before publishing a new one: %w", err)
case coredata.DocumentVersionStatusPendingApproval:
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
default:
return fmt.Errorf("a version already exists at this number: %w", err)
}
}
return fmt.Errorf("cannot insert document version: %w", err)
}

View File

@@ -864,6 +864,9 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
if errGenerated, ok := errors.AsType[*probo.ErrDocumentVersionGenerated](err); ok {
return nil, gqlutils.Conflict(ctx, errGenerated)
}
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}