Add major.minor document versioning

Introduce a two-part version scheme (major.minor) for documents.
Drafts start at 0.1 and increment minor on each new draft. Publishing
as minor keeps the current version, publishing as major bumps to the
next major.0. Both current_published_major and current_published_minor
are tracked on the document for exact version lookups.

Signatures and approval quorums aggregate across all versions sharing
the same major number using CTE joins. Approval page mutations spread
the decision fragment so Relay updates the version row state without
requiring a page refresh.

GraphQL, MCP, and service layer expose separate publishMajor and
publishMinor mutations instead of a single mutation with a type enum.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-03-27 19:57:58 +01:00
parent c5a412ea41
commit 68bc8cf59a
31 changed files with 877 additions and 417 deletions

View File

@@ -2441,7 +2441,8 @@ type Document implements Node {
description: String
documentType: DocumentType!
classification: DocumentClassification!
currentPublishedVersion: Int
currentPublishedMajor: Int
currentPublishedMinor: Int
trustCenterVisibility: TrustCenterVisibility!
organization: Organization! @goField(forceResolver: true)
@@ -2501,7 +2502,8 @@ type EmployeeDocumentVersion
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentVersion"
) {
id: ID!
version: Int!
major: Int!
minor: Int!
status: DocumentVersionStatus!
signed: Boolean! @goField(forceResolver: true)
approvalDecision: DocumentVersionApprovalDecision @goField(forceResolver: true)
@@ -3792,10 +3794,16 @@ type Mutation {
exportStateOfApplicabilityPDF(
input: ExportStateOfApplicabilityPDFInput!
): ExportStateOfApplicabilityPDFPayload!
publishDocumentVersion(
input: PublishDocumentVersionInput!
publishMajorDocumentVersion(
input: PublishMajorDocumentVersionInput!
): PublishDocumentVersionPayload!
bulkPublishDocumentVersions(
publishMinorDocumentVersion(
input: PublishMinorDocumentVersionInput!
): PublishDocumentVersionPayload!
bulkPublishMajorDocumentVersions(
input: BulkPublishDocumentVersionsInput!
): BulkPublishDocumentVersionsPayload!
bulkPublishMinorDocumentVersions(
input: BulkPublishDocumentVersionsInput!
): BulkPublishDocumentVersionsPayload!
requestDocumentVersionApproval(
@@ -5355,7 +5363,8 @@ type DocumentVersion implements Node {
id: ID!
document: Document! @goField(forceResolver: true)
status: DocumentVersionStatus!
version: Int!
major: Int!
minor: Int!
content: String!
changelog: String!
title: String!
@@ -5686,7 +5695,12 @@ type RequestDocumentVersionApprovalPayload {
approvalQuorum: DocumentVersionApprovalQuorum!
}
input PublishDocumentVersionInput {
input PublishMajorDocumentVersionInput {
documentId: ID!
changelog: String
}
input PublishMinorDocumentVersionInput {
documentId: ID!
changelog: String
}

View File

@@ -80,13 +80,14 @@ func NewDocument(document *coredata.Document) *Document {
Organization: &Organization{
ID: document.OrganizationID,
},
DocumentType: document.DocumentType,
Classification: document.Classification,
CurrentPublishedVersion: document.CurrentPublishedVersion,
TrustCenterVisibility: document.TrustCenterVisibility,
Status: document.Status,
ArchivedAt: document.ArchivedAt,
CreatedAt: document.CreatedAt,
UpdatedAt: document.UpdatedAt,
DocumentType: document.DocumentType,
Classification: document.Classification,
CurrentPublishedMajor: document.CurrentPublishedMajor,
CurrentPublishedMinor: document.CurrentPublishedMinor,
TrustCenterVisibility: document.TrustCenterVisibility,
Status: document.Status,
ArchivedAt: document.ArchivedAt,
CreatedAt: document.CreatedAt,
UpdatedAt: document.UpdatedAt,
}
}

View File

@@ -76,7 +76,8 @@ func NewDocumentVersion(documentVersion *coredata.DocumentVersion) *DocumentVers
Document: &Document{
ID: documentVersion.DocumentID,
},
Version: documentVersion.VersionNumber,
Major: documentVersion.Major,
Minor: documentVersion.Minor,
Title: documentVersion.Title,
Content: documentVersion.Content,
Status: documentVersion.Status,

View File

@@ -66,7 +66,8 @@ type (
EmployeeDocumentVersion struct {
ID gid.GID
OrganizationID gid.GID
Version int
Major int
Minor int
Status coredata.DocumentVersionStatus
PublishedAt *time.Time
CreatedAt time.Time

View File

@@ -1647,7 +1647,8 @@ func (r *employeeDocumentResolver) Versions(ctx context.Context, obj *types.Empl
employeeVersions[i] = &types.EmployeeDocumentVersion{
ID: v.ID,
OrganizationID: v.OrganizationID,
Version: v.VersionNumber,
Major: v.Major,
Minor: v.Minor,
Status: v.Status,
PublishedAt: v.PublishedAt,
CreatedAt: v.CreatedAt,
@@ -5012,16 +5013,20 @@ func (r *mutationResolver) ExportStateOfApplicabilityPDF(ctx context.Context, in
}, nil
}
// PublishDocumentVersion is the resolver for the publishDocumentVersion field.
func (r *mutationResolver) PublishDocumentVersion(ctx context.Context, input types.PublishDocumentVersionInput) (*types.PublishDocumentVersionPayload, error) {
// PublishMajorDocumentVersion is the resolver for the publishMajorDocumentVersion field.
func (r *mutationResolver) PublishMajorDocumentVersion(ctx context.Context, input types.PublishMajorDocumentVersionInput) (*types.PublishDocumentVersionPayload, error) {
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentVersionPublish); err != nil {
return nil, err
}
identity := authn.IdentityFromContext(ctx)
prb := r.ProboService(ctx, input.DocumentID.TenantID())
document, documentVersion, err := prb.Documents.PublishVersion(ctx, input.DocumentID, identity.ID, input.Changelog)
document, documentVersion, err := prb.Documents.PublishMajorVersion(
ctx,
input.DocumentID,
authn.IdentityFromContext(ctx).ID,
input.Changelog,
)
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
@@ -5031,7 +5036,11 @@ func (r *mutationResolver) PublishDocumentVersion(ctx context.Context, input typ
return nil, gqlutils.Invalid(ctx, errNotDraft)
}
r.logger.ErrorCtx(ctx, "cannot publish document version", log.Error(err))
if errNoChanges, ok := errors.AsType[*probo.ErrDocumentVersionNoChanges](err); ok {
return nil, gqlutils.Invalid(ctx, errNoChanges)
}
r.logger.ErrorCtx(ctx, "cannot publish major document version", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -5041,8 +5050,41 @@ func (r *mutationResolver) PublishDocumentVersion(ctx context.Context, input typ
}, nil
}
// BulkPublishDocumentVersions is the resolver for the bulkPublishDocumentVersions field.
func (r *mutationResolver) BulkPublishDocumentVersions(ctx context.Context, input types.BulkPublishDocumentVersionsInput) (*types.BulkPublishDocumentVersionsPayload, error) {
// PublishMinorDocumentVersion is the resolver for the publishMinorDocumentVersion field.
func (r *mutationResolver) PublishMinorDocumentVersion(ctx context.Context, input types.PublishMinorDocumentVersionInput) (*types.PublishDocumentVersionPayload, error) {
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentVersionPublish); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.DocumentID.TenantID())
document, documentVersion, err := prb.Documents.PublishMinorVersion(
ctx,
input.DocumentID,
authn.IdentityFromContext(ctx).ID,
input.Changelog,
)
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
if errNotDraft, ok := errors.AsType[*probo.ErrDocumentVersionNotDraft](err); ok {
return nil, gqlutils.Invalid(ctx, errNotDraft)
}
r.logger.ErrorCtx(ctx, "cannot publish minor document version", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.PublishDocumentVersionPayload{
Document: types.NewDocument(document),
DocumentVersion: types.NewDocumentVersion(documentVersion),
}, nil
}
// BulkPublishMajorDocumentVersions is the resolver for the bulkPublishMajorDocumentVersions field.
func (r *mutationResolver) BulkPublishMajorDocumentVersions(ctx context.Context, input types.BulkPublishDocumentVersionsInput) (*types.BulkPublishDocumentVersionsPayload, error) {
if len(input.DocumentIds) == 0 {
return &types.BulkPublishDocumentVersionsPayload{
DocumentVersions: []*types.DocumentVersion{},
@@ -5056,12 +5098,10 @@ func (r *mutationResolver) BulkPublishDocumentVersions(ctx context.Context, inpu
}
}
identity := authn.IdentityFromContext(ctx)
prb := r.ProboService(ctx, input.DocumentIds[0].TenantID())
versions, documents, err := prb.Documents.BulkPublishVersions(ctx, probo.BulkPublishVersionsRequest{
versions, documents, err := prb.Documents.BulkPublishMajorVersions(ctx, probo.BulkPublishVersionsRequest{
DocumentIDs: input.DocumentIds,
PublishedBy: identity.ID,
Changelog: input.Changelog,
})
if err != nil {
@@ -5073,7 +5113,57 @@ func (r *mutationResolver) BulkPublishDocumentVersions(ctx context.Context, inpu
return nil, gqlutils.Invalid(ctx, errNotDraft)
}
r.logger.ErrorCtx(ctx, "cannot bulk publish document versions", log.Error(err))
r.logger.ErrorCtx(ctx, "cannot bulk publish major document versions", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
typesVersions := make([]*types.DocumentVersion, len(versions))
for i, v := range versions {
typesVersions[i] = types.NewDocumentVersion(v)
}
typesDocuments := make([]*types.Document, len(documents))
for i, d := range documents {
typesDocuments[i] = types.NewDocument(d)
}
return &types.BulkPublishDocumentVersionsPayload{
DocumentVersions: typesVersions,
Documents: typesDocuments,
}, nil
}
// BulkPublishMinorDocumentVersions is the resolver for the bulkPublishMinorDocumentVersions field.
func (r *mutationResolver) BulkPublishMinorDocumentVersions(ctx context.Context, input types.BulkPublishDocumentVersionsInput) (*types.BulkPublishDocumentVersionsPayload, error) {
if len(input.DocumentIds) == 0 {
return &types.BulkPublishDocumentVersionsPayload{
DocumentVersions: []*types.DocumentVersion{},
Documents: []*types.Document{},
}, nil
}
for _, documentID := range input.DocumentIds {
if err := r.authorize(ctx, documentID, probo.ActionDocumentVersionPublish); err != nil {
return nil, err
}
}
prb := r.ProboService(ctx, input.DocumentIds[0].TenantID())
versions, documents, err := prb.Documents.BulkPublishMinorVersions(ctx, probo.BulkPublishVersionsRequest{
DocumentIDs: input.DocumentIds,
Changelog: input.Changelog,
})
if err != nil {
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
return nil, gqlutils.Conflict(ctx, errArchived)
}
if errNotDraft, ok := errors.AsType[*probo.ErrDocumentVersionNotDraft](err); ok {
return nil, gqlutils.Invalid(ctx, errNotDraft)
}
r.logger.ErrorCtx(ctx, "cannot bulk publish minor document versions", log.Error(err))
return nil, gqlutils.Internal(ctx)
}