Add document approval workflow

Introduce a complete approval system for document publishing. Document
versions can now require approval from selected reviewers before being
published, with automatic publishing once all approvers have approved.

- Add approval quorum and decision tables with backfill migration
- Implement request approval, approve, and reject flows with electronic
  signature support for approve decisions
- Add employee approvals page with dedicated tab and pending approvals view
- Add changelog field to publish and request approval flows
- Pre-select previous version's approvers in the publish dialog
- Show quorum approvers in document list with 100 approver hard limit
- Expose approval workflow through GraphQL, MCP, and CLI
- Remove legacy default approvers feature entirely
- Add comprehensive e2e test coverage for approval workflows

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-03-27 17:22:54 +01:00
parent 4a2d308da0
commit 999171a626
78 changed files with 6483 additions and 1600 deletions

View File

@@ -1623,7 +1623,7 @@ input ApplicabilityStatementOrder
}
input DocumentVersionFilter {
status: DocumentVersionStatus
statuses: [DocumentVersionStatus!]
}
# Input Types for Filtering
@@ -2441,13 +2441,6 @@ type Document implements Node {
classification: DocumentClassification!
currentPublishedVersion: Int
trustCenterVisibility: TrustCenterVisibility!
approvers(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: ProfileOrder
): ProfileConnection! @goField(forceResolver: true)
organization: Organization! @goField(forceResolver: true)
versions(
@@ -2477,16 +2470,17 @@ type Document implements Node {
permission(action: String!): Boolean! @goField(forceResolver: true)
}
type SignableDocument
type EmployeeDocument
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SignableDocument"
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocument"
) {
id: ID!
title: String!
description: String
documentType: DocumentType!
classification: DocumentClassification!
signed: Boolean! @goField(forceResolver: true)
signed: Boolean @goField(forceResolver: true)
approvalState: DocumentVersionApprovalDecisionState @goField(forceResolver: true)
versions(
first: Int
@@ -2494,13 +2488,26 @@ type SignableDocument
last: Int
before: CursorKey
orderBy: DocumentVersionOrder
filter: DocumentVersionFilter
): DocumentVersionConnection! @goField(forceResolver: true)
): EmployeeDocumentVersionConnection! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
type EmployeeDocumentVersion
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentVersion"
) {
id: ID!
version: Int!
status: DocumentVersionStatus!
signed: Boolean! @goField(forceResolver: true)
approvalDecision: DocumentVersionApprovalDecision @goField(forceResolver: true)
publishedAt: Datetime
createdAt: Datetime!
updatedAt: Datetime!
}
type Meeting implements Node {
id: ID!
name: String!
@@ -2952,9 +2959,20 @@ type Viewer {
last: Int
before: CursorKey
orderBy: DocumentOrder
): SignableDocumentConnection! @goField(forceResolver: true)
): EmployeeDocumentConnection! @goField(forceResolver: true)
signableDocument(id: ID!): SignableDocument @goField(forceResolver: true)
signableDocument(id: ID!): EmployeeDocument @goField(forceResolver: true)
approvableDocuments(
organizationId: ID!
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: DocumentOrder
): EmployeeDocumentConnection! @goField(forceResolver: true)
approvableDocument(id: ID!): EmployeeDocument @goField(forceResolver: true)
}
type TrustCenterConnection {
@@ -3226,20 +3244,36 @@ type EvidenceEdge {
node: Evidence!
}
type SignableDocumentConnection
type EmployeeDocumentConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SignableDocumentConnection"
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentConnection"
) {
edges: [SignableDocumentEdge!]!
edges: [EmployeeDocumentEdge!]!
pageInfo: PageInfo!
}
type SignableDocumentEdge
type EmployeeDocumentEdge
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SignableDocumentEdge"
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentEdge"
) {
cursor: CursorKey!
node: SignableDocument!
node: EmployeeDocument!
}
type EmployeeDocumentVersionConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentVersionConnection"
) {
edges: [EmployeeDocumentVersionEdge!]!
pageInfo: PageInfo!
}
type EmployeeDocumentVersionEdge
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentVersionEdge"
) {
cursor: CursorKey!
node: EmployeeDocumentVersion!
}
type DocumentConnection
@@ -3762,6 +3796,9 @@ type Mutation {
bulkPublishDocumentVersions(
input: BulkPublishDocumentVersionsInput!
): BulkPublishDocumentVersionsPayload!
requestDocumentVersionApproval(
input: RequestDocumentVersionApprovalInput!
): RequestDocumentVersionApprovalPayload!
bulkDeleteDocuments(
input: BulkDeleteDocumentsInput!
): BulkDeleteDocumentsPayload!
@@ -3797,6 +3834,18 @@ type Mutation {
input: CancelSignatureRequestInput!
): CancelSignatureRequestPayload!
signDocument(input: SignDocumentInput!): SignDocumentPayload!
addDocumentVersionApprover(
input: AddDocumentVersionApproverInput!
): AddDocumentVersionApproverPayload!
removeDocumentVersionApprover(
input: RemoveDocumentVersionApproverInput!
): RemoveDocumentVersionApproverPayload!
approveDocumentVersion(
input: ApproveDocumentVersionInput!
): ApproveDocumentVersionPayload!
rejectDocumentVersion(
input: RejectDocumentVersionInput!
): RejectDocumentVersionPayload!
exportDocumentVersionPDF(
input: ExportDocumentVersionPDFInput!
@@ -4409,7 +4458,6 @@ input CreateDocumentInput {
organizationId: ID!
title: String!
content: String!
approverIds: [ID!]!
documentType: DocumentType!
classification: DocumentClassification!
trustCenterVisibility: TrustCenterVisibility
@@ -4419,7 +4467,6 @@ input UpdateDocumentInput {
id: ID!
title: String
content: String
approverIds: [ID!]
documentType: DocumentType
classification: DocumentClassification
trustCenterVisibility: TrustCenterVisibility
@@ -5324,6 +5371,14 @@ type DocumentVersion implements Node {
filter: DocumentVersionSignatureFilter
): DocumentVersionSignatureConnection! @goField(forceResolver: true)
approvalQuorums(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: DocumentVersionApprovalQuorumOrder
): DocumentVersionApprovalQuorumConnection! @goField(forceResolver: true)
signed: Boolean! @goField(forceResolver: true)
publishedAt: Datetime
@@ -5398,6 +5453,170 @@ type DocumentVersionSignature implements Node {
permission(action: String!): Boolean! @goField(forceResolver: true)
}
enum DocumentVersionApprovalDecisionState
@goModel(
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionState"
) {
PENDING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStatePending"
)
APPROVED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateApproved"
)
REJECTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateRejected"
)
}
enum DocumentVersionApprovalDecisionOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionOrderField"
) {
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionOrderFieldCreatedAt"
)
}
enum DocumentVersionApprovalQuorumStatus
@goModel(
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatus"
) {
PENDING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusPending"
)
APPROVED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusApproved"
)
REJECTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusRejected"
)
}
enum DocumentVersionApprovalQuorumOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumOrderField"
) {
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumOrderFieldCreatedAt"
)
}
input DocumentVersionApprovalQuorumOrder {
field: DocumentVersionApprovalQuorumOrderField!
direction: OrderDirection!
}
type DocumentVersionApprovalQuorumConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DocumentVersionApprovalQuorumConnection"
) {
edges: [DocumentVersionApprovalQuorumEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type DocumentVersionApprovalQuorumEdge {
cursor: CursorKey!
node: DocumentVersionApprovalQuorum!
}
type DocumentVersionApprovalQuorum implements Node {
id: ID!
documentVersion: DocumentVersion! @goField(forceResolver: true)
status: DocumentVersionApprovalQuorumStatus!
decisions(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: DocumentVersionApprovalDecisionOrder
filter: DocumentVersionApprovalDecisionFilter
): DocumentVersionApprovalDecisionConnection! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
permission(action: String!): Boolean! @goField(forceResolver: true)
}
input DocumentVersionApprovalDecisionFilter {
states: [DocumentVersionApprovalDecisionState!]
}
input DocumentVersionApprovalDecisionOrder {
field: DocumentVersionApprovalDecisionOrderField!
direction: OrderDirection!
}
type DocumentVersionApprovalDecisionConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DocumentVersionApprovalDecisionConnection"
) {
edges: [DocumentVersionApprovalDecisionEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type DocumentVersionApprovalDecisionEdge {
cursor: CursorKey!
node: DocumentVersionApprovalDecision!
}
type DocumentVersionApprovalDecision implements Node {
id: ID!
quorum: DocumentVersionApprovalQuorum! @goField(forceResolver: true)
documentVersion: DocumentVersion! @goField(forceResolver: true)
approver: Profile! @goField(forceResolver: true)
state: DocumentVersionApprovalDecisionState!
comment: String
decidedAt: Datetime
createdAt: Datetime!
updatedAt: Datetime!
permission(action: String!): Boolean! @goField(forceResolver: true)
}
input ApproveDocumentVersionInput {
documentVersionId: ID!
comment: String
}
type ApproveDocumentVersionPayload {
approvalDecision: DocumentVersionApprovalDecision!
}
input RejectDocumentVersionInput {
documentVersionId: ID!
comment: String
}
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!
signatoryId: ID!
@@ -5416,16 +5635,6 @@ type BulkRequestSignaturesPayload {
documentVersionSignatureEdges: [DocumentVersionSignatureEdge!]!
}
input BulkPublishDocumentVersionsInput {
documentIds: [ID!]!
changelog: String!
}
type BulkPublishDocumentVersionsPayload {
documentVersionEdges: [DocumentVersionEdge!]!
documentEdges: [DocumentEdge!]!
}
input BulkDeleteDocumentsInput {
documentIds: [ID!]!
}
@@ -5461,14 +5670,34 @@ type BulkExportDocumentsPayload {
exportJobId: ID!
}
input RequestDocumentVersionApprovalInput {
documentId: ID!
approverIds: [ID!]!
changelog: String
}
type RequestDocumentVersionApprovalPayload {
approvalQuorum: DocumentVersionApprovalQuorum!
}
input PublishDocumentVersionInput {
documentId: ID!
changelog: String
}
type PublishDocumentVersionPayload {
documentVersion: DocumentVersion!
document: Document!
documentVersion: DocumentVersion!
}
input BulkPublishDocumentVersionsInput {
documentIds: [ID!]!
changelog: String!
}
type BulkPublishDocumentVersionsPayload {
documentVersions: [DocumentVersion!]!
documents: [Document!]!
}
type CreateDraftDocumentVersionPayload {

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
DocumentVersionApprovalDecisionOrderBy OrderBy[coredata.DocumentVersionApprovalDecisionOrderField]
DocumentVersionApprovalDecisionConnection struct {
TotalCount int
Edges []*DocumentVersionApprovalDecisionEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
Filters *coredata.DocumentVersionApprovalDecisionFilter
}
)
func NewDocumentVersionApprovalDecisionConnection(
page *page.Page[*coredata.DocumentVersionApprovalDecision, coredata.DocumentVersionApprovalDecisionOrderField],
parentType any,
parentID gid.GID,
filter *coredata.DocumentVersionApprovalDecisionFilter,
) *DocumentVersionApprovalDecisionConnection {
edges := make([]*DocumentVersionApprovalDecisionEdge, len(page.Data))
for i, decision := range page.Data {
edges[i] = NewDocumentVersionApprovalDecisionEdge(decision, page.Cursor.OrderBy.Field)
}
return &DocumentVersionApprovalDecisionConnection{
Edges: edges,
PageInfo: *NewPageInfo(page),
Resolver: parentType,
ParentID: parentID,
Filters: filter,
}
}
func NewDocumentVersionApprovalDecisionEdge(decision *coredata.DocumentVersionApprovalDecision, orderBy coredata.DocumentVersionApprovalDecisionOrderField) *DocumentVersionApprovalDecisionEdge {
return &DocumentVersionApprovalDecisionEdge{
Cursor: decision.CursorKey(orderBy),
Node: NewDocumentVersionApprovalDecision(decision),
}
}
func NewDocumentVersionApprovalDecision(decision *coredata.DocumentVersionApprovalDecision) *DocumentVersionApprovalDecision {
return &DocumentVersionApprovalDecision{
Quorum: &DocumentVersionApprovalQuorum{
ID: decision.QuorumID,
},
Approver: &Profile{
ID: decision.ApproverID,
},
ID: decision.ID,
State: decision.State,
Comment: decision.Comment,
DecidedAt: decision.DecidedAt,
CreatedAt: decision.CreatedAt,
UpdatedAt: decision.UpdatedAt,
}
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
DocumentVersionApprovalQuorumConnection struct {
TotalCount int
Edges []*DocumentVersionApprovalQuorumEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
)
func NewDocumentVersionApprovalQuorumConnection(
page *page.Page[*coredata.DocumentVersionApprovalQuorum, coredata.DocumentVersionApprovalQuorumOrderField],
parentType any,
parentID gid.GID,
) *DocumentVersionApprovalQuorumConnection {
edges := make([]*DocumentVersionApprovalQuorumEdge, len(page.Data))
for i, quorum := range page.Data {
edges[i] = NewDocumentVersionApprovalQuorumEdge(quorum, page.Cursor.OrderBy.Field)
}
return &DocumentVersionApprovalQuorumConnection{
Edges: edges,
PageInfo: *NewPageInfo(page),
Resolver: parentType,
ParentID: parentID,
}
}
func NewDocumentVersionApprovalQuorumEdge(
quorum *coredata.DocumentVersionApprovalQuorum,
orderBy coredata.DocumentVersionApprovalQuorumOrderField,
) *DocumentVersionApprovalQuorumEdge {
return &DocumentVersionApprovalQuorumEdge{
Cursor: quorum.CursorKey(orderBy),
Node: NewDocumentVersionApprovalQuorum(quorum),
}
}
func NewDocumentVersionApprovalQuorum(quorum *coredata.DocumentVersionApprovalQuorum) *DocumentVersionApprovalQuorum {
return &DocumentVersionApprovalQuorum{
ID: quorum.ID,
DocumentVersion: &DocumentVersion{
ID: quorum.VersionID,
},
Status: quorum.Status,
CreatedAt: quorum.CreatedAt,
UpdatedAt: quorum.UpdatedAt,
}
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"fmt"
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type EmployeeDocumentFilterMode int
const (
EmployeeDocumentFilterModeSignature EmployeeDocumentFilterMode = iota
EmployeeDocumentFilterModeApproval
)
type (
EmployeeDocumentConnection struct {
Edges []*EmployeeDocumentEdge
PageInfo *PageInfo
}
EmployeeDocumentEdge struct {
Cursor page.CursorKey
Node *EmployeeDocument
}
EmployeeDocument struct {
ID gid.GID
Title string
Description *string
DocumentType coredata.DocumentType
Classification coredata.DocumentClassification
CreatedAt time.Time
UpdatedAt time.Time
FilterMode EmployeeDocumentFilterMode
}
EmployeeDocumentVersionConnection struct {
Edges []*EmployeeDocumentVersionEdge
PageInfo *PageInfo
}
EmployeeDocumentVersionEdge struct {
Cursor page.CursorKey
Node *EmployeeDocumentVersion
}
EmployeeDocumentVersion struct {
ID gid.GID
OrganizationID gid.GID
Version int
Status coredata.DocumentVersionStatus
PublishedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
)
func NewEmployeeDocumentConnection(
p *page.Page[*EmployeeDocument, coredata.DocumentOrderField],
) *EmployeeDocumentConnection {
var edges = make([]*EmployeeDocumentEdge, len(p.Data))
for i := range edges {
edges[i] = NewEmployeeDocumentEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &EmployeeDocumentConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}
func NewEmployeeDocumentEdge(document *EmployeeDocument, orderBy coredata.DocumentOrderField) *EmployeeDocumentEdge {
return &EmployeeDocumentEdge{
Cursor: document.CursorKey(orderBy),
Node: document,
}
}
func (d EmployeeDocument) CursorKey(orderBy coredata.DocumentOrderField) page.CursorKey {
switch orderBy {
case coredata.DocumentOrderFieldCreatedAt:
return page.NewCursorKey(d.ID, d.CreatedAt)
case coredata.DocumentOrderFieldTitle:
return page.NewCursorKey(d.ID, d.Title)
case coredata.DocumentOrderFieldDocumentType:
return page.NewCursorKey(d.ID, d.DocumentType)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func NewEmployeeDocumentVersionConnection(
p *page.Page[*EmployeeDocumentVersion, coredata.DocumentVersionOrderField],
) *EmployeeDocumentVersionConnection {
var edges = make([]*EmployeeDocumentVersionEdge, len(p.Data))
for i := range edges {
edges[i] = NewEmployeeDocumentVersionEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &EmployeeDocumentVersionConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}
func NewEmployeeDocumentVersionEdge(version *EmployeeDocumentVersion, orderBy coredata.DocumentVersionOrderField) *EmployeeDocumentVersionEdge {
return &EmployeeDocumentVersionEdge{
Cursor: version.CursorKey(orderBy),
Node: version,
}
}
func (v EmployeeDocumentVersion) CursorKey(orderBy coredata.DocumentVersionOrderField) page.CursorKey {
switch orderBy {
case coredata.DocumentVersionOrderFieldCreatedAt:
return page.NewCursorKey(v.ID, v.CreatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}

View File

@@ -1,83 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
SignableDocumentConnection struct {
Edges []*SignableDocumentEdge
PageInfo *PageInfo
}
SignableDocumentEdge struct {
Cursor page.CursorKey
Node *SignableDocument
}
SignableDocument struct {
ID gid.GID
Title string
Description *string
DocumentType coredata.DocumentType
Classification coredata.DocumentClassification
CreatedAt time.Time
UpdatedAt time.Time
}
)
func (SignableDocument) IsNode() {}
func (d SignableDocument) GetID() gid.GID { return d.ID }
func NewSignableDocumentConnection(
p *page.Page[*SignableDocument, coredata.DocumentOrderField],
) *SignableDocumentConnection {
var edges = make([]*SignableDocumentEdge, len(p.Data))
for i := range edges {
edges[i] = NewSignableDocumentEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &SignableDocumentConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}
func NewSignableDocumentEdge(document *SignableDocument, orderBy coredata.DocumentOrderField) *SignableDocumentEdge {
return &SignableDocumentEdge{
Cursor: document.CursorKey(orderBy),
Node: document,
}
}
func (d SignableDocument) CursorKey(orderBy coredata.DocumentOrderField) page.CursorKey {
switch orderBy {
case coredata.DocumentOrderFieldCreatedAt:
return page.NewCursorKey(d.ID, d.CreatedAt)
case coredata.DocumentOrderFieldTitle:
return page.NewCursorKey(d.ID, d.Title)
case coredata.DocumentOrderFieldDocumentType:
return page.NewCursorKey(d.ID, d.DocumentType)
}
panic("unsupported order by")
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package mcp_v1
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
func allApproversCursor() *page.Cursor[coredata.MembershipProfileOrderField] {
return page.NewCursor(
100,
nil,
page.Head,
page.OrderBy[coredata.MembershipProfileOrderField]{
Field: coredata.MembershipProfileOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
},
)
}
func profileIDs(p *page.Page[*coredata.MembershipProfile, coredata.MembershipProfileOrderField]) []gid.GID {
ids := make([]gid.GID, len(p.Data))
for i, profile := range p.Data {
ids[i] = profile.ID
}
return ids
}

View File

@@ -1685,16 +1685,7 @@ func (r *Resolver) ListControlDocumentsTool(ctx context.Context, req *mcp.CallTo
return nil, types.ListControlDocumentsOutput{}, fmt.Errorf("failed to list control documents: %w", err)
}
approverIDsMap := make(map[gid.GID][]gid.GID)
for _, d := range docPage.Data {
approverPage, err := prb.Documents.ListApprovers(ctx, d.ID, allApproversCursor())
if err != nil {
return nil, types.ListControlDocumentsOutput{}, fmt.Errorf("failed to list document approvers: %w", err)
}
approverIDsMap[d.ID] = profileIDs(approverPage)
}
return nil, types.NewListControlDocumentsOutput(docPage, approverIDsMap), nil
return nil, types.NewListControlDocumentsOutput(docPage), nil
}
func (r *Resolver) ListControlAuditsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListControlAuditsInput) (*mcp.CallToolResult, types.ListControlAuditsOutput, error) {
@@ -2059,16 +2050,7 @@ func (r *Resolver) ListDocumentsTool(ctx context.Context, req *mcp.CallToolReque
panic(fmt.Errorf("cannot list organization documents: %w", err))
}
approverIDsMap := make(map[gid.GID][]gid.GID)
for _, d := range docPage.Data {
approverPage, err := prb.Documents.ListApprovers(ctx, d.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document approvers: %w", err))
}
approverIDsMap[d.ID] = profileIDs(approverPage)
}
return nil, types.NewListDocumentsOutput(docPage, approverIDsMap), nil
return nil, types.NewListDocumentsOutput(docPage), nil
}
func (r *Resolver) GetDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDocumentInput) (*mcp.CallToolResult, types.GetDocumentOutput, error) {
@@ -2081,13 +2063,8 @@ func (r *Resolver) GetDocumentTool(ctx context.Context, req *mcp.CallToolRequest
panic(fmt.Errorf("cannot get document: %w", err))
}
approverPage, err := prb.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document approvers: %w", err))
}
return nil, types.GetDocumentOutput{
Document: types.NewDocument(document, profileIDs(approverPage)),
Document: types.NewDocument(document),
}, nil
}
@@ -2107,7 +2084,6 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest
OrganizationID: input.OrganizationID,
Title: input.Title,
Content: input.Content,
ApproverIDs: input.ApproverIds,
Classification: input.Classification,
DocumentType: input.DocumentType,
TrustCenterVisibility: trustCenterVisibility,
@@ -2117,7 +2093,7 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest
panic(fmt.Errorf("cannot create document: %w", err))
}
return nil, types.NewAddDocumentOutput(document, documentVersion, input.ApproverIds, input.ApproverIds), nil
return nil, types.NewAddDocumentOutput(document, documentVersion), nil
}
func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateDocumentInput) (*mcp.CallToolResult, types.UpdateDocumentOutput, error) {
@@ -2130,7 +2106,6 @@ func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequ
probo.UpdateDocumentRequest{
DocumentID: input.ID,
Title: input.Title,
ApproverIDs: input.ApproverIds,
Classification: input.Classification,
DocumentType: input.DocumentType,
TrustCenterVisibility: input.TrustCenterVisibility,
@@ -2140,13 +2115,8 @@ func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequ
panic(fmt.Errorf("cannot update document: %w", err))
}
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document approvers: %w", err))
}
return nil, types.UpdateDocumentOutput{
Document: types.NewDocument(document, profileIDs(approverPage)),
Document: types.NewDocument(document),
}, nil
}
@@ -2172,16 +2142,7 @@ func (r *Resolver) ListDocumentVersionsTool(ctx context.Context, req *mcp.CallTo
panic(fmt.Errorf("cannot list document versions: %w", err))
}
approverIDsMap := make(map[gid.GID][]gid.GID)
for _, v := range versionPage.Data {
approverPage, err := svc.Documents.ListVersionApprovers(ctx, v.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document version approvers: %w", err))
}
approverIDsMap[v.ID] = profileIDs(approverPage)
}
return nil, types.NewListDocumentVersionsOutput(versionPage, approverIDsMap), nil
return nil, types.NewListDocumentVersionsOutput(versionPage), nil
}
func (r *Resolver) GetDocumentVersionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDocumentVersionInput) (*mcp.CallToolResult, types.GetDocumentVersionOutput, error) {
@@ -2194,13 +2155,8 @@ func (r *Resolver) GetDocumentVersionTool(ctx context.Context, req *mcp.CallTool
panic(fmt.Errorf("cannot get document version: %w", err))
}
approverPage, err := svc.Documents.ListVersionApprovers(ctx, input.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document version approvers: %w", err))
}
return nil, types.GetDocumentVersionOutput{
DocumentVersion: types.NewDocumentVersion(version, profileIDs(approverPage)),
DocumentVersion: types.NewDocumentVersion(version),
}, nil
}
@@ -2214,13 +2170,8 @@ func (r *Resolver) CreateDraftDocumentVersionTool(ctx context.Context, req *mcp.
panic(fmt.Errorf("cannot create draft document version: %w", err))
}
approverPage, err := svc.Documents.ListVersionApprovers(ctx, draftVersion.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document version approvers: %w", err))
}
return nil, types.CreateDraftDocumentVersionOutput{
DocumentVersion: types.NewDocumentVersion(draftVersion, profileIDs(approverPage)),
DocumentVersion: types.NewDocumentVersion(draftVersion),
}, nil
}
@@ -2240,13 +2191,8 @@ func (r *Resolver) UpdateDocumentVersionTool(ctx context.Context, req *mcp.CallT
panic(fmt.Errorf("cannot update document version: %w", err))
}
versionApproverPage, err := svc.Documents.ListVersionApprovers(ctx, documentVersion.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document version approvers: %w", err))
}
return nil, types.UpdateDocumentVersionOutput{
DocumentVersion: types.NewDocumentVersion(documentVersion, profileIDs(versionApproverPage)),
DocumentVersion: types.NewDocumentVersion(documentVersion),
}, nil
}
@@ -2262,19 +2208,9 @@ func (r *Resolver) PublishDocumentVersionTool(ctx context.Context, req *mcp.Call
panic(fmt.Errorf("cannot publish document version: %w", err))
}
docApproverPage, err := svc.Documents.ListApprovers(ctx, document.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document approvers: %w", err))
}
versionApproverPage, err := svc.Documents.ListVersionApprovers(ctx, documentVersion.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document version approvers: %w", err))
}
return nil, types.PublishDocumentVersionOutput{
Document: types.NewDocument(document, profileIDs(docApproverPage)),
DocumentVersion: types.NewDocumentVersion(documentVersion, profileIDs(versionApproverPage)),
Document: types.NewDocument(document),
DocumentVersion: types.NewDocumentVersion(documentVersion),
}, nil
}
@@ -3243,13 +3179,8 @@ func (r *Resolver) ArchiveDocumentTool(ctx context.Context, req *mcp.CallToolReq
return nil, types.ArchiveDocumentOutput{}, fmt.Errorf("cannot archive document: %w", err)
}
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
if err != nil {
return nil, types.ArchiveDocumentOutput{}, fmt.Errorf("cannot list document approvers: %w", err)
}
return nil, types.ArchiveDocumentOutput{
Document: types.NewDocument(document, profileIDs(approverPage)),
Document: types.NewDocument(document),
}, nil
}
@@ -3263,13 +3194,8 @@ func (r *Resolver) UnarchiveDocumentTool(ctx context.Context, req *mcp.CallToolR
return nil, types.UnarchiveDocumentOutput{}, fmt.Errorf("cannot unarchive document: %w", err)
}
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
if err != nil {
return nil, types.UnarchiveDocumentOutput{}, fmt.Errorf("cannot list document approvers: %w", err)
}
return nil, types.UnarchiveDocumentOutput{
Document: types.NewDocument(document, profileIDs(approverPage)),
Document: types.NewDocument(document),
}, nil
}
@@ -3374,3 +3300,27 @@ func (r *Resolver) GetAuditLogEntryTool(ctx context.Context, req *mcp.CallToolRe
AuditLogEntry: types.NewAuditLogEntry(entry),
}, nil
}
func (r *Resolver) RequestDocumentVersionApprovalTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RequestDocumentVersionApprovalInput) (*mcp.CallToolResult, types.RequestDocumentVersionApprovalOutput, error) {
r.MustAuthorize(ctx, input.DocumentID, probo.ActionDocumentVersionRequestApproval)
svc := r.ProboService(ctx, input.DocumentID)
quorum, err := svc.DocumentApprovals.RequestApproval(ctx, probo.RequestApprovalRequest{
DocumentID: input.DocumentID,
ApproverIDs: input.ApproverIds,
Changelog: input.Changelog,
})
if err != nil {
panic(fmt.Errorf("cannot request document version approval: %w", err))
}
documentVersion, err := svc.Documents.GetVersion(ctx, quorum.VersionID)
if err != nil {
panic(fmt.Errorf("cannot get document version: %w", err))
}
return nil, types.RequestDocumentVersionApprovalOutput{
DocumentVersion: types.NewDocumentVersion(documentVersion),
}, nil
}

View File

@@ -5184,7 +5184,6 @@ components:
required:
- id
- organization_id
- approver_ids
- title
- document_type
- classification
@@ -5199,11 +5198,6 @@ components:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Approver IDs
title:
type: string
description: Document title
@@ -5246,7 +5240,6 @@ components:
- organization_id
- document_id
- title
- approver_ids
- version_number
- classification
- content
@@ -5267,11 +5260,6 @@ components:
title:
type: string
description: Document version title
approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Approver IDs
version_number:
type: integer
description: Version number
@@ -5418,7 +5406,6 @@ components:
- organization_id
- title
- content
- approver_ids
- classification
- document_type
properties:
@@ -5431,11 +5418,6 @@ components:
content:
type: string
description: Document content
approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Approver IDs
classification:
$ref: "#/components/schemas/DocumentClassification"
description: Document classification
@@ -5468,11 +5450,6 @@ components:
title:
type: string
description: Document title
approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Approver IDs
classification:
$ref: "#/components/schemas/DocumentClassification"
description: Document classification
@@ -5639,7 +5616,7 @@ components:
description: Document ID
changelog:
type: string
description: Changelog
description: Changelog for this version
PublishDocumentVersionOutput:
type: object
@@ -5652,6 +5629,32 @@ components:
document_version:
$ref: "#/components/schemas/DocumentVersion"
RequestDocumentVersionApprovalInput:
type: object
required:
- document_id
- approver_ids
properties:
document_id:
$ref: "#/components/schemas/GID"
description: Document ID
approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Approver profile IDs
changelog:
type: string
description: Changelog for this version
RequestDocumentVersionApprovalOutput:
type: object
required:
- document_version
properties:
document_version:
$ref: "#/components/schemas/DocumentVersion"
DeleteDocumentInput:
type: object
required:
@@ -7476,6 +7479,14 @@ tools:
$ref: "#/components/schemas/PublishDocumentVersionInput"
outputSchema:
$ref: "#/components/schemas/PublishDocumentVersionOutput"
- name: requestDocumentVersionApproval
description: Request approval for a document version
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/RequestDocumentVersionApprovalInput"
outputSchema:
$ref: "#/components/schemas/RequestDocumentVersionApprovalOutput"
- name: deleteDocument
description: Delete a document
hints:

View File

@@ -16,15 +16,13 @@ package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
func NewDocument(d *coredata.Document, approverIDs []gid.GID) *Document {
func NewDocument(d *coredata.Document) *Document {
return &Document{
ID: d.ID,
OrganizationID: d.OrganizationID,
ApproverIds: approverIDs,
Title: d.Title,
DocumentType: d.DocumentType,
Classification: d.Classification,
@@ -37,10 +35,10 @@ func NewDocument(d *coredata.Document, approverIDs []gid.GID) *Document {
}
}
func NewListControlDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField], approverIDsMap map[gid.GID][]gid.GID) ListControlDocumentsOutput {
func NewListControlDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField]) ListControlDocumentsOutput {
documents := make([]*Document, 0, len(documentPage.Data))
for _, d := range documentPage.Data {
documents = append(documents, NewDocument(d, approverIDsMap[d.ID]))
documents = append(documents, NewDocument(d))
}
var nextCursor *page.CursorKey
@@ -55,10 +53,10 @@ func NewListControlDocumentsOutput(documentPage *page.Page[*coredata.Document, c
}
}
func NewListDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField], approverIDsMap map[gid.GID][]gid.GID) ListDocumentsOutput {
func NewListDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField]) ListDocumentsOutput {
documents := make([]*Document, 0, len(documentPage.Data))
for _, d := range documentPage.Data {
documents = append(documents, NewDocument(d, approverIDsMap[d.ID]))
documents = append(documents, NewDocument(d))
}
var nextCursor *page.CursorKey
@@ -73,20 +71,19 @@ func NewListDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata
}
}
func NewAddDocumentOutput(doc *coredata.Document, docVersion *coredata.DocumentVersion, docApproverIDs []gid.GID, versionApproverIDs []gid.GID) AddDocumentOutput {
func NewAddDocumentOutput(doc *coredata.Document, docVersion *coredata.DocumentVersion) AddDocumentOutput {
return AddDocumentOutput{
Document: NewDocument(doc, docApproverIDs),
DocumentVersion: NewDocumentVersion(docVersion, versionApproverIDs),
Document: NewDocument(doc),
DocumentVersion: NewDocumentVersion(docVersion),
}
}
func NewDocumentVersion(dv *coredata.DocumentVersion, approverIDs []gid.GID) *DocumentVersion {
func NewDocumentVersion(dv *coredata.DocumentVersion) *DocumentVersion {
return &DocumentVersion{
ID: dv.ID,
OrganizationID: dv.OrganizationID,
DocumentID: dv.DocumentID,
Title: dv.Title,
ApproverIds: approverIDs,
VersionNumber: dv.VersionNumber,
Classification: dv.Classification,
Content: dv.Content,
@@ -98,10 +95,10 @@ func NewDocumentVersion(dv *coredata.DocumentVersion, approverIDs []gid.GID) *Do
}
}
func NewListDocumentVersionsOutput(versionPage *page.Page[*coredata.DocumentVersion, coredata.DocumentVersionOrderField], approverIDsMap map[gid.GID][]gid.GID) ListDocumentVersionsOutput {
func NewListDocumentVersionsOutput(versionPage *page.Page[*coredata.DocumentVersion, coredata.DocumentVersionOrderField]) ListDocumentVersionsOutput {
versions := make([]*DocumentVersion, 0, len(versionPage.Data))
for _, v := range versionPage.Data {
versions = append(versions, NewDocumentVersion(v, approverIDsMap[v.ID]))
versions = append(versions, NewDocumentVersion(v))
}
var nextCursor *page.CursorKey