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

@@ -66,45 +66,14 @@ func (p Document) CursorKey(orderBy DocumentOrderField) page.CursorKey {
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
q := `
WITH document AS (
SELECT id, organization_id, status
FROM documents
WHERE id = $1
LIMIT 1
),
latest_version AS (
SELECT dv.id, dv.document_id, dv.status AS version_status
FROM document_versions dv
INNER JOIN document ON dv.document_id = document.id
ORDER BY dv.created_at DESC
LIMIT 1
),
last_quorum AS (
SELECT
lv.document_id,
q.status::text AS status
FROM document_version_approval_quorums q
INNER JOIN latest_version lv ON lv.id = q.version_id
ORDER BY q.created_at DESC
LIMIT 1
)
SELECT
document.organization_id,
document.status,
COALESCE(lv.version_status::text, ''),
COALESCE(lq.status, '')
FROM document
LEFT JOIN latest_version lv ON lv.document_id = document.id
LEFT JOIN last_quorum lq ON lq.document_id = document.id;
SELECT organization_id
FROM documents
WHERE id = $1
LIMIT 1;
`
var (
organizationID gid.GID
documentStatus DocumentStatus
latestVersionStatus string
lastQuorumStatus string
)
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID, &documentStatus, &latestVersionStatus, &lastQuorumStatus); err != nil {
var organizationID gid.GID
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
@@ -112,10 +81,7 @@ LEFT JOIN last_quorum lq ON lq.document_id = document.id;
}
return map[string]string{
"organization_id": organizationID.String(),
"document_status": documentStatus.String(),
"version_status": latestVersionStatus,
"last_quorum_status": lastQuorumStatus,
"organization_id": organizationID.String(),
}, nil
}
@@ -1117,7 +1083,7 @@ LIMIT 1
state, err := pgx.CollectOneRow(rows, pgx.RowTo[DocumentVersionApprovalDecisionState])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return DocumentVersionApprovalDecisionStatePending, nil
return "", nil
}
return "", fmt.Errorf("cannot collect approval state: %w", err)
}

View File

@@ -0,0 +1,143 @@
// 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 coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
DocumentDefaultApprover struct {
DocumentID gid.GID `db:"document_id"`
ApproverProfileID gid.GID `db:"approver_profile_id"`
OrganizationID gid.GID `db:"organization_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
DocumentDefaultApprovers []*DocumentDefaultApprover
)
// LoadByDocumentID loads all default approvers for a document.
func (das *DocumentDefaultApprovers) LoadByDocumentID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
documentID gid.GID,
) error {
q := `
SELECT
document_id,
approver_profile_id,
organization_id,
created_at,
updated_at
FROM document_default_approvers
WHERE
%s
AND document_id = @document_id
ORDER BY created_at ASC;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"document_id": documentID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query document default approvers: %w", err)
}
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentDefaultApprover])
if err != nil {
return fmt.Errorf("cannot collect document default approvers: %w", err)
}
*das = result
return nil
}
// MergeByDocumentID merges the given approver profile IDs for a document,
// inserting new ones, keeping existing ones, and deleting removed ones.
func (das *DocumentDefaultApprovers) MergeByDocumentID(
ctx context.Context,
conn pg.Tx,
scope Scoper,
documentID gid.GID,
organizationID gid.GID,
approverProfileIDs []gid.GID,
) error {
q := `
MERGE INTO document_default_approvers AS target
USING (
SELECT unnest(@approver_profile_ids::text[]) AS approver_profile_id
) AS source
ON
%s
AND target.document_id = @document_id
AND target.approver_profile_id = source.approver_profile_id
WHEN NOT MATCHED THEN
INSERT (document_id, approver_profile_id, tenant_id, organization_id, created_at, updated_at)
VALUES (@document_id, source.approver_profile_id, @tenant_id, @organization_id, @now, @now)
WHEN NOT MATCHED BY SOURCE
AND %s
AND target.document_id = @document_id THEN
DELETE;
`
q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment())
now := time.Now()
ids := make([]string, len(approverProfileIDs))
for i, id := range approverProfileIDs {
ids[i] = id.String()
}
args := pgx.StrictNamedArgs{
"document_id": documentID,
"approver_profile_ids": ids,
"tenant_id": scope.GetTenantID(),
"organization_id": organizationID,
"now": now,
}
maps.Copy(args, scope.SQLArguments())
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot merge document default approvers: %w", err)
}
result := make(DocumentDefaultApprovers, 0, len(approverProfileIDs))
for _, profileID := range approverProfileIDs {
result = append(result, &DocumentDefaultApprover{
DocumentID: documentID,
ApproverProfileID: profileID,
OrganizationID: organizationID,
CreatedAt: now,
UpdatedAt: now,
})
}
*das = result
return nil
}

View File

@@ -52,44 +52,15 @@ type (
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
q := `
WITH document_version AS (
SELECT id, document_id, organization_id, status AS version_status
FROM document_versions
WHERE id = $1
LIMIT 1
),
document AS (
SELECT d.id, d.status
FROM documents d
INNER JOIN document_version ON d.id = document_version.document_id
),
last_quorum AS (
SELECT
q.version_id,
q.status::text AS status
FROM document_version_approval_quorums q
INNER JOIN document_version ON q.version_id = document_version.id
ORDER BY q.created_at DESC
LIMIT 1
)
SELECT
document_version.organization_id,
document.status,
document_version.version_status,
COALESCE(lq.status, '')
FROM document_version
INNER JOIN document ON document.id = document_version.document_id
LEFT JOIN last_quorum lq ON lq.version_id = document_version.id;
SELECT organization_id
FROM document_versions
WHERE id = $1
LIMIT 1;
`
var (
organizationID gid.GID
documentStatus DocumentStatus
documentVersionStatus DocumentVersionStatus
lastQuorumStatus string
)
var organizationID gid.GID
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID, &documentStatus, &documentVersionStatus, &lastQuorumStatus); err != nil {
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
@@ -97,10 +68,7 @@ LEFT JOIN last_quorum lq ON lq.version_id = document_version.id;
}
return map[string]string{
"organization_id": organizationID.String(),
"document_status": documentStatus.String(),
"version_status": documentVersionStatus.String(),
"last_quorum_status": lastQuorumStatus,
"organization_id": organizationID.String(),
}, nil
}
@@ -213,6 +181,9 @@ LIMIT 1;
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect document version: %w", err)
}
@@ -343,6 +314,9 @@ LIMIT 1;
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect document version: %w", err)
}
@@ -395,6 +369,9 @@ LIMIT 1;
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect document version: %w", err)
}
@@ -449,6 +426,9 @@ LIMIT 1;
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect document version: %w", err)
}

View File

@@ -424,6 +424,40 @@ WHERE
return nil
}
func (d *DocumentVersionApprovalDecisions) VoidPendingByQuorumID(
ctx context.Context,
conn pg.Tx,
scope Scoper,
quorumID gid.GID,
now time.Time,
) error {
q := `
UPDATE document_version_approval_decisions
SET
state = 'VOIDED',
updated_at = @updated_at
WHERE
%s
AND quorum_id = @quorum_id
AND state = 'PENDING'
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"quorum_id": quorumID,
"updated_at": now,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot void pending approval decisions: %w", err)
}
return nil
}
func (d *DocumentVersionApprovalDecisions) CountByQuorumID(
ctx context.Context,
conn pg.Querier,

View File

@@ -29,6 +29,7 @@ const (
DocumentVersionApprovalDecisionStatePending DocumentVersionApprovalDecisionState = "PENDING"
DocumentVersionApprovalDecisionStateApproved DocumentVersionApprovalDecisionState = "APPROVED"
DocumentVersionApprovalDecisionStateRejected DocumentVersionApprovalDecisionState = "REJECTED"
DocumentVersionApprovalDecisionStateVoided DocumentVersionApprovalDecisionState = "VOIDED"
)
func (s DocumentVersionApprovalDecisionState) MarshalText() ([]byte, error) {
@@ -45,6 +46,8 @@ func (s *DocumentVersionApprovalDecisionState) UnmarshalText(data []byte) error
*s = DocumentVersionApprovalDecisionStateApproved
case DocumentVersionApprovalDecisionStateRejected.String():
*s = DocumentVersionApprovalDecisionStateRejected
case DocumentVersionApprovalDecisionStateVoided.String():
*s = DocumentVersionApprovalDecisionStateVoided
default:
return fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", val)
}
@@ -62,6 +65,8 @@ func (s DocumentVersionApprovalDecisionState) String() string {
val = "APPROVED"
case DocumentVersionApprovalDecisionStateRejected:
val = "REJECTED"
case DocumentVersionApprovalDecisionStateVoided:
val = "VOIDED"
default:
panic(fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", string(s)))
}

View File

@@ -25,6 +25,7 @@ const (
DocumentVersionApprovalQuorumStatusPending DocumentVersionApprovalQuorumStatus = "PENDING"
DocumentVersionApprovalQuorumStatusApproved DocumentVersionApprovalQuorumStatus = "APPROVED"
DocumentVersionApprovalQuorumStatusRejected DocumentVersionApprovalQuorumStatus = "REJECTED"
DocumentVersionApprovalQuorumStatusVoided DocumentVersionApprovalQuorumStatus = "VOIDED"
)
func (s DocumentVersionApprovalQuorumStatus) MarshalText() ([]byte, error) {
@@ -41,6 +42,8 @@ func (s *DocumentVersionApprovalQuorumStatus) UnmarshalText(data []byte) error {
*s = DocumentVersionApprovalQuorumStatusApproved
case DocumentVersionApprovalQuorumStatusRejected.String():
*s = DocumentVersionApprovalQuorumStatusRejected
case DocumentVersionApprovalQuorumStatusVoided.String():
*s = DocumentVersionApprovalQuorumStatusVoided
default:
return fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", val)
}
@@ -58,6 +61,8 @@ func (s DocumentVersionApprovalQuorumStatus) String() string {
val = "APPROVED"
case DocumentVersionApprovalQuorumStatusRejected:
val = "REJECTED"
case DocumentVersionApprovalQuorumStatusVoided:
val = "VOIDED"
default:
panic(fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", string(s)))
}

View File

@@ -20,12 +20,13 @@ import (
)
type (
DocumentVersionStatus uint8
DocumentVersionStatus string
)
const (
DocumentVersionStatusDraft DocumentVersionStatus = iota
DocumentVersionStatusPublished
DocumentVersionStatusDraft DocumentVersionStatus = "DRAFT"
DocumentVersionStatusPendingApproval DocumentVersionStatus = "PENDING_APPROVAL"
DocumentVersionStatusPublished DocumentVersionStatus = "PUBLISHED"
)
func (ps DocumentVersionStatus) MarshalText() ([]byte, error) {
@@ -38,6 +39,8 @@ func (ps *DocumentVersionStatus) UnmarshalText(data []byte) error {
switch val {
case DocumentVersionStatusDraft.String():
*ps = DocumentVersionStatusDraft
case DocumentVersionStatusPendingApproval.String():
*ps = DocumentVersionStatusPendingApproval
case DocumentVersionStatusPublished.String():
*ps = DocumentVersionStatusPublished
default:
@@ -48,16 +51,16 @@ func (ps *DocumentVersionStatus) UnmarshalText(data []byte) error {
}
func (ps DocumentVersionStatus) String() string {
var val string
switch ps {
case DocumentVersionStatusDraft:
val = "DRAFT"
return "DRAFT"
case DocumentVersionStatusPendingApproval:
return "PENDING_APPROVAL"
case DocumentVersionStatusPublished:
val = "PUBLISHED"
return "PUBLISHED"
default:
panic(fmt.Errorf("invalid DocumentVersionStatus value: %q", string(ps)))
}
return val
}
func (ps *DocumentVersionStatus) Scan(value any) error {

View File

@@ -0,0 +1,17 @@
-- 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.
ALTER TYPE document_version_status ADD VALUE 'PENDING_APPROVAL' BEFORE 'PUBLISHED';
ALTER TYPE document_version_approval_quorum_status ADD VALUE 'VOIDED';
ALTER TYPE document_version_approval_decision_state ADD VALUE 'VOIDED';

View File

@@ -0,0 +1,35 @@
-- 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.
-- Backfill: set every draft version that has a pending approval quorum to PENDING_APPROVAL
UPDATE document_versions dv
SET status = 'PENDING_APPROVAL'
WHERE dv.status = 'DRAFT'
AND EXISTS (
SELECT 1
FROM document_version_approval_quorums q
WHERE q.version_id = dv.id
AND q.status = 'PENDING'
);
-- Backfill: void pending decisions in rejected or voided quorums
UPDATE document_version_approval_decisions d
SET state = 'VOIDED'
WHERE d.state = 'PENDING'
AND EXISTS (
SELECT 1
FROM document_version_approval_quorums q
WHERE q.id = d.quorum_id
AND q.status IN ('REJECTED', 'VOIDED')
);

View File

@@ -0,0 +1,47 @@
-- 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.
CREATE TABLE document_default_approvers (
document_id text NOT NULL,
approver_profile_id text NOT NULL,
tenant_id text NOT NULL,
organization_id text NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
PRIMARY KEY (document_id, approver_profile_id),
FOREIGN KEY (document_id) REFERENCES documents(id) ON UPDATE CASCADE ON DELETE CASCADE,
FOREIGN KEY (approver_profile_id) REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE CASCADE
);
-- Backfill default approvers from the last quorum of the last version of each document.
INSERT INTO document_default_approvers (document_id, approver_profile_id, tenant_id, organization_id, created_at, updated_at)
SELECT DISTINCT
dv.document_id,
d.approver_id,
d.tenant_id,
d.organization_id,
d.created_at,
d.created_at
FROM document_version_approval_decisions d
JOIN document_version_approval_quorums q ON q.id = d.quorum_id
JOIN document_versions dv ON dv.id = q.version_id
WHERE q.id = (
SELECT q2.id
FROM document_version_approval_quorums q2
JOIN document_versions dv2 ON dv2.id = q2.version_id
WHERE dv2.document_id = dv.document_id
ORDER BY dv2.created_at DESC, q2.created_at DESC
LIMIT 1
)
ON CONFLICT (document_id, approver_profile_id) DO NOTHING;

View File

@@ -197,11 +197,10 @@ const (
ActionDocumentVersionUpdate = "core:document-version:update"
ActionDocumentVersionDeleteDraft = "core:document-version:delete-draft"
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"
ActionDocumentVersionAddApprover = "core:document-version:add-approver"
ActionDocumentVersionRemoveApprover = "core:document-version:remove-approver"
ActionDocumentVersionPublish = "core:document-version:publish"
ActionDocumentVersionExport = "core:document-version:export"

View File

@@ -19,9 +19,8 @@ import (
"context"
"errors"
"fmt"
"time"
"net/url"
"time"
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/pg"
@@ -84,10 +83,10 @@ func (req *RequestApprovalRequest) Validate() error {
v := validator.New()
v.Check(req.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType))
v.Check(req.ApproverIDs, "approver_ids", validator.Required())
v.Check(len(req.ApproverIDs), "approver_ids", validator.Max(100))
v.CheckEach(req.ApproverIDs, "approver_ids", func(_ int, item any) {
v.Check(item, "approver_ids", validator.GID(coredata.MembershipProfileEntityType))
v.Check(len(req.ApproverIDs), "approver_ids", validator.Min(1), validator.Max(100))
v.Check(req.ApproverIDs, "approver_ids", validator.NoDuplicates())
v.CheckEach(req.ApproverIDs, "approver_ids", func(index int, item any) {
v.Check(item, fmt.Sprintf("approver_ids[%d]", index), validator.GID(coredata.MembershipProfileEntityType))
})
v.Check(req.Changelog, "changelog", validator.Required(), validator.SafeText(5000))
@@ -121,53 +120,19 @@ func (s *DocumentApprovalService) RequestApproval(
return fmt.Errorf("cannot load latest version: %w", err)
}
if documentVersion.Status == coredata.DocumentVersionStatusPublished {
return fmt.Errorf("cannot request approval for a published document")
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
return &ErrDocumentVersionNotDraft{}
}
if err := s.rejectPendingQuorum(ctx, tx, documentVersion.ID); err != nil {
return fmt.Errorf("cannot reject pending quorum: %w", err)
q, err := s.requestApprovalInTx(ctx, tx, document, documentVersion, req.ApproverIDs, req.Changelog)
if err != nil {
return err
}
quorum = q
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, s.svc.scope, document.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
approverProfiles := &coredata.MembershipProfiles{}
if err := approverProfiles.LoadByIDs(ctx, tx, s.svc.scope, req.ApproverIDs); err != nil {
return fmt.Errorf("cannot load approver profiles: %w", err)
}
now := time.Now()
if req.Changelog != nil {
documentVersion.Changelog = *req.Changelog
documentVersion.UpdatedAt = now
if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document version changelog: %w", err)
}
}
quorum = &coredata.DocumentVersionApprovalQuorum{
ID: gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionApprovalQuorumEntityType),
OrganizationID: document.OrganizationID,
VersionID: documentVersion.ID,
Status: coredata.DocumentVersionApprovalQuorumStatusPending,
CreatedAt: now,
UpdatedAt: now,
}
if err := quorum.Insert(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert approval quorum: %w", err)
}
if err := s.createDecisions(ctx, tx, quorum, document.OrganizationID, req.ApproverIDs, now); err != nil {
return fmt.Errorf("cannot create approval decisions: %w", err)
}
if err := s.sendApprovalEmails(ctx, tx, *approverProfiles, document, organization, documentVersion.ID); err != nil {
return fmt.Errorf("cannot send approval emails: %w", err)
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, req.DocumentID, document.OrganizationID, req.ApproverIDs); err != nil {
return fmt.Errorf("cannot update default approvers: %w", err)
}
return nil
@@ -181,6 +146,138 @@ func (s *DocumentApprovalService) RequestApproval(
return quorum, nil
}
func (s *DocumentApprovalService) requestApprovalInTx(
ctx context.Context,
tx pg.Tx,
document *coredata.Document,
documentVersion *coredata.DocumentVersion,
approverIDs []gid.GID,
changelog *string,
) (*coredata.DocumentVersionApprovalQuorum, error) {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, s.svc.scope, document.OrganizationID); err != nil {
return nil, fmt.Errorf("cannot load organization: %w", err)
}
approverProfiles := &coredata.MembershipProfiles{}
if err := approverProfiles.LoadByIDs(ctx, tx, s.svc.scope, approverIDs); err != nil {
return nil, fmt.Errorf("cannot load approver profiles: %w", err)
}
now := time.Now()
documentVersion.Status = coredata.DocumentVersionStatusPendingApproval
if changelog != nil {
documentVersion.Changelog = *changelog
}
if document.CurrentPublishedMajor != nil {
documentVersion.Major = *document.CurrentPublishedMajor + 1
} else {
documentVersion.Major = 1
}
documentVersion.Minor = 0
documentVersion.UpdatedAt = now
if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil {
return nil, fmt.Errorf("cannot update document version: %w", err)
}
quorum := &coredata.DocumentVersionApprovalQuorum{
ID: gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionApprovalQuorumEntityType),
OrganizationID: document.OrganizationID,
VersionID: documentVersion.ID,
Status: coredata.DocumentVersionApprovalQuorumStatusPending,
CreatedAt: now,
UpdatedAt: now,
}
if err := quorum.Insert(ctx, tx, s.svc.scope); err != nil {
return nil, fmt.Errorf("cannot insert approval quorum: %w", err)
}
if err := s.createDecisions(ctx, tx, quorum, document.OrganizationID, approverIDs, now); err != nil {
return nil, fmt.Errorf("cannot create approval decisions: %w", err)
}
if err := s.sendApprovalEmails(ctx, tx, *approverProfiles, document, organization, documentVersion.ID); err != nil {
return nil, fmt.Errorf("cannot send approval emails: %w", err)
}
return quorum, nil
}
func (s *DocumentApprovalService) BulkPublishMajorVersions(
ctx context.Context,
req BulkPublishVersionsRequest,
) ([]*coredata.DocumentVersion, []*coredata.Document, error) {
var publishedVersions []*coredata.DocumentVersion
var updatedDocuments []*coredata.Document
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
for _, documentID := range req.DocumentIDs {
dv := &coredata.DocumentVersion{}
if err := dv.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil {
return fmt.Errorf("cannot load latest version for %q: %w", documentID, err)
}
// Skip documents already pending approval.
if dv.Status == coredata.DocumentVersionStatusPendingApproval {
continue
}
document := &coredata.Document{}
if err := document.LoadByID(ctx, tx, s.svc.scope, documentID); err != nil {
return fmt.Errorf("cannot load document %q: %w", documentID, err)
}
if document.ArchivedAt != nil {
return &ErrDocumentArchived{}
}
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.LoadByDocumentID(ctx, tx, s.svc.scope, documentID); err != nil {
return fmt.Errorf("cannot load default approvers for %q: %w", documentID, err)
}
if dv.Status != coredata.DocumentVersionStatusDraft {
continue
}
if len(*defaultApprovers) > 0 {
approverIDs := make([]gid.GID, len(*defaultApprovers))
for i, a := range *defaultApprovers {
approverIDs[i] = a.ApproverProfileID
}
if _, err := s.requestApprovalInTx(ctx, tx, document, dv, approverIDs, &req.Changelog); err != nil {
return fmt.Errorf("cannot request approval for %q: %w", documentID, err)
}
} else {
var err error
document, dv, err = s.svc.Documents.publishMajorVersionInTx(ctx, tx, documentID, &req.Changelog, true)
if err != nil {
return fmt.Errorf("cannot publish document %q: %w", documentID, err)
}
}
publishedVersions = append(publishedVersions, dv)
updatedDocuments = append(updatedDocuments, document)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return publishedVersions, updatedDocuments, nil
}
func (s *DocumentApprovalService) Approve(
ctx context.Context,
req ApproveDocumentVersionRequest,
@@ -205,6 +302,10 @@ func (s *DocumentApprovalService) Approve(
return fmt.Errorf("cannot load document: %w", err)
}
if document.ArchivedAt != nil {
return &ErrDocumentArchived{}
}
var profile *coredata.MembershipProfile
var err error
quorum, profile, err = s.loadQuorumAndProfile(ctx, conn, req.DocumentVersionID, req.IdentityID, documentVersion.OrganizationID)
@@ -269,9 +370,20 @@ func (s *DocumentApprovalService) Approve(
approverID := decision.ApproverID
quorumID := quorum.ID
err = s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
quorum = &coredata.DocumentVersionApprovalQuorum{}
if err := quorum.LoadByID(ctx, tx, s.svc.scope, quorumID); err != nil {
return fmt.Errorf("cannot load quorum: %w", err)
}
if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
return &ErrDocumentVersionNotPendingApproval{}
}
decision = &coredata.DocumentVersionApprovalDecision{}
if err := decision.LoadByQuorumIDAndApproverID(ctx, tx, s.svc.scope, quorum.ID, approverID); err != nil {
return fmt.Errorf("cannot load approval decision: %w", err)
@@ -343,6 +455,15 @@ func (s *DocumentApprovalService) Reject(
return fmt.Errorf("cannot load document version: %w", err)
}
document := &coredata.Document{}
if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil {
return fmt.Errorf("cannot load document: %w", err)
}
if document.ArchivedAt != nil {
return &ErrDocumentArchived{}
}
quorum, profile, err := s.loadQuorumAndProfile(ctx, tx, req.DocumentVersionID, req.IdentityID, documentVersion.OrganizationID)
if err != nil {
return fmt.Errorf("cannot load quorum and profile: %w", err)
@@ -375,6 +496,25 @@ func (s *DocumentApprovalService) Reject(
return fmt.Errorf("cannot update approval quorum: %w", err)
}
decisions := &coredata.DocumentVersionApprovalDecisions{}
if err := decisions.VoidPendingByQuorumID(ctx, tx, s.svc.scope, quorum.ID, now); err != nil {
return fmt.Errorf("cannot void pending decisions: %w", err)
}
documentVersion.Status = coredata.DocumentVersionStatusDraft
if document.CurrentPublishedMajor != nil {
documentVersion.Major = *document.CurrentPublishedMajor
documentVersion.Minor = *document.CurrentPublishedMinor + 1
} else {
documentVersion.Major = 0
documentVersion.Minor = 1
}
documentVersion.UpdatedAt = now
if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document version status: %w", err)
}
return nil
},
)
@@ -386,70 +526,71 @@ func (s *DocumentApprovalService) Reject(
return decision, nil
}
func (s *DocumentApprovalService) AddApprover(
func (s *DocumentApprovalService) VoidApproval(
ctx context.Context,
documentVersionID gid.GID,
approverID gid.GID,
) (*coredata.DocumentVersionApprovalDecision, error) {
var decision *coredata.DocumentVersionApprovalDecision
) (*coredata.DocumentVersionApprovalQuorum, *coredata.DocumentVersion, error) {
var (
quorum *coredata.DocumentVersionApprovalQuorum
documentVersion *coredata.DocumentVersion
)
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
documentVersion := &coredata.DocumentVersion{}
documentVersion = &coredata.DocumentVersion{}
if err := documentVersion.LoadByID(ctx, tx, s.svc.scope, documentVersionID); err != nil {
return fmt.Errorf("cannot load document version: %w", err)
}
quorum := &coredata.DocumentVersionApprovalQuorum{}
if err := quorum.LoadLastByDocumentVersionID(ctx, tx, s.svc.scope, documentVersionID); err != nil {
document := &coredata.Document{}
if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil {
return fmt.Errorf("cannot load document: %w", err)
}
if document.ArchivedAt != nil {
return &ErrDocumentArchived{}
}
if documentVersion.Status != coredata.DocumentVersionStatusPendingApproval {
return &ErrDocumentVersionNotPendingApproval{}
}
quorum = &coredata.DocumentVersionApprovalQuorum{}
if err := quorum.LoadLastByDocumentVersionID(ctx, tx, s.svc.scope, documentVersionID); err != nil {
return fmt.Errorf("cannot load approval quorum: %w", err)
}
if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
return &ErrDocumentVersionNotPendingApproval{}
}
now := time.Now()
decision = &coredata.DocumentVersionApprovalDecision{
ID: gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionApprovalDecisionEntityType),
OrganizationID: documentVersion.OrganizationID,
QuorumID: quorum.ID,
ApproverID: approverID,
State: coredata.DocumentVersionApprovalDecisionStatePending,
CreatedAt: now,
UpdatedAt: now,
quorum.Status = coredata.DocumentVersionApprovalQuorumStatusVoided
quorum.UpdatedAt = now
if err := quorum.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update approval quorum: %w", err)
}
if err := decision.Insert(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert approval decision: %w", err)
decisions := &coredata.DocumentVersionApprovalDecisions{}
if err := decisions.VoidPendingByQuorumID(ctx, tx, s.svc.scope, quorum.ID, now); err != nil {
return fmt.Errorf("cannot void pending decisions: %w", err)
}
document := &coredata.Document{}
if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil {
return fmt.Errorf("cannot load document: %w", err)
documentVersion.Status = coredata.DocumentVersionStatusDraft
if document.CurrentPublishedMajor != nil {
documentVersion.Major = *document.CurrentPublishedMajor
documentVersion.Minor = *document.CurrentPublishedMinor + 1
} else {
documentVersion.Major = 0
documentVersion.Minor = 1
}
documentVersion.UpdatedAt = now
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, s.svc.scope, document.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
profile := &coredata.MembershipProfile{}
if err := profile.LoadByID(ctx, tx, s.svc.scope, approverID); err != nil {
return fmt.Errorf("cannot load approver profile: %w", err)
}
if err := s.sendApprovalEmails(
ctx,
tx,
coredata.MembershipProfiles{profile},
document,
organization,
documentVersionID,
); err != nil {
return fmt.Errorf("cannot send approval email: %w", err)
if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document version status: %w", err)
}
return nil
@@ -457,66 +598,10 @@ func (s *DocumentApprovalService) AddApprover(
)
if err != nil {
return nil, err
return nil, nil, err
}
return decision, nil
}
func (s *DocumentApprovalService) RemoveApprover(
ctx context.Context,
approvalDecisionID gid.GID,
) (gid.GID, error) {
var documentVersionID gid.GID
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
decision := &coredata.DocumentVersionApprovalDecision{}
if err := decision.LoadByID(ctx, tx, s.svc.scope, approvalDecisionID); err != nil {
return fmt.Errorf("cannot load approval decision: %w", err)
}
quorum := &coredata.DocumentVersionApprovalQuorum{}
if err := quorum.LoadByID(ctx, tx, s.svc.scope, decision.QuorumID); err != nil {
return fmt.Errorf("cannot load approval quorum: %w", err)
}
if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
return &ErrDocumentVersionNotPendingApproval{}
}
documentVersionID = quorum.VersionID
if err := decision.Delete(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot delete approval decision: %w", err)
}
remaining, err := s.countDecisions(ctx, tx, quorum.ID)
if err != nil {
return fmt.Errorf("cannot count remaining decisions: %w", err)
}
if remaining == 0 {
if err := quorum.Delete(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot delete approval quorum: %w", err)
}
return nil
}
if err := s.maybeApproveQuorum(ctx, tx, quorum.ID); err != nil {
return fmt.Errorf("cannot check quorum approval: %w", err)
}
return nil
},
)
if err != nil {
return gid.GID{}, err
}
return documentVersionID, nil
return quorum, documentVersion, nil
}
func (s *DocumentApprovalService) GetQuorum(
@@ -726,34 +811,6 @@ func (s *DocumentApprovalService) loadQuorumAndProfile(
return quorum, profile, nil
}
func (s *DocumentApprovalService) rejectPendingQuorum(
ctx context.Context,
tx pg.Tx,
documentVersionID gid.GID,
) error {
existingQuorum := &coredata.DocumentVersionApprovalQuorum{}
if err := existingQuorum.LoadLastByDocumentVersionID(ctx, tx, s.svc.scope, documentVersionID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil
}
return fmt.Errorf("cannot load last quorum: %w", err)
}
if existingQuorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
return nil
}
now := time.Now()
existingQuorum.Status = coredata.DocumentVersionApprovalQuorumStatusRejected
existingQuorum.UpdatedAt = now
if err := existingQuorum.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot reject existing quorum: %w", err)
}
return nil
}
func (s *DocumentApprovalService) createDecisions(
ctx context.Context,
tx pg.Tx,
@@ -918,16 +975,18 @@ func (s *DocumentApprovalService) maybeApproveQuorum(
return fmt.Errorf("cannot count total decisions: %w", err)
}
if totalCount > 0 {
decisions := &coredata.DocumentVersionApprovalDecisions{}
approvedCount, err := decisions.CountApprovedByQuorumID(ctx, tx, s.svc.scope, quorumID)
if err != nil {
return fmt.Errorf("cannot count approved decisions: %w", err)
}
if totalCount == 0 {
return nil
}
if approvedCount != totalCount {
return nil
}
decisions := &coredata.DocumentVersionApprovalDecisions{}
approvedCount, err := decisions.CountApprovedByQuorumID(ctx, tx, s.svc.scope, quorumID)
if err != nil {
return fmt.Errorf("cannot count approved decisions: %w", err)
}
if approvedCount != totalCount {
return nil
}
quorum := &coredata.DocumentVersionApprovalQuorum{}
@@ -960,13 +1019,17 @@ func (s *DocumentApprovalService) publishVersion(
return fmt.Errorf("cannot load document version: %w", err)
}
_, _, err := s.svc.Documents.publishMajorVersionInTx(
ctx,
tx,
version.DocumentID,
nil,
false,
)
document := &coredata.Document{}
if err := document.LoadByID(ctx, tx, s.svc.scope, version.DocumentID); err != nil {
return fmt.Errorf("cannot load document: %w", err)
}
return err
document.CurrentPublishedMajor = &version.Major
document.CurrentPublishedMinor = &version.Minor
if err := s.svc.Documents.finalizePublish(ctx, tx, document, version, nil); err != nil {
return fmt.Errorf("cannot finalize publish: %w", err)
}
return nil
}

View File

@@ -62,6 +62,12 @@ type (
ErrDocumentVersionNotDraft struct {
}
ErrDocumentVersionNotPublished struct {
}
ErrDocumentVersionPendingApproval struct {
}
ErrDocumentArchived struct {
}
@@ -78,12 +84,14 @@ type (
Classification coredata.DocumentClassification
DocumentType coredata.DocumentType
TrustCenterVisibility *coredata.TrustCenterVisibility
DefaultApproverIDs []gid.GID
}
UpdateDocumentRequest struct {
DocumentID gid.GID
Title *string
TrustCenterVisibility *coredata.TrustCenterVisibility
DefaultApproverIDs *[]gid.GID
}
UpdateDocumentVersionRequest struct {
@@ -129,6 +137,11 @@ func (cdr *CreateDocumentRequest) Validate() error {
v.Check(cdr.Classification, "classification", validator.Required(), validator.OneOfSlice(coredata.DocumentClassifications()))
v.Check(cdr.DocumentType, "document_type", validator.Required(), validator.OneOfSlice(coredata.DocumentTypes()))
v.Check(cdr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
v.Check(len(cdr.DefaultApproverIDs), "default_approver_ids", validator.Max(100))
v.Check(cdr.DefaultApproverIDs, "default_approver_ids", validator.NoDuplicates())
v.CheckEach(cdr.DefaultApproverIDs, "default_approver_ids", func(_ int, item any) {
v.Check(item, "default_approver_ids", validator.GID(coredata.MembershipProfileEntityType))
})
return v.Error()
}
@@ -139,6 +152,13 @@ func (udr *UpdateDocumentRequest) Validate() error {
v.Check(udr.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType))
v.Check(udr.Title, "title", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(udr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
if udr.DefaultApproverIDs != nil {
v.Check(len(*udr.DefaultApproverIDs), "default_approver_ids", validator.Max(100))
v.Check(*udr.DefaultApproverIDs, "default_approver_ids", validator.NoDuplicates())
v.CheckEach(*udr.DefaultApproverIDs, "default_approver_ids", func(_ int, item any) {
v.Check(item, "default_approver_ids", validator.GID(coredata.MembershipProfileEntityType))
})
}
return v.Error()
}
@@ -179,7 +199,15 @@ func (e ErrSignatureNotCancellable) Error() string {
}
func (e ErrDocumentVersionNotDraft) Error() string {
return "cannot update a published document version"
return "document version is not a draft"
}
func (e ErrDocumentVersionNotPublished) Error() string {
return "document version is not published"
}
func (e ErrDocumentVersionPendingApproval) Error() string {
return "cannot publish a document version that is pending approval"
}
func (e ErrDocumentArchived) Error() string {
@@ -214,6 +242,48 @@ func (s *DocumentService) Get(
return document, nil
}
func (s *DocumentService) GetDefaultApprovers(
ctx context.Context,
documentID gid.GID,
) (coredata.MembershipProfiles, error) {
var approvers coredata.DocumentDefaultApprovers
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return approvers.LoadByDocumentID(ctx, conn, s.svc.scope, documentID)
},
)
if err != nil {
return nil, fmt.Errorf("cannot load default approvers: %w", err)
}
if len(approvers) == 0 {
return nil, nil
}
profileIDs := make([]gid.GID, len(approvers))
for i, a := range approvers {
profileIDs[i] = a.ApproverProfileID
}
var profiles coredata.MembershipProfiles
err = s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return profiles.LoadByIDs(ctx, conn, s.svc.scope, profileIDs)
},
)
if err != nil {
return nil, fmt.Errorf("cannot load approver profiles: %w", err)
}
return profiles, nil
}
func (s *DocumentService) GetByIDs(
ctx context.Context,
documentIDs ...gid.GID,
@@ -343,6 +413,10 @@ func (s DocumentService) GenerateChangelog(
return fmt.Errorf("cannot load document: %w", err)
}
if document.ArchivedAt != nil {
return &ErrDocumentArchived{}
}
if document.CurrentPublishedMajor == nil {
initialVersionChangelog := "Initial version"
changelog = &initialVersionChangelog
@@ -375,37 +449,6 @@ func (s DocumentService) GenerateChangelog(
return changelog, nil
}
func (s *DocumentService) BulkPublishMajorVersions(
ctx context.Context,
req BulkPublishVersionsRequest,
) ([]*coredata.DocumentVersion, []*coredata.Document, error) {
var publishedVersions []*coredata.DocumentVersion
var updatedDocuments []*coredata.Document
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
for _, documentID := range req.DocumentIDs {
document, version, err := s.publishMajorVersionInTx(ctx, tx, documentID, &req.Changelog, true)
if err != nil {
return fmt.Errorf("cannot publish document %q: %w", documentID, err)
}
publishedVersions = append(publishedVersions, version)
updatedDocuments = append(updatedDocuments, document)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return publishedVersions, updatedDocuments, nil
}
func (s *DocumentService) BulkPublishMinorVersions(
ctx context.Context,
req BulkPublishVersionsRequest,
@@ -417,6 +460,16 @@ func (s *DocumentService) BulkPublishMinorVersions(
ctx,
func(ctx context.Context, tx pg.Tx) error {
for _, documentID := range req.DocumentIDs {
dv := &coredata.DocumentVersion{}
if err := dv.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil {
return fmt.Errorf("cannot load latest version for %q: %w", documentID, err)
}
// Skip documents already pending approval.
if dv.Status == coredata.DocumentVersionStatusPendingApproval {
continue
}
document, version, err := s.publishMinorVersionInTx(ctx, tx, documentID, &req.Changelog, true)
if err != nil {
return fmt.Errorf("cannot publish document %q: %w", documentID, err)
@@ -449,6 +502,15 @@ func (s *DocumentService) PublishMajorVersion(
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
dv := &coredata.DocumentVersion{}
if err := dv.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil {
return fmt.Errorf("cannot load latest version: %w", err)
}
if dv.Status == coredata.DocumentVersionStatusPendingApproval {
return &ErrDocumentVersionPendingApproval{}
}
var err error
document, documentVersion, err = s.publishMajorVersionInTx(ctx, tx, documentID, changelog, false)
@@ -479,6 +541,15 @@ func (s *DocumentService) PublishMinorVersion(
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
dv := &coredata.DocumentVersion{}
if err := dv.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil {
return fmt.Errorf("cannot load latest version: %w", err)
}
if dv.Status == coredata.DocumentVersionStatusPendingApproval {
return &ErrDocumentVersionPendingApproval{}
}
var err error
document, documentVersion, err = s.publishMinorVersionInTx(ctx, tx, documentID, changelog, false)
@@ -566,6 +637,13 @@ func (s *DocumentService) Create(
return fmt.Errorf("cannot create document version: %w", err)
}
if len(req.DefaultApproverIDs) > 0 {
approvers := &coredata.DocumentDefaultApprovers{}
if err := approvers.MergeByDocumentID(ctx, conn, s.svc.scope, documentID, organization.ID, req.DefaultApproverIDs); err != nil {
return fmt.Errorf("cannot set default approvers: %w", err)
}
}
return nil
},
)
@@ -916,19 +994,30 @@ func (s *DocumentService) RequestSignature(
ctx context.Context,
req RequestSignatureRequest,
) (*coredata.DocumentVersionSignature, error) {
documentVersion, err := s.GetVersion(ctx, req.DocumentVersionID)
if err != nil {
return nil, fmt.Errorf("cannot get document version: %w", err)
}
if documentVersion.Status != coredata.DocumentVersionStatusPublished {
return nil, fmt.Errorf("cannot request signature for unpublished version")
}
var signature *coredata.DocumentVersionSignature
err = s.svc.pg.WithTx(
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
documentVersion := &coredata.DocumentVersion{}
if err := documentVersion.LoadByID(ctx, tx, s.svc.scope, req.DocumentVersionID); err != nil {
return fmt.Errorf("cannot load document version: %w", err)
}
document := &coredata.Document{}
if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil {
return fmt.Errorf("cannot load document: %w", err)
}
if document.ArchivedAt != nil {
return &ErrDocumentArchived{}
}
if documentVersion.Status != coredata.DocumentVersionStatusPublished {
return fmt.Errorf("cannot request signature for unpublished version")
}
var err error
signature, err = s.createSignatureRequestInTx(ctx, tx, req.DocumentVersionID, req.Signatory, false)
if err != nil {
return fmt.Errorf("cannot create signature request: %w", err)
@@ -1015,12 +1104,16 @@ func (s *DocumentService) CreateDraft(
return fmt.Errorf("cannot load document: %w", err)
}
if document.ArchivedAt != nil {
return &ErrDocumentArchived{}
}
if err := latestVersion.LoadLatestVersion(ctx, conn, s.svc.scope, documentID); err != nil {
return fmt.Errorf("cannot load latest version: %w", err)
}
if latestVersion.Status != coredata.DocumentVersionStatusPublished {
return fmt.Errorf("cannot create draft from unpublished version")
return &ErrDocumentVersionNotPublished{}
}
draftVersion.ID = draftVersionID
@@ -1064,6 +1157,15 @@ func (s *DocumentService) DeleteDraft(
return fmt.Errorf("cannot load document version: %w", err)
}
document := &coredata.Document{}
if err := document.LoadByID(ctx, conn, s.svc.scope, documentVersion.DocumentID); err != nil {
return fmt.Errorf("cannot load document: %w", err)
}
if document.ArchivedAt != nil {
return &ErrDocumentArchived{}
}
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
return fmt.Errorf("cannot delete published document version")
}
@@ -1639,6 +1741,13 @@ func (s *DocumentService) Update(
}
}
if req.DefaultApproverIDs != nil {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, req.DocumentID, document.OrganizationID, *req.DefaultApproverIDs); err != nil {
return fmt.Errorf("cannot update default approvers: %w", err)
}
}
return nil
},
)
@@ -1753,6 +1862,20 @@ func (s *DocumentService) CancelSignatureRequest(
return fmt.Errorf("cannot load document version signature: %w", err)
}
documentVersion := &coredata.DocumentVersion{}
if err := documentVersion.LoadByID(ctx, tx, s.svc.scope, documentVersionSignature.DocumentVersionID); err != nil {
return fmt.Errorf("cannot load document version: %w", err)
}
document := &coredata.Document{}
if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil {
return fmt.Errorf("cannot load document: %w", err)
}
if document.ArchivedAt != nil {
return &ErrDocumentArchived{}
}
if documentVersionSignature.State != coredata.DocumentVersionSignatureStateRequested {
return ErrSignatureNotCancellable{
currentState: documentVersionSignature.State,
@@ -2269,7 +2392,7 @@ func (s *DocumentService) loadDraftForPublish(
return document, documentVersion, nil
}
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
if documentVersion.Status != coredata.DocumentVersionStatusDraft && documentVersion.Status != coredata.DocumentVersionStatusPendingApproval {
return nil, nil, &ErrDocumentVersionNotDraft{}
}

View File

@@ -20,71 +20,13 @@ import (
)
var (
organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id")
documentWriteActiveOnly = policy.Deny(
ActionDocumentUpdate,
ActionDocumentArchive,
ActionDocumentDraftVersionCreate,
ActionDocumentChangelogGenerate,
ActionDocumentSendSigningNotifications,
ActionDocumentVersionUpdate,
ActionDocumentVersionPublish,
ActionDocumentVersionRequestApproval,
ActionDocumentVersionApprove,
ActionDocumentVersionReject,
ActionDocumentVersionAddApprover,
ActionDocumentVersionRemoveApprover,
ActionDocumentVersionDeleteDraft,
ActionDocumentVersionSignatureRequest,
ActionDocumentVersionCancelSignature,
).WithSID("document-write-active-only").When(
organizationCondition,
policy.Equals("resource.document_status", "ARCHIVED"),
)
documentUnarchiveArchivedOnly = policy.Deny(
ActionDocumentUnarchive,
).WithSID("document-unarchive-archived-only").When(
organizationCondition,
policy.Equals("resource.document_status", "ACTIVE"),
)
// Deny requesting approval when a pending quorum exists
documentRequestApprovalNoPendingQuorum = policy.Deny(
ActionDocumentVersionRequestApproval,
).WithSID("document-request-approval-no-pending-quorum").When(
organizationCondition,
policy.Equals("resource.last_quorum_status", "PENDING"),
)
// Deny requesting approval when the version is already published
documentRequestApprovalNotPublished = policy.Deny(
ActionDocumentVersionRequestApproval,
).WithSID("document-request-approval-not-published").When(
organizationCondition,
policy.Equals("resource.version_status", "PUBLISHED"),
)
// Deny adding/removing approvers when there is no pending quorum
documentApproverRequiresPendingQuorum = policy.Deny(
ActionDocumentVersionAddApprover,
ActionDocumentVersionRemoveApprover,
).WithSID("document-approver-requires-pending-quorum").When(
organizationCondition,
policy.NotEquals("resource.last_quorum_status", "PENDING"),
)
organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id")
)
// OwnerPolicy defines permissions for organization owners.
var OwnerPolicy = policy.NewPolicy(
"probo:owner",
"Probo Owner",
documentWriteActiveOnly,
documentUnarchiveArchivedOnly,
documentRequestApprovalNoPendingQuorum,
documentRequestApprovalNotPublished,
documentApproverRequiresPendingQuorum,
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
).WithDescription("Full probo access for organization owners")
@@ -92,13 +34,6 @@ var OwnerPolicy = policy.NewPolicy(
var AdminPolicy = policy.NewPolicy(
"probo:admin",
"Probo Admin",
documentWriteActiveOnly,
documentUnarchiveArchivedOnly,
documentRequestApprovalNoPendingQuorum,
documentRequestApprovalNotPublished,
documentApproverRequiresPendingQuorum,
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
).WithDescription("Probo admin access - can manage core entities")
@@ -106,7 +41,6 @@ var AdminPolicy = policy.NewPolicy(
var ViewerPolicy = policy.NewPolicy(
"probo:viewer",
"Probo Viewer",
documentWriteActiveOnly,
policy.Allow(
ActionOrganizationGet,
ActionOrganizationGetLogoUrl,
@@ -244,7 +178,6 @@ var AuditorPolicy = policy.NewPolicy(
var EmployeePolicy = policy.NewPolicy(
"probo:employee",
"Probo Employee",
documentWriteActiveOnly,
policy.Allow(
ActionOrganizationGet,
ActionOrganizationGetLogoUrl,
@@ -263,7 +196,6 @@ var EmployeePolicy = policy.NewPolicy(
ActionDocumentVersionApprovalList,
ActionDocumentVersionApprove,
ActionDocumentVersionReject,
ActionEmployeeDocumentVersionExportPDF,
).WithSID("document-version-approval").When(organizationCondition),
).WithDescription("Employee access - can sign documents, approve documents, and view internal content")

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)
}

View File

@@ -2095,6 +2095,7 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest
Classification: input.Classification,
DocumentType: input.DocumentType,
TrustCenterVisibility: trustCenterVisibility,
DefaultApproverIDs: input.DefaultApproverIds,
},
)
if err != nil {
@@ -2109,12 +2110,18 @@ func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequ
svc := r.ProboService(ctx, input.ID)
var defaultApproverIDs *[]gid.GID
if input.DefaultApproverIds != nil {
defaultApproverIDs = &input.DefaultApproverIds
}
document, err := svc.Documents.Update(
ctx,
probo.UpdateDocumentRequest{
DocumentID: input.ID,
Title: input.Title,
TrustCenterVisibility: input.TrustCenterVisibility,
DefaultApproverIDs: defaultApproverIDs,
},
)
if err != nil {
@@ -3928,3 +3935,18 @@ func (r *Resolver) ListMeasureDocumentsTool(ctx context.Context, req *mcp.CallTo
return nil, types.NewListMeasureDocumentsOutput(docPage), nil
}
func (r *Resolver) VoidDocumentVersionApprovalTool(ctx context.Context, req *mcp.CallToolRequest, input *types.VoidDocumentVersionApprovalInput) (*mcp.CallToolResult, types.VoidDocumentVersionApprovalOutput, error) {
r.MustAuthorize(ctx, input.DocumentVersionID, probo.ActionDocumentVersionVoidApproval)
svc := r.ProboService(ctx, input.DocumentVersionID)
_, documentVersion, err := svc.DocumentApprovals.VoidApproval(ctx, input.DocumentVersionID)
if err != nil {
panic(fmt.Errorf("cannot void document version approval: %w", err))
}
return nil, types.VoidDocumentVersionApprovalOutput{
DocumentVersion: types.NewDocumentVersion(documentVersion),
}, nil
}

View File

@@ -5159,6 +5159,7 @@ components:
type: string
enum:
- DRAFT
- PENDING_APPROVAL
- PUBLISHED
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentVersionStatus
@@ -5495,6 +5496,11 @@ components:
trust_center_visibility:
$ref: "#/components/schemas/TrustCenterVisibility"
description: Trust center visibility
default_approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Default approver profile IDs
AddDocumentOutput:
type: object
@@ -5522,6 +5528,11 @@ components:
trust_center_visibility:
$ref: "#/components/schemas/TrustCenterVisibility"
description: Trust center visibility
default_approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Default approver profile IDs
UpdateDocumentOutput:
type: object
@@ -5860,6 +5871,23 @@ components:
$ref: "#/components/schemas/GID"
description: Deleted document version signature ID
VoidDocumentVersionApprovalInput:
type: object
required:
- document_version_id
properties:
document_version_id:
$ref: "#/components/schemas/GID"
description: Document version ID
VoidDocumentVersionApprovalOutput:
type: object
required:
- document_version
properties:
document_version:
$ref: "#/components/schemas/DocumentVersion"
MeetingOrderField:
type: string
enum:
@@ -8474,6 +8502,15 @@ tools:
$ref: "#/components/schemas/CancelSignatureRequestInput"
outputSchema:
$ref: "#/components/schemas/CancelSignatureRequestOutput"
- name: voidDocumentVersionApproval
description: Void a pending document version approval request
hints:
readonly: false
destructive: true
inputSchema:
$ref: "#/components/schemas/VoidDocumentVersionApprovalInput"
outputSchema:
$ref: "#/components/schemas/VoidDocumentVersionApprovalOutput"
- name: listMeetings
description: List all meetings for the organization
hints:

View File

@@ -47,6 +47,36 @@ func Required() ValidatorFunc {
}
}
// NoDuplicates validates that a slice contains no duplicate elements.
func NoDuplicates() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
rv := reflect.ValueOf(actualValue)
if rv.Kind() != reflect.Slice {
return newValidationError(ErrorCodeInvalidFormat, "value must be a slice")
}
if !rv.Type().Elem().Comparable() {
return newValidationError(ErrorCodeInvalidFormat, "slice elements must be comparable")
}
seen := make(map[any]struct{}, rv.Len())
for i := range rv.Len() {
elem := rv.Index(i).Interface()
if _, ok := seen[elem]; ok {
return newValidationError(ErrorCodeInvalidFormat, "must not contain duplicates")
}
seen[elem] = struct{}{}
}
return nil
}
}
// NotEmpty validates that a field is not empty.
// Similar to Required, but can be used independently.
func NotEmpty() ValidatorFunc {

View File

@@ -264,3 +264,65 @@ func TestRequired(t *testing.T) {
}
})
}
func TestNoDuplicates(t *testing.T) {
t.Run("nil slice", func(t *testing.T) {
var slice []string
err := NoDuplicates()(slice)
if err != nil {
t.Errorf("expected no error for nil slice, got: %v", err)
}
})
t.Run("empty slice", func(t *testing.T) {
slice := []string{}
err := NoDuplicates()(slice)
if err != nil {
t.Errorf("expected no error for empty slice, got: %v", err)
}
})
t.Run("unique strings", func(t *testing.T) {
slice := []string{"a", "b", "c"}
err := NoDuplicates()(slice)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("duplicate strings", func(t *testing.T) {
slice := []string{"a", "b", "a"}
err := NoDuplicates()(slice)
if err == nil {
t.Fatal("expected validation error for duplicates")
} else if err.Code != ErrorCodeInvalidFormat {
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
}
})
t.Run("unique ints", func(t *testing.T) {
slice := []int{1, 2, 3}
err := NoDuplicates()(slice)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("duplicate ints", func(t *testing.T) {
slice := []int{1, 2, 1}
err := NoDuplicates()(slice)
if err == nil {
t.Fatal("expected validation error for duplicates")
}
})
t.Run("non-comparable elements", func(t *testing.T) {
slice := []map[string]string{{"a": "b"}}
err := NoDuplicates()(slice)
if err == nil {
t.Fatal("expected validation error for non-comparable elements")
} else if err.Code != ErrorCodeInvalidFormat {
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
}
})
}