Consolidate document draft management into updateDocument

Replace the three separate draft mutations (createDraftDocumentVersion,
updateDocumentVersion, deleteDraftDocumentVersion) with automatic draft
lifecycle management inside updateDocument. The backend now auto-creates
a draft when a published document is edited, updates the existing draft
on subsequent edits, and auto-deletes the draft when content reverts to
match the published version.

A new deleteDocumentDraft mutation provides explicit draft deletion.

Backend:
- Merge version-level fields (content, title, classification,
  documentType) into UpdateDocumentRequest
- Convert CreateDraft, UpdateVersion, DeleteDraft into private
  transaction helpers called from Update
- Update returns (*Document, *DocumentVersion, error) with the version
  present only when a draft exists

Frontend:
- Remove all create/update/delete draft mutations from components
- Auto-save via updateDocument with layout refetch on draft status
  transitions while preserving editor cursor (data-generation key)
- Title, type, and classification editable on published versions
  (backend auto-creates draft)
- Forms use react-hook-form values option to stay synced with Relay
  fragment data across draft/publish transitions

API surface (GraphQL, MCP, CLI, n8n) updated consistently:
- Removed: createDraftDocumentVersion, updateDocumentVersion,
  deleteDraftDocumentVersion
- Added: deleteDocumentDraft (document-level)
- Updated: updateDocument accepts content, classification, documentType

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-14 14:37:24 +02:00
parent 74d7d3ff25
commit 03708d45c3
21 changed files with 1054 additions and 1087 deletions

View File

@@ -290,18 +290,17 @@ func TestDocument_Update(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"update title via document version",
"update title via document",
func(t *testing.T) {
t.Parallel()
doc := factory.NewDocument(owner).
WithTitle("Document to Update")
doc.Create()
versionID := doc.VersionID()
documentID := doc.Create()
query := `
mutation UpdateDocumentVersion($input: UpdateDocumentVersionInput!) {
updateDocumentVersion(input: $input) {
mutation UpdateDocument($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
documentVersion {
id
title
@@ -311,33 +310,32 @@ func TestDocument_Update(t *testing.T) {
`
var result struct {
UpdateDocumentVersion struct {
UpdateDocument struct {
DocumentVersion struct {
ID string `json:"id"`
Title string `json:"title"`
} `json:"documentVersion"`
} `json:"updateDocumentVersion"`
} `json:"updateDocument"`
}
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"documentVersionId": versionID,
"title": "Updated Document Title",
"id": documentID,
"title": "Updated Document Title",
},
}, &result)
require.NoError(t, err)
assert.Equal(t, "Updated Document Title", result.UpdateDocumentVersion.DocumentVersion.Title)
assert.Equal(t, "Updated Document Title", result.UpdateDocument.DocumentVersion.Title)
},
)
}
func TestDocumentVersion_Update_TitleValidation(t *testing.T) {
func TestDocument_Update_TitleValidation(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
doc := factory.NewDocument(owner).WithTitle("Validation Test Document")
doc.Create()
baseVersionID := doc.VersionID()
baseDocumentID := doc.Create()
tests := []struct {
name string
@@ -347,41 +345,41 @@ func TestDocumentVersion_Update_TitleValidation(t *testing.T) {
}{
{
name: "title with HTML tags",
setup: func() string { return baseVersionID },
setup: func() string { return baseDocumentID },
input: func(id string) map[string]any {
return map[string]any{"documentVersionId": id, "title": "<script>alert('xss')</script>"}
return map[string]any{"id": id, "title": "<script>alert('xss')</script>"}
},
wantErrorContains: "HTML",
},
{
name: "title with newline",
setup: func() string { return baseVersionID },
setup: func() string { return baseDocumentID },
input: func(id string) map[string]any {
return map[string]any{"documentVersionId": id, "title": "Test\nDocument"}
return map[string]any{"id": id, "title": "Test\nDocument"}
},
wantErrorContains: "newline",
},
{
name: "title with carriage return",
setup: func() string { return baseVersionID },
setup: func() string { return baseDocumentID },
input: func(id string) map[string]any {
return map[string]any{"documentVersionId": id, "title": "Test\rDocument"}
return map[string]any{"id": id, "title": "Test\rDocument"}
},
wantErrorContains: "carriage return",
},
{
name: "title with null byte",
setup: func() string { return baseVersionID },
setup: func() string { return baseDocumentID },
input: func(id string) map[string]any {
return map[string]any{"documentVersionId": id, "title": "Test\x00Document"}
return map[string]any{"id": id, "title": "Test\x00Document"}
},
wantErrorContains: "control character",
},
{
name: "title with zero-width space",
setup: func() string { return baseVersionID },
setup: func() string { return baseDocumentID },
input: func(id string) map[string]any {
return map[string]any{"documentVersionId": id, "title": "Test\u200BDocument"}
return map[string]any{"id": id, "title": "Test\u200BDocument"}
},
wantErrorContains: "zero-width",
},
@@ -389,11 +387,11 @@ func TestDocumentVersion_Update_TitleValidation(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
versionID := tt.setup()
documentID := tt.setup()
query := `
mutation UpdateDocumentVersion($input: UpdateDocumentVersionInput!) {
updateDocumentVersion(input: $input) {
mutation UpdateDocument($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
documentVersion {
id
}
@@ -401,7 +399,7 @@ func TestDocumentVersion_Update_TitleValidation(t *testing.T) {
}
`
_, err := owner.Do(query, map[string]any{"input": tt.input(versionID)})
_, err := owner.Do(query, map[string]any{"input": tt.input(documentID)})
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErrorContains)
})
@@ -979,14 +977,13 @@ func TestDocument_MaxLength_Validation(t *testing.T) {
assert.Contains(t, err.Error(), "title")
})
t.Run("update version", func(t *testing.T) {
t.Run("update document with long title", func(t *testing.T) {
doc := factory.NewDocument(owner).WithTitle("Max Length Test")
doc.Create()
versionID := doc.VersionID()
documentID := doc.Create()
query := `
mutation UpdateDocumentVersion($input: UpdateDocumentVersionInput!) {
updateDocumentVersion(input: $input) {
mutation UpdateDocument($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
documentVersion { id }
}
}
@@ -994,8 +991,8 @@ func TestDocument_MaxLength_Validation(t *testing.T) {
_, err := owner.Do(query, map[string]any{
"input": map[string]any{
"documentVersionId": versionID,
"title": longTitle,
"id": documentID,
"title": longTitle,
},
})
require.Error(t, err)
@@ -1028,15 +1025,14 @@ func TestDocument_MaxLength_Validation(t *testing.T) {
assert.Contains(t, err.Error(), "content")
})
t.Run("update version with long content", func(t *testing.T) {
docID, versionID := createTestDocument(t, owner)
t.Run("update document with long content", func(t *testing.T) {
docID, _ := createTestDocument(t, owner)
require.NotEmpty(t, docID)
require.NotEmpty(t, versionID)
query := `
mutation UpdateDocumentVersion($input: UpdateDocumentVersionInput!) {
updateDocumentVersion(input: $input) {
documentVersion { id }
mutation UpdateDocument($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document { id }
}
}
`
@@ -1045,8 +1041,8 @@ func TestDocument_MaxLength_Validation(t *testing.T) {
_, err := owner.Do(query, map[string]any{
"input": map[string]any{
"documentVersionId": versionID,
"content": longContent,
"id": docID,
"content": longContent,
},
})
require.Error(t, err)

View File

@@ -220,7 +220,7 @@ func TestDocumentVersion_PublishVersion(t *testing.T) {
assert.Equal(t, 0, result.Node.Versions.Edges[0].Node.Minor)
}
func TestDocumentVersion_CreateDraft(t *testing.T) {
func TestDocumentVersion_AutoCreateDraft(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
@@ -228,38 +228,112 @@ func TestDocumentVersion_CreateDraft(t *testing.T) {
docID, _ := createTestDocument(t, owner)
approveTestDocument(t, owner, docID)
// Updating content should auto-create a draft
query := `
mutation CreateDraftDocumentVersion($input: CreateDraftDocumentVersionInput!) {
createDraftDocumentVersion(input: $input) {
documentVersionEdge {
node {
id
status
}
mutation UpdateDocument($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document {
id
}
documentVersion {
id
status
content
}
}
}
`
var result struct {
CreateDraftDocumentVersion struct {
DocumentVersionEdge struct {
Node struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"createDraftDocumentVersion"`
UpdateDocument struct {
Document struct {
ID string `json:"id"`
} `json:"document"`
DocumentVersion *struct {
ID string `json:"id"`
Status string `json:"status"`
Content string `json:"content"`
} `json:"documentVersion"`
} `json:"updateDocument"`
}
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"documentID": docID,
"id": docID,
"content": testutil.ProseMirrorTextDoc("Updated content"),
},
}, &result)
require.NoError(t, err)
assert.Equal(t, "DRAFT", result.CreateDraftDocumentVersion.DocumentVersionEdge.Node.Status)
require.NotNil(t, result.UpdateDocument.DocumentVersion)
assert.Equal(t, "DRAFT", result.UpdateDocument.DocumentVersion.Status)
}
func TestDocumentVersion_AutoDeleteDraft(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create and approve a document (auto-publishes on approval)
docID, _ := createTestDocument(t, owner)
approveTestDocument(t, owner, docID)
// First update to create a draft
query := `
mutation UpdateDocument($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document {
id
}
documentVersion {
id
status
}
}
}
`
var createResult struct {
UpdateDocument struct {
Document struct {
ID string `json:"id"`
} `json:"document"`
DocumentVersion *struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"documentVersion"`
} `json:"updateDocument"`
}
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"id": docID,
"content": testutil.ProseMirrorTextDoc("Updated content"),
},
}, &createResult)
require.NoError(t, err)
require.NotNil(t, createResult.UpdateDocument.DocumentVersion)
// Now revert content to match the published version — draft should be auto-deleted
var revertResult struct {
UpdateDocument struct {
Document struct {
ID string `json:"id"`
} `json:"document"`
DocumentVersion *struct {
ID string `json:"id"`
} `json:"documentVersion"`
} `json:"updateDocument"`
}
err = owner.Execute(query, map[string]any{
"input": map[string]any{
"id": docID,
"content": testutil.ProseMirrorTextDoc("Initial content"),
},
}, &revertResult)
require.NoError(t, err)
assert.Nil(t, revertResult.UpdateDocument.DocumentVersion)
}
func TestDocumentVersion_RequestSignature(t *testing.T) {
@@ -536,18 +610,17 @@ func TestDocumentVersion_BulkPublishMinorSkipsPendingApproval(t *testing.T) {
docID, _ := createTestDocument(t, owner)
approveTestDocument(t, owner, docID)
// Create a draft so we can publish minor
// Create a draft by updating content (auto-creates draft)
_, err := owner.Do(`
mutation($input: CreateDraftDocumentVersionInput!) {
createDraftDocumentVersion(input: $input) {
documentVersionEdge {
node { id }
}
mutation($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
documentVersion { id }
}
}
`, map[string]any{
"input": map[string]any{
"documentID": docID,
"id": docID,
"content": testutil.ProseMirrorTextDoc("Updated content to create a draft"),
},
})
require.NoError(t, err)
@@ -645,6 +718,128 @@ func TestDocumentVersion_BulkRequestSignatures(t *testing.T) {
}
}
func TestDocumentVersion_AutoCreateDraftOnClassificationOrTypeUpdate(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run("documentType update creates draft", func(t *testing.T) {
t.Parallel()
// Create and approve a document (auto-publishes on approval)
docID, _ := createTestDocument(t, owner)
approveTestDocument(t, owner, docID)
// Updating documentType should auto-create a draft
query := `
mutation UpdateDocument($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document {
id
}
documentVersion {
id
status
}
}
}
`
var result struct {
UpdateDocument struct {
Document struct {
ID string `json:"id"`
} `json:"document"`
DocumentVersion *struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"documentVersion"`
} `json:"updateDocument"`
}
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"id": docID,
"documentType": "PROCEDURE",
},
}, &result)
require.NoError(t, err)
require.NotNil(t, result.UpdateDocument.DocumentVersion)
assert.Equal(t, "DRAFT", result.UpdateDocument.DocumentVersion.Status)
})
t.Run("classification update creates draft", func(t *testing.T) {
t.Parallel()
// Create and approve a document (auto-publishes on approval)
docID, _ := createTestDocument(t, owner)
approveTestDocument(t, owner, docID)
// Updating classification should auto-create a draft
query := `
mutation UpdateDocument($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document {
id
}
documentVersion {
id
status
}
}
}
`
var result struct {
UpdateDocument struct {
Document struct {
ID string `json:"id"`
} `json:"document"`
DocumentVersion *struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"documentVersion"`
} `json:"updateDocument"`
}
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"id": docID,
"classification": "CONFIDENTIAL",
},
}, &result)
require.NoError(t, err)
require.NotNil(t, result.UpdateDocument.DocumentVersion)
assert.Equal(t, "DRAFT", result.UpdateDocument.DocumentVersion.Status)
})
}
func TestDocumentVersion_ViewerCannotUpdateDocument(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
// Create and approve a document (auto-publishes on approval)
docID, _ := createTestDocument(t, owner)
approveTestDocument(t, owner, docID)
// Viewer attempts to update content on the published document
_, err := viewer.Do(`
mutation UpdateDocument($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document { id }
}
}
`, map[string]any{
"input": map[string]any{
"id": docID,
"content": testutil.ProseMirrorTextDoc("Viewer updated content"),
},
})
testutil.RequireForbiddenError(t, err, "viewer should not be able to update document")
}
func TestDocumentVersion_BulkDelete(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
@@ -1143,3 +1338,148 @@ func TestDocument_DefaultApprovers(t *testing.T) {
assert.Empty(t, result.UpdateDocument.Document.DefaultApprovers)
})
}
func TestDocumentVersion_DeleteDraft(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
const query = `
mutation DeleteDocumentDraft($input: DeleteDocumentDraftInput!) {
deleteDocumentDraft(input: $input) {
document {
id
}
}
}
`
t.Run(
"delete draft after publishing",
func(t *testing.T) {
t.Parallel()
docID, _ := createTestDocument(t, owner)
approveTestDocument(t, owner, docID)
// Create a draft by updating content
updateQuery := `
mutation UpdateDocument($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document { id }
documentVersion { id status }
}
}
`
var updateResult struct {
UpdateDocument struct {
Document struct {
ID string `json:"id"`
} `json:"document"`
DocumentVersion *struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"documentVersion"`
} `json:"updateDocument"`
}
err := owner.Execute(updateQuery, map[string]any{
"input": map[string]any{
"id": docID,
"content": testutil.ProseMirrorTextDoc("Draft content"),
},
}, &updateResult)
require.NoError(t, err)
require.NotNil(t, updateResult.UpdateDocument.DocumentVersion)
assert.Equal(t, "DRAFT", updateResult.UpdateDocument.DocumentVersion.Status)
// Now delete the draft
var result struct {
DeleteDocumentDraft struct {
Document struct {
ID string `json:"id"`
} `json:"document"`
} `json:"deleteDocumentDraft"`
}
err = owner.Execute(query, map[string]any{
"input": map[string]any{"documentId": docID},
}, &result)
require.NoError(t, err)
assert.Equal(t, docID, result.DeleteDocumentDraft.Document.ID)
},
)
t.Run(
"cannot delete initial v0.1 draft",
func(t *testing.T) {
t.Parallel()
docID, _ := createTestDocument(t, owner)
var result struct{}
err := owner.Execute(query, map[string]any{
"input": map[string]any{"documentId": docID},
}, &result)
require.Error(t, err)
},
)
t.Run(
"cannot delete when latest is published",
func(t *testing.T) {
t.Parallel()
docID, _ := createTestDocument(t, owner)
approveTestDocument(t, owner, docID)
var result struct{}
err := owner.Execute(query, map[string]any{
"input": map[string]any{"documentId": docID},
}, &result)
require.Error(t, err)
},
)
t.Run(
"viewer cannot delete draft",
func(t *testing.T) {
t.Parallel()
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
docID, _ := createTestDocument(t, owner)
approveTestDocument(t, owner, docID)
// Create a draft
updateQuery := `
mutation UpdateDocument($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document { id }
documentVersion { id }
}
}
`
var updateResult struct {
UpdateDocument struct {
Document struct {
ID string `json:"id"`
} `json:"document"`
DocumentVersion *struct {
ID string `json:"id"`
} `json:"documentVersion"`
} `json:"updateDocument"`
}
err := owner.Execute(updateQuery, map[string]any{
"input": map[string]any{
"id": docID,
"content": testutil.ProseMirrorTextDoc("Draft content"),
},
}, &updateResult)
require.NoError(t, err)
var result struct{}
err = viewer.Execute(query, map[string]any{
"input": map[string]any{"documentId": docID},
}, &result)
testutil.RequireForbiddenError(t, err, "viewer should not be able to delete document draft")
},
)
}