Resolve document publish approvers from defaults
The MCP publishDocument tool required callers to pass approver_ids and to distinguish an omitted list (rejected) from an empty one (direct publish), a null-vs-empty subtlety that is awkward for an LLM to get right. Drop approver_ids from the MCP tool and resolve a major publish's approvers from the document's default approvers instead: an approval is requested when the document has default approvers, otherwise the version is published directly. Default approvers are configured with addDocument or updateDocument. Replace a document's default approvers on every major publish that supplies an explicit list, even when the list is empty, so a direct publish through the GraphQL API clears stale approvers instead of leaving them behind (previously the empty case skipped the update). Expose the default-approver behaviour as a separate entry point, PublishVersionWithDefaultApprovers, that loads the defaults and delegates to PublishVersion. PublishVersion keeps its explicit-approver contract for the GraphQL API. Require only the publish permission to publish a version, whether or not it opens an approval quorum, and drop the now-unused request-approval action. Fold the publish steps into the publishMinor and publishMajor primitives shared by both the single and bulk publish paths, and drop the redundant InTx suffix from RequestApproval and emitDocumentEvent, which already take a transaction argument. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
@@ -1688,6 +1688,45 @@ func TestDocument_DefaultApprovers(t *testing.T) {
|
||||
|
||||
assert.Empty(t, result.UpdateDocument.Document.DefaultApprovers)
|
||||
})
|
||||
|
||||
t.Run("major publish with explicit approvers updates default approvers, even when empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
docID := createTestDocumentWithApprovers(t, owner, []string{approverID})
|
||||
|
||||
var result struct {
|
||||
PublishDocument struct {
|
||||
Document struct {
|
||||
DefaultApprovers []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"defaultApprovers"`
|
||||
} `json:"document"`
|
||||
DocumentVersion struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"documentVersion"`
|
||||
} `json:"publishDocument"`
|
||||
}
|
||||
|
||||
err := owner.Execute(`
|
||||
mutation($input: PublishDocumentInput!) {
|
||||
publishDocument(input: $input) {
|
||||
document { defaultApprovers { id } }
|
||||
documentVersion { status }
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"minor": false,
|
||||
"documentId": docID,
|
||||
"approverIds": []string{},
|
||||
"changelog": "Direct publish clearing approvers",
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "PUBLISHED", result.PublishDocument.DocumentVersion.Status)
|
||||
assert.Empty(t, result.PublishDocument.Document.DefaultApprovers)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDocumentVersion_DeleteDraft(t *testing.T) {
|
||||
|
||||
@@ -93,3 +93,71 @@ func TestMCP_Document_CRUD(t *testing.T) {
|
||||
}, &deleteResult)
|
||||
assert.Equal(t, addResult.Document.ID, deleteResult.DeletedDocumentID)
|
||||
}
|
||||
|
||||
func TestMCP_Document_PublishUsesDefaultApprovers(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
orgID := owner.GetOrganizationID().String()
|
||||
approverID := owner.GetProfileID().String()
|
||||
|
||||
addDocument := func(defaultApproverIDs []string) string {
|
||||
input := map[string]any{
|
||||
"organizationId": orgID,
|
||||
"title": factory.SafeName("Document"),
|
||||
"content": "Body content",
|
||||
"classification": "INTERNAL",
|
||||
"documentType": "POLICY",
|
||||
}
|
||||
if defaultApproverIDs != nil {
|
||||
input["defaultApproverIds"] = defaultApproverIDs
|
||||
}
|
||||
|
||||
var addResult struct {
|
||||
Document struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"document"`
|
||||
}
|
||||
mc.CallToolInto("addDocument", input, &addResult)
|
||||
require.NotEmpty(t, addResult.Document.ID)
|
||||
|
||||
return addResult.Document.ID
|
||||
}
|
||||
|
||||
type publishResult struct {
|
||||
DocumentVersion struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"documentVersion"`
|
||||
ApprovalQuorum *struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"approvalQuorum"`
|
||||
}
|
||||
|
||||
t.Run("requests approval from default approvers", func(t *testing.T) {
|
||||
docID := addDocument([]string{approverID})
|
||||
|
||||
var result publishResult
|
||||
mc.CallToolInto("publishDocument", map[string]any{
|
||||
"documentId": docID,
|
||||
"minor": false,
|
||||
"changelog": "Initial major",
|
||||
}, &result)
|
||||
|
||||
require.NotNil(t, result.ApprovalQuorum)
|
||||
assert.Equal(t, "PENDING_APPROVAL", result.DocumentVersion.Status)
|
||||
})
|
||||
|
||||
t.Run("publishes directly without default approvers", func(t *testing.T) {
|
||||
docID := addDocument(nil)
|
||||
|
||||
var result publishResult
|
||||
mc.CallToolInto("publishDocument", map[string]any{
|
||||
"documentId": docID,
|
||||
"minor": false,
|
||||
"changelog": "Initial major",
|
||||
}, &result)
|
||||
|
||||
assert.Nil(t, result.ApprovalQuorum)
|
||||
assert.Equal(t, "PUBLISHED", result.DocumentVersion.Status)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -200,17 +200,16 @@ const (
|
||||
ActionDocumentDeleteDraft = "core:document:delete-draft"
|
||||
|
||||
// DocumentVersion actions
|
||||
ActionDocumentVersionGet = "core:document-version:get"
|
||||
ActionDocumentVersionList = "core:document-version:list"
|
||||
ActionDocumentVersionExportPDF = "core:document-version:export-pdf"
|
||||
ActionDocumentVersionSign = "core:document-version:sign"
|
||||
ActionDocumentVersionRequestApproval = "core:document-version:request-approval"
|
||||
ActionDocumentVersionVoidApproval = "core:document-version:void-approval"
|
||||
ActionDocumentVersionApprove = "core:document-version:approve"
|
||||
ActionDocumentVersionReject = "core:document-version:reject"
|
||||
ActionDocumentVersionApprovalList = "core:document-version:approval-list"
|
||||
ActionDocumentVersionPublish = "core:document-version:publish"
|
||||
ActionDocumentVersionExport = "core:document-version:export"
|
||||
ActionDocumentVersionGet = "core:document-version:get"
|
||||
ActionDocumentVersionList = "core:document-version:list"
|
||||
ActionDocumentVersionExportPDF = "core:document-version:export-pdf"
|
||||
ActionDocumentVersionSign = "core:document-version:sign"
|
||||
ActionDocumentVersionVoidApproval = "core:document-version:void-approval"
|
||||
ActionDocumentVersionApprove = "core:document-version:approve"
|
||||
ActionDocumentVersionReject = "core:document-version:reject"
|
||||
ActionDocumentVersionApprovalList = "core:document-version:approval-list"
|
||||
ActionDocumentVersionPublish = "core:document-version:publish"
|
||||
ActionDocumentVersionExport = "core:document-version:export"
|
||||
|
||||
// EmployeeDocument actions
|
||||
ActionEmployeeDocumentGet = "core:employee-document:get"
|
||||
|
||||
@@ -75,8 +75,9 @@ func (e ErrApprovalDecisionAlreadyMade) Error() string {
|
||||
return "approval decision has already been made"
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) RequestApprovalInTx(
|
||||
ctx context.Context, scope coredata.Scoper,
|
||||
func (s *DocumentApprovalService) RequestApproval(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
tx pg.Tx,
|
||||
document *coredata.Document,
|
||||
documentVersion *coredata.DocumentVersion,
|
||||
@@ -134,7 +135,8 @@ func (s *DocumentApprovalService) RequestApprovalInTx(
|
||||
// an approval is requested for it; otherwise it is published as a major
|
||||
// bump. Documents with no draft (or already pending approval) are skipped.
|
||||
func (s *DocumentApprovalService) BulkPublishVersions(
|
||||
ctx context.Context, scope coredata.Scoper,
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
req BulkPublishVersionsRequest,
|
||||
) ([]*coredata.DocumentVersion, []*coredata.Document, error) {
|
||||
var (
|
||||
@@ -183,7 +185,14 @@ func (s *DocumentApprovalService) BulkPublishVersions(
|
||||
if req.Minor {
|
||||
var err error
|
||||
|
||||
document, dv, err = s.svc.Documents.publishMinorVersionInTx(ctx, scope, tx, documentID, &req.Changelog, true)
|
||||
document, dv, err = s.svc.Documents.publishMinor(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
documentID,
|
||||
&req.Changelog,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot publish document %q: %w", documentID, err)
|
||||
}
|
||||
@@ -199,7 +208,15 @@ func (s *DocumentApprovalService) BulkPublishVersions(
|
||||
approverIDs[i] = a.ApproverProfileID
|
||||
}
|
||||
|
||||
quorum, err := s.RequestApprovalInTx(ctx, scope, tx, document, dv, approverIDs, &req.Changelog)
|
||||
quorum, err := s.RequestApproval(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
document,
|
||||
dv,
|
||||
approverIDs,
|
||||
&req.Changelog,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot request approval for %q: %w", documentID, err)
|
||||
}
|
||||
@@ -208,7 +225,14 @@ func (s *DocumentApprovalService) BulkPublishVersions(
|
||||
} else {
|
||||
var err error
|
||||
|
||||
document, dv, err = s.svc.Documents.publishMajorVersionInTx(ctx, scope, tx, documentID, &req.Changelog, true)
|
||||
document, dv, err = s.svc.Documents.publishMajor(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
documentID,
|
||||
&req.Changelog,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot publish document %q: %w", documentID, err)
|
||||
}
|
||||
@@ -218,8 +242,11 @@ func (s *DocumentApprovalService) BulkPublishVersions(
|
||||
publishedVersions = append(publishedVersions, dv)
|
||||
updatedDocuments = append(updatedDocuments, document)
|
||||
|
||||
// A direct publish emits the version-published webhook inside
|
||||
// publishMinor/publishMajor; only the approval-quorum case needs
|
||||
// its event emitted here.
|
||||
if requestedQuorum != nil {
|
||||
if err := s.svc.Documents.emitDocumentEventInTx(
|
||||
if err := s.svc.Documents.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
@@ -232,20 +259,6 @@ func (s *DocumentApprovalService) BulkPublishVersions(
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot emit approval quorum requested webhook: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := s.svc.Documents.emitDocumentEventInTx(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
dv.DocumentID,
|
||||
coredata.WebhookEventTypeDocumentVersionPublished,
|
||||
dv,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot emit version published webhook: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,7 +466,7 @@ func (s *DocumentApprovalService) Approve(
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.svc.Documents.emitDocumentEventInTx(
|
||||
if err := s.svc.Documents.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
@@ -552,7 +565,7 @@ func (s *DocumentApprovalService) Reject(
|
||||
return fmt.Errorf("cannot update document version status: %w", err)
|
||||
}
|
||||
|
||||
if err := s.svc.Documents.emitDocumentEventInTx(
|
||||
if err := s.svc.Documents.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
@@ -566,7 +579,7 @@ func (s *DocumentApprovalService) Reject(
|
||||
return fmt.Errorf("cannot emit approval quorum rejected webhook: %w", err)
|
||||
}
|
||||
|
||||
if err := s.svc.Documents.emitDocumentEventInTx(
|
||||
if err := s.svc.Documents.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
@@ -658,7 +671,7 @@ func (s *DocumentApprovalService) VoidApproval(
|
||||
return fmt.Errorf("cannot update document version status: %w", err)
|
||||
}
|
||||
|
||||
if err := s.svc.Documents.emitDocumentEventInTx(
|
||||
if err := s.svc.Documents.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
@@ -1022,7 +1035,7 @@ func (s *DocumentApprovalService) maybeApproveQuorum(
|
||||
return fmt.Errorf("cannot publish version: %w", err)
|
||||
}
|
||||
|
||||
if err := s.svc.Documents.emitDocumentEventInTx(
|
||||
if err := s.svc.Documents.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
@@ -1036,7 +1049,7 @@ func (s *DocumentApprovalService) maybeApproveQuorum(
|
||||
return fmt.Errorf("cannot emit approval quorum approved webhook: %w", err)
|
||||
}
|
||||
|
||||
if err := s.svc.Documents.emitDocumentEventInTx(
|
||||
if err := s.svc.Documents.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
|
||||
@@ -580,140 +580,36 @@ func (s DocumentService) generateChangelog(
|
||||
return &text, nil
|
||||
}
|
||||
|
||||
// PublishVersion is the single entry point for publishing a document
|
||||
// version. The behaviour depends on req.Minor and req.ApproverIDs:
|
||||
// - Minor=true: publish the existing draft as a minor version. ApproverIDs
|
||||
// are ignored.
|
||||
// - Minor=false with ApproverIDs: open an approval quorum on the draft as
|
||||
// a pending major bump (currentMajor+1.0). Result.Quorum is set.
|
||||
// - Minor=false without ApproverIDs: publish the draft immediately as a
|
||||
// major bump (currentMajor+1.0).
|
||||
func (s *DocumentService) PublishVersion(
|
||||
ctx context.Context, scope coredata.Scoper,
|
||||
func (s *DocumentService) PublishVersionWithDefaultApprovers(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
req PublishDocumentRequest,
|
||||
) (*PublishDocumentResult, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &PublishDocumentResult{}
|
||||
var result *PublishDocumentResult
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
dv := &coredata.DocumentVersion{}
|
||||
if err := dv.LoadLatestVersion(ctx, tx, scope, req.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot load latest version: %w", err)
|
||||
}
|
||||
|
||||
if dv.Status == coredata.DocumentVersionStatusPendingApproval {
|
||||
return &ErrDocumentVersionPendingApproval{}
|
||||
}
|
||||
|
||||
if req.Minor {
|
||||
document, version, err := s.publishMinorVersionInTx(ctx, scope, tx, req.DocumentID, &req.Changelog, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot publish minor version: %w", err)
|
||||
if !req.Minor {
|
||||
defaultApprovers := &coredata.DocumentDefaultApprovers{}
|
||||
if err := defaultApprovers.LoadByDocumentID(ctx, tx, scope, req.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot load default approvers: %w", err)
|
||||
}
|
||||
|
||||
result.Document = document
|
||||
result.Version = version
|
||||
|
||||
if err := s.emitDocumentEventInTx(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
version.DocumentID,
|
||||
coredata.WebhookEventTypeDocumentVersionPublished,
|
||||
version,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot emit document version published webhook: %w", err)
|
||||
approverIDs := make([]gid.GID, len(*defaultApprovers))
|
||||
for i, a := range *defaultApprovers {
|
||||
approverIDs[i] = a.ApproverProfileID
|
||||
}
|
||||
|
||||
return nil
|
||||
req.ApproverIDs = approverIDs
|
||||
}
|
||||
|
||||
if len(req.ApproverIDs) == 0 {
|
||||
document, version, err := s.publishMajorVersionInTx(ctx, scope, tx, req.DocumentID, &req.Changelog, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot publish major version: %w", err)
|
||||
}
|
||||
|
||||
result.Document = document
|
||||
result.Version = version
|
||||
|
||||
if err := s.emitDocumentEventInTx(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
version.DocumentID,
|
||||
coredata.WebhookEventTypeDocumentVersionPublished,
|
||||
version,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot emit document version published webhook: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
profiles := &coredata.MembershipProfiles{}
|
||||
if err := profiles.LoadByIDs(ctx, tx, scope, req.ApproverIDs); err != nil {
|
||||
return fmt.Errorf("cannot load approver profiles: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, p := range *profiles {
|
||||
if p.ContractEndDate != nil && p.ContractEndDate.Before(now) {
|
||||
return &ErrProfileContractEnded{ProfileID: p.ID}
|
||||
}
|
||||
}
|
||||
|
||||
document := &coredata.Document{}
|
||||
if err := document.LoadByID(ctx, tx, scope, req.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
if dv.Status != coredata.DocumentVersionStatusDraft {
|
||||
return &ErrDocumentVersionNotDraft{}
|
||||
}
|
||||
|
||||
quorum, err := s.svc.DocumentApprovals.RequestApprovalInTx(ctx, scope, tx, document, dv, req.ApproverIDs, &req.Changelog)
|
||||
publishResult, err := s.publish(ctx, scope, tx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot request approval: %w", err)
|
||||
return fmt.Errorf("cannot publish version: %w", err)
|
||||
}
|
||||
|
||||
defaultApprovers := &coredata.DocumentDefaultApprovers{}
|
||||
if err := defaultApprovers.MergeByDocumentID(ctx, tx, scope, req.DocumentID, document.OrganizationID, req.ApproverIDs); err != nil {
|
||||
return fmt.Errorf("cannot update default approvers: %w", err)
|
||||
}
|
||||
|
||||
result.Document = document
|
||||
result.Version = dv
|
||||
result.Quorum = quorum
|
||||
|
||||
if err := s.emitDocumentEventInTx(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
dv.DocumentID,
|
||||
coredata.WebhookEventTypeDocumentVersionApprovalQuorumRequested,
|
||||
dv,
|
||||
nil,
|
||||
&quorum.ID,
|
||||
nil,
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot emit approval quorum requested webhook: %w", err)
|
||||
}
|
||||
result = publishResult
|
||||
|
||||
return nil
|
||||
},
|
||||
@@ -725,6 +621,161 @@ func (s *DocumentService) PublishVersion(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PublishVersion publishes a document version. The behaviour depends on
|
||||
// req.Minor and req.ApproverIDs:
|
||||
// - Minor=true: publish the existing draft as a minor version. ApproverIDs
|
||||
// are ignored.
|
||||
// - Minor=false with ApproverIDs: open an approval quorum on the draft as
|
||||
// a pending major bump (currentMajor+1.0). Result.Quorum is set.
|
||||
// - Minor=false without ApproverIDs: publish the draft immediately as a
|
||||
// major bump (currentMajor+1.0).
|
||||
//
|
||||
// A major publish also replaces the document's default approvers with
|
||||
// req.ApproverIDs, even when the list is empty.
|
||||
func (s *DocumentService) PublishVersion(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
req PublishDocumentRequest,
|
||||
) (*PublishDocumentResult, error) {
|
||||
var result *PublishDocumentResult
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
publishResult, err := s.publish(ctx, scope, tx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot publish version: %w", err)
|
||||
}
|
||||
|
||||
result = publishResult
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) publish(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
tx pg.Tx,
|
||||
req PublishDocumentRequest,
|
||||
) (*PublishDocumentResult, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &PublishDocumentResult{}
|
||||
|
||||
dv := &coredata.DocumentVersion{}
|
||||
if err := dv.LoadLatestVersion(ctx, tx, scope, req.DocumentID); err != nil {
|
||||
return nil, fmt.Errorf("cannot load latest version: %w", err)
|
||||
}
|
||||
|
||||
if dv.Status == coredata.DocumentVersionStatusPendingApproval {
|
||||
return nil, &ErrDocumentVersionPendingApproval{}
|
||||
}
|
||||
|
||||
if req.Minor {
|
||||
document, version, err := s.publishMinor(ctx, scope, tx, req.DocumentID, &req.Changelog, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot publish minor version: %w", err)
|
||||
}
|
||||
|
||||
result.Document = document
|
||||
result.Version = version
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
document := &coredata.Document{}
|
||||
if err := document.LoadByID(ctx, tx, scope, req.DocumentID); err != nil {
|
||||
return nil, fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return nil, &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
defaultApprovers := &coredata.DocumentDefaultApprovers{}
|
||||
if err := defaultApprovers.MergeByDocumentID(ctx, tx, scope, req.DocumentID, document.OrganizationID, req.ApproverIDs); err != nil {
|
||||
return nil, fmt.Errorf("cannot update default approvers: %w", err)
|
||||
}
|
||||
|
||||
if len(req.ApproverIDs) == 0 {
|
||||
publishedDocument, version, err := s.publishMajor(ctx, scope, tx, req.DocumentID, &req.Changelog, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot publish major version: %w", err)
|
||||
}
|
||||
|
||||
result.Document = publishedDocument
|
||||
result.Version = version
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if err := s.requestMajorApproval(ctx, scope, tx, req, document, dv, result); err != nil {
|
||||
return nil, fmt.Errorf("cannot request major approval: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) requestMajorApproval(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
tx pg.Tx,
|
||||
req PublishDocumentRequest,
|
||||
document *coredata.Document,
|
||||
dv *coredata.DocumentVersion,
|
||||
result *PublishDocumentResult,
|
||||
) error {
|
||||
profiles := &coredata.MembershipProfiles{}
|
||||
if err := profiles.LoadByIDs(ctx, tx, scope, req.ApproverIDs); err != nil {
|
||||
return fmt.Errorf("cannot load approver profiles: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, p := range *profiles {
|
||||
if p.ContractEndDate != nil && p.ContractEndDate.Before(now) {
|
||||
return &ErrProfileContractEnded{ProfileID: p.ID}
|
||||
}
|
||||
}
|
||||
|
||||
if dv.Status != coredata.DocumentVersionStatusDraft {
|
||||
return &ErrDocumentVersionNotDraft{}
|
||||
}
|
||||
|
||||
quorum, err := s.svc.DocumentApprovals.RequestApproval(ctx, scope, tx, document, dv, req.ApproverIDs, &req.Changelog)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot request approval: %w", err)
|
||||
}
|
||||
|
||||
result.Document = document
|
||||
result.Version = dv
|
||||
result.Quorum = quorum
|
||||
|
||||
if err := s.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
dv.DocumentID,
|
||||
coredata.WebhookEventTypeDocumentVersionApprovalQuorumRequested,
|
||||
dv,
|
||||
nil,
|
||||
&quorum.ID,
|
||||
nil,
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot emit approval quorum requested webhook: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) Create(
|
||||
ctx context.Context, scope coredata.Scoper,
|
||||
req CreateDocumentRequest,
|
||||
@@ -808,11 +859,11 @@ func (s *DocumentService) Create(
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.emitDocumentEventInTx(ctx, scope, conn, documentID, coredata.WebhookEventTypeDocumentCreated, nil, nil, nil, nil); err != nil {
|
||||
if err := s.emitDocumentEvent(ctx, scope, conn, documentID, coredata.WebhookEventTypeDocumentCreated, nil, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("cannot emit document created webhook: %w", err)
|
||||
}
|
||||
|
||||
if err := s.emitDocumentEventInTx(
|
||||
if err := s.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
conn,
|
||||
@@ -988,7 +1039,7 @@ func (s *DocumentService) SignDocumentVersionByIdentity(
|
||||
return fmt.Errorf("cannot update document version signature: %w", err)
|
||||
}
|
||||
|
||||
if err := s.emitDocumentEventInTx(
|
||||
if err := s.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
@@ -1255,7 +1306,7 @@ func (s *DocumentService) RequestSignature(
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.emitDocumentEventInTx(
|
||||
if err := s.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
@@ -1377,7 +1428,7 @@ func (s *DocumentService) deleteDraftInTx(
|
||||
|
||||
// For deletion events this must be called before the document is soft-deleted,
|
||||
// since Document.LoadByID filters out soft-deleted rows.
|
||||
func (s *DocumentService) emitDocumentEventInTx(
|
||||
func (s *DocumentService) emitDocumentEvent(
|
||||
ctx context.Context, scope coredata.Scoper,
|
||||
tx pg.Tx,
|
||||
documentID gid.GID,
|
||||
@@ -1543,7 +1594,7 @@ func (s *DocumentService) SoftDelete(
|
||||
return s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
if err := s.emitDocumentEventInTx(ctx, scope, tx, documentID, coredata.WebhookEventTypeDocumentDeleted, nil, nil, nil, nil); err != nil {
|
||||
if err := s.emitDocumentEvent(ctx, scope, tx, documentID, coredata.WebhookEventTypeDocumentDeleted, nil, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("cannot emit document deleted webhook: %w", err)
|
||||
}
|
||||
|
||||
@@ -2239,7 +2290,7 @@ func (s *DocumentService) Update(
|
||||
}
|
||||
|
||||
if versionDeleted {
|
||||
if err := s.emitDocumentEventInTx(
|
||||
if err := s.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
@@ -2259,14 +2310,14 @@ func (s *DocumentService) Update(
|
||||
versionPrevious = nil
|
||||
}
|
||||
|
||||
if err := s.emitDocumentEventInTx(ctx, scope, tx, resultVersion.DocumentID, versionEvent, resultVersion, nil, nil, versionPrevious); err != nil {
|
||||
if err := s.emitDocumentEvent(ctx, scope, tx, resultVersion.DocumentID, versionEvent, resultVersion, nil, nil, versionPrevious); err != nil {
|
||||
return fmt.Errorf("cannot emit document version webhook: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if docLevelChanged {
|
||||
if err := s.emitDocumentEventInTx(ctx, scope, tx, req.DocumentID, coredata.WebhookEventTypeDocumentUpdated, nil, nil, nil, webhooktypes.NewDocument(&previousDocument)); err != nil {
|
||||
if err := s.emitDocumentEvent(ctx, scope, tx, req.DocumentID, coredata.WebhookEventTypeDocumentUpdated, nil, nil, nil, webhooktypes.NewDocument(&previousDocument)); err != nil {
|
||||
return fmt.Errorf("cannot emit document updated webhook: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -2315,7 +2366,7 @@ func (s *DocumentService) DeleteDraft(
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.emitDocumentEventInTx(
|
||||
if err := s.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
@@ -2385,7 +2436,7 @@ func (s *DocumentService) Archive(
|
||||
return fmt.Errorf("cannot archive document: %w", err)
|
||||
}
|
||||
|
||||
if err := s.emitDocumentEventInTx(ctx, scope, tx, documentID, coredata.WebhookEventTypeDocumentArchived, nil, nil, nil, nil); err != nil {
|
||||
if err := s.emitDocumentEvent(ctx, scope, tx, documentID, coredata.WebhookEventTypeDocumentArchived, nil, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("cannot emit document archived webhook: %w", err)
|
||||
}
|
||||
|
||||
@@ -2425,7 +2476,7 @@ func (s *DocumentService) Unarchive(
|
||||
return fmt.Errorf("cannot unarchive document: %w", err)
|
||||
}
|
||||
|
||||
if err := s.emitDocumentEventInTx(ctx, scope, tx, documentID, coredata.WebhookEventTypeDocumentUnarchived, nil, nil, nil, nil); err != nil {
|
||||
if err := s.emitDocumentEvent(ctx, scope, tx, documentID, coredata.WebhookEventTypeDocumentUnarchived, nil, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("cannot emit document unarchived webhook: %w", err)
|
||||
}
|
||||
|
||||
@@ -2477,7 +2528,7 @@ func (s *DocumentService) CancelSignatureRequest(
|
||||
return fmt.Errorf("cannot delete document version signature: %w", err)
|
||||
}
|
||||
|
||||
if err := s.emitDocumentEventInTx(
|
||||
if err := s.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
@@ -3199,8 +3250,13 @@ func (s *DocumentService) finalizePublish(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) publishMajorVersionInTx(
|
||||
ctx context.Context, scope coredata.Scoper,
|
||||
// publishMajor publishes the document's draft as a new major version
|
||||
// (currentMajor+1.0) and emits the version-published webhook. ignoreExisting is
|
||||
// set by bulk publish to treat an already-published version as an idempotent
|
||||
// no-op.
|
||||
func (s *DocumentService) publishMajor(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
tx pg.Tx,
|
||||
documentID gid.GID,
|
||||
changelog *string,
|
||||
@@ -3238,6 +3294,20 @@ func (s *DocumentService) publishMajorVersionInTx(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err := s.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
documentVersion.DocumentID,
|
||||
coredata.WebhookEventTypeDocumentVersionPublished,
|
||||
documentVersion,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot emit document version published webhook: %w", err)
|
||||
}
|
||||
|
||||
return document, documentVersion, nil
|
||||
}
|
||||
|
||||
@@ -3260,8 +3330,12 @@ func (s *DocumentService) cancelPreviousMajorSignatureRequestsInTx(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) publishMinorVersionInTx(
|
||||
ctx context.Context, scope coredata.Scoper,
|
||||
// publishMinor publishes the document's draft as a minor version and emits the
|
||||
// version-published webhook. ignoreExisting is set by bulk publish to treat an
|
||||
// already-published version as an idempotent no-op.
|
||||
func (s *DocumentService) publishMinor(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
tx pg.Tx,
|
||||
documentID gid.GID,
|
||||
changelog *string,
|
||||
@@ -3287,6 +3361,20 @@ func (s *DocumentService) publishMinorVersionInTx(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err := s.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
documentVersion.DocumentID,
|
||||
coredata.WebhookEventTypeDocumentVersionPublished,
|
||||
documentVersion,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot emit document version published webhook: %w", err)
|
||||
}
|
||||
|
||||
return document, documentVersion, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3209,7 +3209,8 @@ func formatRiskTreatment(t coredata.RiskTreatment) string {
|
||||
// minor is false a non-empty approverIDs triggers an approval request at
|
||||
// (currentMajor+1).0; otherwise the version is published at (currentMajor+1).0.
|
||||
func (s *GeneratedDocumentService) publishOrRequestApproval(
|
||||
ctx context.Context, scope coredata.Scoper,
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
tx pg.Tx,
|
||||
document *coredata.Document,
|
||||
version *coredata.DocumentVersion,
|
||||
@@ -3290,18 +3291,18 @@ func (s *GeneratedDocumentService) publishOrRequestApproval(
|
||||
return fmt.Errorf("cannot save default approvers: %w", err)
|
||||
}
|
||||
|
||||
quorum, err := s.svc.DocumentApprovals.RequestApprovalInTx(ctx, scope, tx, document, version, approverIDs, nil)
|
||||
quorum, err := s.svc.DocumentApprovals.RequestApproval(ctx, scope, tx, document, version, approverIDs, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot request approval: %w", err)
|
||||
}
|
||||
|
||||
if isFirstVersion {
|
||||
if err := s.svc.Documents.emitDocumentEventInTx(ctx, scope, tx, document.ID, coredata.WebhookEventTypeDocumentCreated, nil, nil, nil, nil); err != nil {
|
||||
if err := s.svc.Documents.emitDocumentEvent(ctx, scope, tx, document.ID, coredata.WebhookEventTypeDocumentCreated, nil, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("cannot emit document created webhook: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.svc.Documents.emitDocumentEventInTx(
|
||||
if err := s.svc.Documents.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
@@ -3333,12 +3334,12 @@ func (s *GeneratedDocumentService) publishOrRequestApproval(
|
||||
}
|
||||
|
||||
if isFirstVersion {
|
||||
if err := s.svc.Documents.emitDocumentEventInTx(ctx, scope, tx, document.ID, coredata.WebhookEventTypeDocumentCreated, nil, nil, nil, nil); err != nil {
|
||||
if err := s.svc.Documents.emitDocumentEvent(ctx, scope, tx, document.ID, coredata.WebhookEventTypeDocumentCreated, nil, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("cannot emit document created webhook: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.svc.Documents.emitDocumentEventInTx(
|
||||
if err := s.svc.Documents.emitDocumentEvent(
|
||||
ctx,
|
||||
scope,
|
||||
tx,
|
||||
|
||||
@@ -307,7 +307,6 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
|
||||
ActionDocumentUnarchive,
|
||||
ActionDocumentDeleteDraft,
|
||||
ActionDocumentVersionSign,
|
||||
ActionDocumentVersionRequestApproval,
|
||||
ActionDocumentVersionVoidApproval,
|
||||
ActionDocumentVersionApprove,
|
||||
ActionDocumentVersionReject,
|
||||
|
||||
@@ -1036,12 +1036,7 @@ func (r *mutationResolver) DeleteDocument(ctx context.Context, input types.Delet
|
||||
|
||||
// PublishDocument is the resolver for the publishDocument field.
|
||||
func (r *mutationResolver) PublishDocument(ctx context.Context, input types.PublishDocumentInput) (*types.PublishDocumentPayload, error) {
|
||||
action := probo.ActionDocumentVersionPublish
|
||||
if !input.Minor && len(input.ApproverIds) > 0 {
|
||||
action = probo.ActionDocumentVersionRequestApproval
|
||||
}
|
||||
|
||||
scope, err := r.authorize(ctx, input.DocumentID, action)
|
||||
scope, err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentVersionPublish)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -6084,23 +6084,17 @@ func (r *Resolver) ListSCIMEventsTool(ctx context.Context, req *mcp.CallToolRequ
|
||||
}
|
||||
|
||||
func (r *Resolver) PublishDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishDocumentInput) (*mcp.CallToolResult, types.PublishDocumentOutput, error) {
|
||||
action := probo.ActionDocumentVersionPublish
|
||||
if !input.Minor && len(input.ApproverIds) > 0 {
|
||||
action = probo.ActionDocumentVersionRequestApproval
|
||||
}
|
||||
|
||||
scope, err := r.Authorize(ctx, input.DocumentID, action)
|
||||
scope, err := r.Authorize(ctx, input.DocumentID, probo.ActionDocumentVersionPublish)
|
||||
if err != nil {
|
||||
return nil, types.PublishDocumentOutput{}, err
|
||||
}
|
||||
|
||||
svc := r.proboSvc
|
||||
|
||||
result, err := svc.Documents.PublishVersion(ctx, scope, probo.PublishDocumentRequest{
|
||||
DocumentID: input.DocumentID,
|
||||
Minor: input.Minor,
|
||||
ApproverIDs: input.ApproverIds,
|
||||
Changelog: input.Changelog,
|
||||
result, err := svc.Documents.PublishVersionWithDefaultApprovers(ctx, scope, probo.PublishDocumentRequest{
|
||||
DocumentID: input.DocumentID,
|
||||
Minor: input.Minor,
|
||||
Changelog: input.Changelog,
|
||||
})
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot publish document: %w", err))
|
||||
|
||||
@@ -6305,12 +6305,7 @@ components:
|
||||
description: Document ID
|
||||
minor:
|
||||
type: boolean
|
||||
description: When true, publish the draft as a minor version; approver_ids must be omitted. When false, publish as a new major version; approver_ids is required (a non-empty list requests approval, an empty list publishes directly without approval).
|
||||
approver_ids:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: "Approver profile IDs. Required when minor is false: pass a non-empty list to request approval (approvers receive an email notification), or an empty list to publish directly without approval. Must be omitted when minor is true."
|
||||
description: When true, publish the draft as a minor version (the document must already have a published major version). When false, publish a new major version using the document's default approvers; if the document has default approvers an approval is requested from them (they receive an email notification), otherwise the version is published directly. Configure a document's default approvers with updateDocument.
|
||||
changelog:
|
||||
type: string
|
||||
description: Changelog for this version
|
||||
@@ -6328,7 +6323,7 @@ components:
|
||||
$ref: "#/components/schemas/DocumentVersion"
|
||||
approval_quorum:
|
||||
$ref: "#/components/schemas/DocumentVersionApprovalQuorum"
|
||||
description: Set when an approval was requested instead of publishing.
|
||||
description: Set when an approval was requested from the document's default approvers instead of publishing directly.
|
||||
|
||||
DeleteDocumentInput:
|
||||
type: object
|
||||
|
||||
Reference in New Issue
Block a user