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:
@@ -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)
|
||||
}
|
||||
|
||||
143
pkg/coredata/document_default_approver.go
Normal file
143
pkg/coredata/document_default_approver.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
17
pkg/coredata/migrations/20260408T120000Z.sql
Normal file
17
pkg/coredata/migrations/20260408T120000Z.sql
Normal 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';
|
||||
35
pkg/coredata/migrations/20260408T120004Z.sql
Normal file
35
pkg/coredata/migrations/20260408T120004Z.sql
Normal 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')
|
||||
);
|
||||
47
pkg/coredata/migrations/20260408T130000Z.sql
Normal file
47
pkg/coredata/migrations/20260408T130000Z.sql
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user