Redesign document approval flow

Replace the per-approver add/remove model with a quorum-based approval
system. Documents now have default approvers that are pre-populated when
requesting approval, and the publish dialog lets users adjust the list
before submitting.

Key changes:

- Add PENDING_APPROVAL document version status with dedicated transitions
- Introduce approval quorums with request/approve/reject/void lifecycle
- Add default approvers per document (stored in document_default_approvers)
  with MERGE-based upsert for efficient sync
- Add NoDuplicates validator for slice fields
- Split ALTER TYPE ADD VALUE migrations into separate files (required by
  PostgreSQL when run inside transactions)
- Use VOIDED consistently for both quorum status and decision state enums
- Expose void/approve/reject through GraphQL and MCP, with e2e tests
- Add approval management UI: publish dialog with approver selection,
  approval list with void support, and external approve/reject page

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-09 11:17:51 +02:00
parent 17f579b8a2
commit ab5f42ad74
38 changed files with 2125 additions and 922 deletions

View File

@@ -104,6 +104,10 @@ enum DocumentVersionStatus
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusDraft"
)
PENDING_APPROVAL
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusPendingApproval"
)
PUBLISHED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusPublished"
@@ -2583,6 +2587,8 @@ type Document implements Node {
filter: ControlFilter
): ControlConnection! @goField(forceResolver: true)
defaultApprovers: [Profile!]! @goField(forceResolver: true)
status: DocumentStatus!
archivedAt: Datetime
@@ -3934,6 +3940,9 @@ type Mutation {
requestDocumentVersionApproval(
input: RequestDocumentVersionApprovalInput!
): RequestDocumentVersionApprovalPayload!
voidDocumentVersionApproval(
input: VoidDocumentVersionApprovalInput!
): VoidDocumentVersionApprovalPayload!
bulkDeleteDocuments(
input: BulkDeleteDocumentsInput!
): BulkDeleteDocumentsPayload!
@@ -3969,12 +3978,6 @@ type Mutation {
input: CancelSignatureRequestInput!
): CancelSignatureRequestPayload!
signDocument(input: SignDocumentInput!): SignDocumentPayload!
addDocumentVersionApprover(
input: AddDocumentVersionApproverInput!
): AddDocumentVersionApproverPayload!
removeDocumentVersionApprover(
input: RemoveDocumentVersionApproverInput!
): RemoveDocumentVersionApproverPayload!
approveDocumentVersion(
input: ApproveDocumentVersionInput!
): ApproveDocumentVersionPayload!
@@ -4668,6 +4671,7 @@ input CreateDocumentInput {
documentType: DocumentType!
classification: DocumentClassification!
trustCenterVisibility: TrustCenterVisibility
defaultApproverIds: [ID!]
}
input UpdateDocumentInput {
@@ -4675,6 +4679,7 @@ input UpdateDocumentInput {
title: String
content: String
trustCenterVisibility: TrustCenterVisibility
defaultApproverIds: [ID!]
}
input ExportDocumentVersionPDFInput {
@@ -5686,6 +5691,10 @@ enum DocumentVersionApprovalDecisionState
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateRejected"
)
VOIDED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateVoided"
)
}
enum DocumentVersionApprovalDecisionOrderField
@@ -5714,6 +5723,10 @@ enum DocumentVersionApprovalQuorumStatus
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusRejected"
)
VOIDED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusVoided"
)
}
enum DocumentVersionApprovalQuorumOrderField
@@ -5816,23 +5829,6 @@ type RejectDocumentVersionPayload {
approvalDecision: DocumentVersionApprovalDecision!
}
input AddDocumentVersionApproverInput {
documentVersionId: ID!
approverId: ID!
}
type AddDocumentVersionApproverPayload {
approvalDecisionEdge: DocumentVersionApprovalDecisionEdge!
}
input RemoveDocumentVersionApproverInput {
approvalDecisionId: ID!
}
type RemoveDocumentVersionApproverPayload {
deletedApprovalDecisionId: ID!
documentVersion: DocumentVersion!
}
input RequestSignatureInput {
documentVersionId: ID!
@@ -5897,6 +5893,15 @@ type RequestDocumentVersionApprovalPayload {
approvalQuorum: DocumentVersionApprovalQuorum!
}
input VoidDocumentVersionApprovalInput {
documentVersionId: ID!
}
type VoidDocumentVersionApprovalPayload {
approvalQuorum: DocumentVersionApprovalQuorum!
documentVersion: DocumentVersion!
}
input PublishMajorDocumentVersionInput {
documentId: ID!
changelog: String

View File

@@ -1577,6 +1577,28 @@ func (r *documentResolver) Controls(ctx context.Context, obj *types.Document, fi
return types.NewControlConnection(page, r, obj.ID, controlFilter), nil
}
// DefaultApprovers is the resolver for the defaultApprovers field.
func (r *documentResolver) DefaultApprovers(ctx context.Context, obj *types.Document) ([]*types.Profile, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionDocumentGet); err != nil {
return nil, err
}
prb := r.ProboService(ctx, obj.ID.TenantID())
profiles, err := prb.Documents.GetDefaultApprovers(ctx, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get default approvers", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
result := make([]*types.Profile, len(profiles))
for i, p := range profiles {
result[i] = types.NewProfile(p)
}
return result, nil
}
// Permission is the resolver for the permission field.
func (r *documentResolver) Permission(ctx context.Context, obj *types.Document, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
@@ -5247,6 +5269,7 @@ func (r *mutationResolver) CreateDocument(ctx context.Context, input types.Creat
Classification: input.Classification,
DocumentType: input.DocumentType,
TrustCenterVisibility: input.TrustCenterVisibility,
DefaultApproverIDs: input.DefaultApproverIds,
},
)
if err != nil {
@@ -5275,12 +5298,18 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat
prb := r.ProboService(ctx, input.ID.TenantID())
var defaultApproverIDs *[]gid.GID
if input.DefaultApproverIds != nil {
defaultApproverIDs = &input.DefaultApproverIds
}
document, err := prb.Documents.Update(
ctx,
probo.UpdateDocumentRequest{
DocumentID: input.ID,
Title: input.Title,
TrustCenterVisibility: input.TrustCenterVisibility,
DefaultApproverIDs: defaultApproverIDs,
},
)
@@ -5659,6 +5688,10 @@ func (r *mutationResolver) PublishMajorDocumentVersion(ctx context.Context, inpu
return nil, gqlutils.Invalid(ctx, errNotDraft)
}
if errPending, ok := errors.AsType[*probo.ErrDocumentVersionPendingApproval](err); ok {
return nil, gqlutils.Conflict(ctx, errPending)
}
r.logger.ErrorCtx(ctx, "cannot publish major document version", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -5692,6 +5725,10 @@ func (r *mutationResolver) PublishMinorDocumentVersion(ctx context.Context, inpu
return nil, gqlutils.Invalid(ctx, errNotDraft)
}
if errPending, ok := errors.AsType[*probo.ErrDocumentVersionPendingApproval](err); ok {
return nil, gqlutils.Conflict(ctx, errPending)
}
r.logger.ErrorCtx(ctx, "cannot publish minor document version", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -5719,7 +5756,7 @@ func (r *mutationResolver) BulkPublishMajorDocumentVersions(ctx context.Context,
prb := r.ProboService(ctx, input.DocumentIds[0].TenantID())
versions, documents, err := prb.Documents.BulkPublishMajorVersions(ctx, probo.BulkPublishVersionsRequest{
versions, documents, err := prb.DocumentApprovals.BulkPublishMajorVersions(ctx, probo.BulkPublishVersionsRequest{
DocumentIDs: input.DocumentIds,
Changelog: input.Changelog,
})
@@ -5820,6 +5857,10 @@ func (r *mutationResolver) RequestDocumentVersionApproval(ctx context.Context, i
return nil, gqlutils.Conflict(ctx, errArchived)
}
if errNotDraft, ok := errors.AsType[*probo.ErrDocumentVersionNotDraft](err); ok {
return nil, gqlutils.Conflict(ctx, errNotDraft)
}
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}
@@ -5833,6 +5874,34 @@ func (r *mutationResolver) RequestDocumentVersionApproval(ctx context.Context, i
}, nil
}
// VoidDocumentVersionApproval is the resolver for the voidDocumentVersionApproval field.
func (r *mutationResolver) VoidDocumentVersionApproval(ctx context.Context, input types.VoidDocumentVersionApprovalInput) (*types.VoidDocumentVersionApprovalPayload, error) {
if err := r.authorize(ctx, input.DocumentVersionID, probo.ActionDocumentVersionVoidApproval); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.DocumentVersionID.TenantID())
quorum, documentVersion, err := prb.DocumentApprovals.VoidApproval(ctx, input.DocumentVersionID)
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
if errNotPending, ok := errors.AsType[*probo.ErrDocumentVersionNotPendingApproval](err); ok {
return nil, gqlutils.Conflict(ctx, errNotPending)
}
r.logger.ErrorCtx(ctx, "cannot void document version approval", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.VoidDocumentVersionApprovalPayload{
ApprovalQuorum: types.NewDocumentVersionApprovalQuorum(quorum),
DocumentVersion: types.NewDocumentVersion(documentVersion),
}, nil
}
// BulkDeleteDocuments is the resolver for the bulkDeleteDocuments field.
func (r *mutationResolver) BulkDeleteDocuments(ctx context.Context, input types.BulkDeleteDocumentsInput) (*types.BulkDeleteDocumentsPayload, error) {
if len(input.DocumentIds) == 0 {
@@ -5957,6 +6026,10 @@ func (r *mutationResolver) GenerateDocumentChangelog(ctx context.Context, input
changelog, err := prb.Documents.GenerateChangelog(ctx, input.DocumentID)
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
r.logger.ErrorCtx(ctx, "cannot generate document changelog", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -5976,6 +6049,14 @@ func (r *mutationResolver) CreateDraftDocumentVersion(ctx context.Context, input
documentVersion, err := prb.Documents.CreateDraft(ctx, input.DocumentID)
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
if errNotPublished, ok := errors.AsType[*probo.ErrDocumentVersionNotPublished](err); ok {
return nil, gqlutils.Conflict(ctx, errNotPublished)
}
r.logger.ErrorCtx(ctx, "cannot create draft document version", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -5995,6 +6076,10 @@ func (r *mutationResolver) DeleteDraftDocumentVersion(ctx context.Context, input
err := prb.Documents.DeleteDraft(ctx, input.DocumentVersionID)
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
r.logger.ErrorCtx(ctx, "cannot delete draft document version", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -6061,6 +6146,10 @@ func (r *mutationResolver) RequestSignature(ctx context.Context, input types.Req
},
)
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
r.logger.ErrorCtx(ctx, "cannot request signature", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -6132,6 +6221,10 @@ func (r *mutationResolver) CancelSignatureRequest(ctx context.Context, input typ
err := prb.Documents.CancelSignatureRequest(ctx, input.DocumentVersionSignatureID)
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
r.logger.ErrorCtx(ctx, "cannot cancel signature request", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -6165,53 +6258,6 @@ func (r *mutationResolver) SignDocument(ctx context.Context, input types.SignDoc
}, nil
}
// AddDocumentVersionApprover is the resolver for the addDocumentVersionApprover field.
func (r *mutationResolver) AddDocumentVersionApprover(ctx context.Context, input types.AddDocumentVersionApproverInput) (*types.AddDocumentVersionApproverPayload, error) {
if err := r.authorize(ctx, input.DocumentVersionID, probo.ActionDocumentVersionAddApprover); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.DocumentVersionID.TenantID())
decision, err := prb.DocumentApprovals.AddApprover(ctx, input.DocumentVersionID, input.ApproverID)
if err != nil {
if errNotPending, ok := errors.AsType[*probo.ErrDocumentVersionNotPendingApproval](err); ok {
return nil, gqlutils.Invalid(ctx, errNotPending)
}
r.logger.ErrorCtx(ctx, "cannot add document version approver", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.AddDocumentVersionApproverPayload{
ApprovalDecisionEdge: types.NewDocumentVersionApprovalDecisionEdge(decision, coredata.DocumentVersionApprovalDecisionOrderFieldCreatedAt),
}, nil
}
// RemoveDocumentVersionApprover is the resolver for the removeDocumentVersionApprover field.
func (r *mutationResolver) RemoveDocumentVersionApprover(ctx context.Context, input types.RemoveDocumentVersionApproverInput) (*types.RemoveDocumentVersionApproverPayload, error) {
if err := r.authorize(ctx, input.ApprovalDecisionID, probo.ActionDocumentVersionRemoveApprover); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.ApprovalDecisionID.TenantID())
documentVersionID, err := prb.DocumentApprovals.RemoveApprover(ctx, input.ApprovalDecisionID)
if err != nil {
if errAlreadyMade, ok := errors.AsType[*probo.ErrApprovalDecisionAlreadyMade](err); ok {
return nil, gqlutils.Conflict(ctx, errAlreadyMade)
}
r.logger.ErrorCtx(ctx, "cannot remove document version approver", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.RemoveDocumentVersionApproverPayload{
DeletedApprovalDecisionID: input.ApprovalDecisionID,
DocumentVersion: &types.DocumentVersion{ID: documentVersionID},
}, nil
}
// ApproveDocumentVersion is the resolver for the approveDocumentVersion field.
func (r *mutationResolver) ApproveDocumentVersion(ctx context.Context, input types.ApproveDocumentVersionInput) (*types.ApproveDocumentVersionPayload, error) {
if err := r.authorize(ctx, input.DocumentVersionID, probo.ActionDocumentVersionApprove); err != nil {
@@ -6238,6 +6284,10 @@ func (r *mutationResolver) ApproveDocumentVersion(ctx context.Context, input typ
SignerUA: httpReq.UserAgent(),
})
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
if errNotPending, ok := errors.AsType[*probo.ErrDocumentVersionNotPendingApproval](err); ok {
return nil, gqlutils.Invalid(ctx, errNotPending)
}
@@ -6275,6 +6325,10 @@ func (r *mutationResolver) RejectDocumentVersion(ctx context.Context, input type
Comment: input.Comment,
})
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
if errNotPending, ok := errors.AsType[*probo.ErrDocumentVersionNotPendingApproval](err); ok {
return nil, gqlutils.Invalid(ctx, errNotPending)
}