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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user