Add document approval workflow
Introduce a complete approval system for document publishing. Document versions can now require approval from selected reviewers before being published, with automatic publishing once all approvers have approved. - Add approval quorum and decision tables with backfill migration - Implement request approval, approve, and reject flows with electronic signature support for approve decisions - Add employee approvals page with dedicated tab and pending approvals view - Add changelog field to publish and request approval flows - Pre-select previous version's approvers in the publish dialog - Show quorum approvers in document list with 100 approver hard limit - Expose approval workflow through GraphQL, MCP, and CLI - Remove legacy default approvers feature entirely - Add comprehensive e2e test coverage for approval workflows Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -61,11 +61,46 @@ 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.Conn) (map[string]string, error) {
|
||||
q := `SELECT organization_id, status FROM documents WHERE id = $1 LIMIT 1;`
|
||||
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;
|
||||
`
|
||||
|
||||
var organizationID gid.GID
|
||||
var documentStatus DocumentStatus
|
||||
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID, &documentStatus); err != nil {
|
||||
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 {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
@@ -73,8 +108,10 @@ func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (m
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"organization_id": organizationID.String(),
|
||||
"document_status": documentStatus.String(),
|
||||
"organization_id": organizationID.String(),
|
||||
"document_status": documentStatus.String(),
|
||||
"version_status": latestVersionStatus,
|
||||
"last_quorum_status": lastQuorumStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -888,3 +925,56 @@ SELECT EXISTS (
|
||||
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
func (p *Document) GetViewerApprovalStateForLastVersion(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentID gid.GID,
|
||||
identityID gid.GID,
|
||||
) (DocumentVersionApprovalDecisionState, error) {
|
||||
q := `
|
||||
WITH viewer_decision AS (
|
||||
SELECT
|
||||
dvad.tenant_id,
|
||||
dvad.state,
|
||||
dv.version_number,
|
||||
dvaq.created_at AS quorum_created_at
|
||||
FROM documents d
|
||||
INNER JOIN document_versions dv ON dv.document_id = d.id
|
||||
INNER JOIN document_version_approval_quorums dvaq ON dvaq.version_id = dv.id
|
||||
INNER JOIN document_version_approval_decisions dvad ON dvad.quorum_id = dvaq.id
|
||||
INNER JOIN iam_membership_profiles p ON dvad.approver_id = p.id
|
||||
WHERE d.id = @document_id
|
||||
AND p.identity_id = @identity_id
|
||||
)
|
||||
SELECT state
|
||||
FROM viewer_decision
|
||||
WHERE %s
|
||||
ORDER BY version_number DESC, quorum_created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"document_id": documentID,
|
||||
"identity_id": identityID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot query document approval state: %w", err)
|
||||
}
|
||||
|
||||
state, err := pgx.CollectOneRow(rows, pgx.RowTo[DocumentVersionApprovalDecisionState])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DocumentVersionApprovalDecisionStatePending, nil
|
||||
}
|
||||
return "", fmt.Errorf("cannot collect approval state: %w", err)
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentApprover struct {
|
||||
DocumentID gid.GID `db:"document_id"`
|
||||
ApproverProfileID gid.GID `db:"approver_profile_id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
TenantID gid.TenantID `db:"tenant_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
DocumentApprovers []*DocumentApprover
|
||||
)
|
||||
|
||||
func (da DocumentApprover) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO
|
||||
document_approvers (
|
||||
document_id,
|
||||
approver_profile_id,
|
||||
organization_id,
|
||||
tenant_id,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
@document_id,
|
||||
@approver_profile_id,
|
||||
@organization_id,
|
||||
@tenant_id,
|
||||
@created_at
|
||||
)
|
||||
ON CONFLICT (document_id, approver_profile_id) DO NOTHING;
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"document_id": da.DocumentID,
|
||||
"approver_profile_id": da.ApproverProfileID,
|
||||
"organization_id": da.OrganizationID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"created_at": da.CreatedAt,
|
||||
}
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert document approver: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (da *DocumentApprovers) LoadByDocumentID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
document_id,
|
||||
approver_profile_id,
|
||||
organization_id,
|
||||
tenant_id,
|
||||
created_at
|
||||
FROM
|
||||
document_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 approvers: %w", err)
|
||||
}
|
||||
|
||||
approvers, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentApprover])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect document approvers: %w", err)
|
||||
}
|
||||
|
||||
*da = approvers
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (da *DocumentApprovers) DeleteByDocumentID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM
|
||||
document_approvers
|
||||
WHERE
|
||||
%s
|
||||
AND document_id = @document_id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"document_id": documentID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete document approvers: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (da *DocumentApprovers) ApproverProfileIDs() []gid.GID {
|
||||
ids := make([]gid.GID, len(*da))
|
||||
for i, a := range *da {
|
||||
ids[i] = a.ApproverProfileID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -16,6 +16,7 @@ package coredata
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/mail"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,7 @@ type (
|
||||
trustCenterVisibilities []TrustCenterVisibility
|
||||
published *bool
|
||||
userEmail *mail.Addr
|
||||
approverIdentityID *gid.GID
|
||||
documentTypes []DocumentType
|
||||
status []DocumentStatus
|
||||
}
|
||||
@@ -58,6 +60,11 @@ func (f *DocumentFilter) WithUserEmail(userEmail *mail.Addr) *DocumentFilter {
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *DocumentFilter) WithApproverIdentityID(identityID *gid.GID) *DocumentFilter {
|
||||
f.approverIdentityID = identityID
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *DocumentFilter) WithDocumentTypes(documentTypes []DocumentType) *DocumentFilter {
|
||||
f.documentTypes = documentTypes
|
||||
return f
|
||||
@@ -98,6 +105,7 @@ func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
|
||||
"trust_center_visibilities": visibilities,
|
||||
"published": f.published,
|
||||
"user_email": f.userEmail,
|
||||
"approver_identity_id": f.approverIdentityID,
|
||||
"document_types": documentTypes,
|
||||
"document_status": status,
|
||||
}
|
||||
@@ -142,6 +150,19 @@ func (f *DocumentFilter) SQLFragment() string {
|
||||
)
|
||||
END
|
||||
AND
|
||||
CASE
|
||||
WHEN @approver_identity_id::text IS NULL THEN TRUE
|
||||
ELSE EXISTS (
|
||||
SELECT 1
|
||||
FROM document_versions dv
|
||||
INNER JOIN document_version_approval_quorums dvaq ON dvaq.version_id = dv.id
|
||||
INNER JOIN document_version_approval_decisions dvad ON dvad.quorum_id = dvaq.id
|
||||
INNER JOIN iam_membership_profiles p ON dvad.approver_id = p.id
|
||||
WHERE dv.document_id = documents.id
|
||||
AND p.identity_id = @approver_identity_id::text
|
||||
)
|
||||
END
|
||||
AND
|
||||
CASE
|
||||
WHEN @document_types::document_type[] IS NOT NULL THEN
|
||||
document_type = ANY(@document_types::document_type[])
|
||||
|
||||
@@ -50,18 +50,44 @@ type (
|
||||
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
||||
func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (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
|
||||
dv.organization_id,
|
||||
d.status
|
||||
FROM document_versions dv
|
||||
INNER JOIN documents d ON d.id = dv.document_id
|
||||
WHERE dv.id = $1
|
||||
LIMIT 1;
|
||||
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;
|
||||
`
|
||||
|
||||
var organizationID gid.GID
|
||||
var documentStatus DocumentStatus
|
||||
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID, &documentStatus); err != nil {
|
||||
var (
|
||||
organizationID gid.GID
|
||||
documentStatus DocumentStatus
|
||||
documentVersionStatus DocumentVersionStatus
|
||||
lastQuorumStatus string
|
||||
)
|
||||
|
||||
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID, &documentStatus, &documentVersionStatus, &lastQuorumStatus); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
@@ -69,8 +95,10 @@ LIMIT 1;
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"organization_id": organizationID.String(),
|
||||
"document_status": documentStatus.String(),
|
||||
"organization_id": organizationID.String(),
|
||||
"document_status": documentStatus.String(),
|
||||
"version_status": documentVersionStatus.String(),
|
||||
"last_quorum_status": lastQuorumStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
458
pkg/coredata/document_version_approval_decision.go
Normal file
458
pkg/coredata/document_version_approval_decision.go
Normal file
@@ -0,0 +1,458 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalDecision struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
QuorumID gid.GID `db:"quorum_id"`
|
||||
ApproverID gid.GID `db:"approver_id"`
|
||||
State DocumentVersionApprovalDecisionState `db:"state"`
|
||||
Comment *string `db:"comment"`
|
||||
ElectronicSignatureID *gid.GID `db:"electronic_signature_id"`
|
||||
DecidedAt *time.Time `db:"decided_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
DocumentVersionApprovalDecisions []*DocumentVersionApprovalDecision
|
||||
)
|
||||
|
||||
func (d DocumentVersionApprovalDecision) CursorKey(orderBy DocumentVersionApprovalDecisionOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case DocumentVersionApprovalDecisionOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(d.ID, d.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecision) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||
q := `SELECT organization_id FROM document_version_approval_decisions WHERE id = $1 LIMIT 1;`
|
||||
|
||||
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
|
||||
}
|
||||
return nil, fmt.Errorf("cannot query document version approval decision authorization attributes: %w", err)
|
||||
}
|
||||
|
||||
return map[string]string{"organization_id": organizationID.String()}, nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecision) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
id gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
quorum_id,
|
||||
approver_id,
|
||||
state,
|
||||
comment,
|
||||
electronic_signature_id,
|
||||
decided_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
document_version_approval_decisions
|
||||
WHERE
|
||||
id = @id
|
||||
AND %s
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": id}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query document version approval decision: %w", err)
|
||||
}
|
||||
|
||||
decision, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersionApprovalDecision])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect document version approval decision: %w", err)
|
||||
}
|
||||
|
||||
*d = decision
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecision) LoadByQuorumIDAndApproverID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
quorumID gid.GID,
|
||||
approverID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
quorum_id,
|
||||
approver_id,
|
||||
state,
|
||||
comment,
|
||||
electronic_signature_id,
|
||||
decided_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
document_version_approval_decisions
|
||||
WHERE
|
||||
%s
|
||||
AND quorum_id = @quorum_id
|
||||
AND approver_id = @approver_id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"quorum_id": quorumID,
|
||||
"approver_id": approverID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query document version approval decision: %w", err)
|
||||
}
|
||||
|
||||
decision, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersionApprovalDecision])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect document version approval decision: %w", err)
|
||||
}
|
||||
|
||||
*d = decision
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecisions) CountApprovedByQuorumID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
quorumID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
document_version_approval_decisions
|
||||
WHERE
|
||||
%s
|
||||
AND quorum_id = @quorum_id
|
||||
AND state = 'APPROVED'
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"quorum_id": quorumID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot scan count: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecisions) LoadByQuorumID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
quorumID gid.GID,
|
||||
cursor *page.Cursor[DocumentVersionApprovalDecisionOrderField],
|
||||
filter *DocumentVersionApprovalDecisionFilter,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
quorum_id,
|
||||
approver_id,
|
||||
state,
|
||||
comment,
|
||||
electronic_signature_id,
|
||||
decided_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
document_version_approval_decisions
|
||||
WHERE
|
||||
%s
|
||||
AND quorum_id = @quorum_id
|
||||
AND %s
|
||||
AND %s
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"quorum_id": quorumID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, filter.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query document version approval decisions: %w", err)
|
||||
}
|
||||
|
||||
decisions, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersionApprovalDecision])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect document version approval decisions: %w", err)
|
||||
}
|
||||
|
||||
*d = decisions
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecision) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO document_version_approval_decisions (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
quorum_id,
|
||||
approver_id,
|
||||
state,
|
||||
comment,
|
||||
electronic_signature_id,
|
||||
decided_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@organization_id,
|
||||
@quorum_id,
|
||||
@approver_id,
|
||||
@state,
|
||||
@comment,
|
||||
@electronic_signature_id,
|
||||
@decided_at,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": d.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": d.OrganizationID,
|
||||
"quorum_id": d.QuorumID,
|
||||
"approver_id": d.ApproverID,
|
||||
"state": d.State,
|
||||
"comment": d.Comment,
|
||||
"electronic_signature_id": d.ElectronicSignatureID,
|
||||
"decided_at": d.DecidedAt,
|
||||
"created_at": d.CreatedAt,
|
||||
"updated_at": d.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
if pgErr.Code == "23505" {
|
||||
return ErrResourceAlreadyExists
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("cannot insert document version approval decision: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds DocumentVersionApprovalDecisions) BulkInsert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
if len(ds) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows := make([][]any, 0, len(ds))
|
||||
for _, d := range ds {
|
||||
rows = append(rows, []any{
|
||||
d.ID,
|
||||
scope.GetTenantID(),
|
||||
d.OrganizationID,
|
||||
d.QuorumID,
|
||||
d.ApproverID,
|
||||
d.State,
|
||||
d.Comment,
|
||||
d.ElectronicSignatureID,
|
||||
d.DecidedAt,
|
||||
d.CreatedAt,
|
||||
d.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
_, err := conn.CopyFrom(
|
||||
ctx,
|
||||
pgx.Identifier{"document_version_approval_decisions"},
|
||||
[]string{
|
||||
"id",
|
||||
"tenant_id",
|
||||
"organization_id",
|
||||
"quorum_id",
|
||||
"approver_id",
|
||||
"state",
|
||||
"comment",
|
||||
"electronic_signature_id",
|
||||
"decided_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
},
|
||||
pgx.CopyFromRows(rows),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecision) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE document_version_approval_decisions
|
||||
SET
|
||||
state = @state,
|
||||
comment = @comment,
|
||||
electronic_signature_id = @electronic_signature_id,
|
||||
decided_at = @decided_at,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": d.ID,
|
||||
"state": d.State,
|
||||
"comment": d.Comment,
|
||||
"electronic_signature_id": d.ElectronicSignatureID,
|
||||
"decided_at": d.DecidedAt,
|
||||
"updated_at": d.UpdatedAt,
|
||||
}
|
||||
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update document version approval decision: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecision) Delete(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM document_version_approval_decisions
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": d.ID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete document version approval decision: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecisions) CountByQuorumID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
quorumID gid.GID,
|
||||
filter *DocumentVersionApprovalDecisionFilter,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
document_version_approval_decisions
|
||||
WHERE
|
||||
%s
|
||||
AND quorum_id = @quorum_id
|
||||
AND %s
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"quorum_id": quorumID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, filter.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot scan count: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
51
pkg/coredata/document_version_approval_decision_filter.go
Normal file
51
pkg/coredata/document_version_approval_decision_filter.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// 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 (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalDecisionFilter struct {
|
||||
states DocumentVersionApprovalDecisionStates
|
||||
}
|
||||
)
|
||||
|
||||
func NewDocumentVersionApprovalDecisionFilter(states []DocumentVersionApprovalDecisionState) *DocumentVersionApprovalDecisionFilter {
|
||||
if len(states) == 0 {
|
||||
states = nil
|
||||
}
|
||||
return &DocumentVersionApprovalDecisionFilter{
|
||||
states: DocumentVersionApprovalDecisionStates(states),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *DocumentVersionApprovalDecisionFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
return pgx.StrictNamedArgs{
|
||||
"filter_states": f.states,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *DocumentVersionApprovalDecisionFilter) SQLFragment() string {
|
||||
return `
|
||||
(
|
||||
CASE
|
||||
WHEN @filter_states::document_version_approval_decision_state[] IS NOT NULL THEN
|
||||
state = ANY(@filter_states::document_version_approval_decision_state[])
|
||||
ELSE TRUE
|
||||
END
|
||||
)`
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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 "fmt"
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalDecisionOrderField string
|
||||
)
|
||||
|
||||
const (
|
||||
DocumentVersionApprovalDecisionOrderFieldCreatedAt DocumentVersionApprovalDecisionOrderField = "CREATED_AT"
|
||||
)
|
||||
|
||||
func (e DocumentVersionApprovalDecisionOrderField) Column() string {
|
||||
switch e {
|
||||
case DocumentVersionApprovalDecisionOrderFieldCreatedAt:
|
||||
return "created_at"
|
||||
}
|
||||
panic(fmt.Sprintf("unsupported order by: %s", e))
|
||||
}
|
||||
|
||||
func (e DocumentVersionApprovalDecisionOrderField) IsValid() bool {
|
||||
switch e {
|
||||
case DocumentVersionApprovalDecisionOrderFieldCreatedAt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e DocumentVersionApprovalDecisionOrderField) String() string { return string(e) }
|
||||
|
||||
func (e *DocumentVersionApprovalDecisionOrderField) UnmarshalText(text []byte) error {
|
||||
*e = DocumentVersionApprovalDecisionOrderField(text)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid DocumentVersionApprovalDecisionOrderField", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e DocumentVersionApprovalDecisionOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(e.String()), nil
|
||||
}
|
||||
100
pkg/coredata/document_version_approval_decision_state.go
Normal file
100
pkg/coredata/document_version_approval_decision_state.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// 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 (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalDecisionState string
|
||||
DocumentVersionApprovalDecisionStates []DocumentVersionApprovalDecisionState
|
||||
)
|
||||
|
||||
const (
|
||||
DocumentVersionApprovalDecisionStatePending DocumentVersionApprovalDecisionState = "PENDING"
|
||||
DocumentVersionApprovalDecisionStateApproved DocumentVersionApprovalDecisionState = "APPROVED"
|
||||
DocumentVersionApprovalDecisionStateRejected DocumentVersionApprovalDecisionState = "REJECTED"
|
||||
)
|
||||
|
||||
func (s DocumentVersionApprovalDecisionState) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
|
||||
func (s *DocumentVersionApprovalDecisionState) UnmarshalText(data []byte) error {
|
||||
val := string(data)
|
||||
|
||||
switch val {
|
||||
case DocumentVersionApprovalDecisionStatePending.String():
|
||||
*s = DocumentVersionApprovalDecisionStatePending
|
||||
case DocumentVersionApprovalDecisionStateApproved.String():
|
||||
*s = DocumentVersionApprovalDecisionStateApproved
|
||||
case DocumentVersionApprovalDecisionStateRejected.String():
|
||||
*s = DocumentVersionApprovalDecisionStateRejected
|
||||
default:
|
||||
return fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", val)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s DocumentVersionApprovalDecisionState) String() string {
|
||||
var val string
|
||||
|
||||
switch s {
|
||||
case DocumentVersionApprovalDecisionStatePending:
|
||||
val = "PENDING"
|
||||
case DocumentVersionApprovalDecisionStateApproved:
|
||||
val = "APPROVED"
|
||||
case DocumentVersionApprovalDecisionStateRejected:
|
||||
val = "REJECTED"
|
||||
default:
|
||||
panic(fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", string(s)))
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func (s *DocumentVersionApprovalDecisionState) Scan(value any) error {
|
||||
val, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid scan source for DocumentVersionApprovalDecisionState, expected string got %T", value)
|
||||
}
|
||||
|
||||
return s.UnmarshalText([]byte(val))
|
||||
}
|
||||
|
||||
func (s DocumentVersionApprovalDecisionState) Value() (driver.Value, error) {
|
||||
return s.String(), nil
|
||||
}
|
||||
|
||||
func (states DocumentVersionApprovalDecisionStates) Value() (driver.Value, error) {
|
||||
if len(states) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString("{")
|
||||
for i, state := range states {
|
||||
if i > 0 {
|
||||
result.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&result, "%q", state.String())
|
||||
}
|
||||
result.WriteString("}")
|
||||
return result.String(), nil
|
||||
}
|
||||
336
pkg/coredata/document_version_approval_quorum.go
Normal file
336
pkg/coredata/document_version_approval_quorum.go
Normal file
@@ -0,0 +1,336 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalQuorum struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
VersionID gid.GID `db:"version_id"`
|
||||
Status DocumentVersionApprovalQuorumStatus `db:"status"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
DocumentVersionApprovalQuorums []*DocumentVersionApprovalQuorum
|
||||
)
|
||||
|
||||
func (q DocumentVersionApprovalQuorum) CursorKey(orderBy DocumentVersionApprovalQuorumOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case DocumentVersionApprovalQuorumOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(q.ID, q.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorum) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||
query := `SELECT organization_id FROM document_version_approval_quorums WHERE id = $1 LIMIT 1;`
|
||||
|
||||
var organizationID gid.GID
|
||||
if err := conn.QueryRow(ctx, query, q.ID).Scan(&organizationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("cannot query approval quorum authorization attributes: %w", err)
|
||||
}
|
||||
|
||||
return map[string]string{"organization_id": organizationID.String()}, nil
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorum) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
id gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
version_id,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
document_version_approval_quorums
|
||||
WHERE
|
||||
id = @id
|
||||
AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": id}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query approval quorum: %w", err)
|
||||
}
|
||||
|
||||
quorum, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersionApprovalQuorum])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect approval quorum: %w", err)
|
||||
}
|
||||
|
||||
*q = quorum
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorum) LoadLastByDocumentVersionID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentVersionID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
version_id,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
document_version_approval_quorums
|
||||
WHERE
|
||||
%s
|
||||
AND version_id = @version_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"version_id": documentVersionID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query last approval quorum: %w", err)
|
||||
}
|
||||
|
||||
quorum, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersionApprovalQuorum])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect last approval quorum: %w", err)
|
||||
}
|
||||
|
||||
*q = quorum
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorums) LoadAllByDocumentVersionID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentVersionID gid.GID,
|
||||
cursor *page.Cursor[DocumentVersionApprovalQuorumOrderField],
|
||||
) error {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
version_id,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
document_version_approval_quorums
|
||||
WHERE
|
||||
%s
|
||||
AND version_id = @version_id
|
||||
AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"version_id": documentVersionID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query approval quorums: %w", err)
|
||||
}
|
||||
|
||||
quorums, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersionApprovalQuorum])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect approval quorums: %w", err)
|
||||
}
|
||||
|
||||
*q = quorums
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorums) CountByDocumentVersionID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentVersionID gid.GID,
|
||||
) (int, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
document_version_approval_quorums
|
||||
WHERE
|
||||
%s
|
||||
AND version_id = @version_id
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"version_id": documentVersionID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, query, args)
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot scan count: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorum) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
query := `
|
||||
INSERT INTO document_version_approval_quorums (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
version_id,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@organization_id,
|
||||
@version_id,
|
||||
@status,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": q.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": q.OrganizationID,
|
||||
"version_id": q.VersionID,
|
||||
"status": q.Status,
|
||||
"created_at": q.CreatedAt,
|
||||
"updated_at": q.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
if pgErr.Code == "23505" {
|
||||
return ErrResourceAlreadyExists
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("cannot insert approval quorum: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorum) Delete(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
query := `
|
||||
DELETE FROM document_version_approval_quorums
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": q.ID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete approval quorum: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorum) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
query := `
|
||||
UPDATE document_version_approval_quorums
|
||||
SET
|
||||
status = @status,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": q.ID,
|
||||
"status": q.Status,
|
||||
"updated_at": q.UpdatedAt,
|
||||
}
|
||||
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update approval quorum: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
55
pkg/coredata/document_version_approval_quorum_order_field.go
Normal file
55
pkg/coredata/document_version_approval_quorum_order_field.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// 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 "fmt"
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalQuorumOrderField string
|
||||
)
|
||||
|
||||
const (
|
||||
DocumentVersionApprovalQuorumOrderFieldCreatedAt DocumentVersionApprovalQuorumOrderField = "CREATED_AT"
|
||||
)
|
||||
|
||||
func (e DocumentVersionApprovalQuorumOrderField) Column() string {
|
||||
switch e {
|
||||
case DocumentVersionApprovalQuorumOrderFieldCreatedAt:
|
||||
return "created_at"
|
||||
}
|
||||
panic(fmt.Sprintf("unsupported order by: %s", e))
|
||||
}
|
||||
|
||||
func (e DocumentVersionApprovalQuorumOrderField) IsValid() bool {
|
||||
switch e {
|
||||
case DocumentVersionApprovalQuorumOrderFieldCreatedAt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e DocumentVersionApprovalQuorumOrderField) String() string { return string(e) }
|
||||
|
||||
func (e *DocumentVersionApprovalQuorumOrderField) UnmarshalText(text []byte) error {
|
||||
*e = DocumentVersionApprovalQuorumOrderField(text)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid DocumentVersionApprovalQuorumOrderField", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e DocumentVersionApprovalQuorumOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(e.String()), nil
|
||||
}
|
||||
79
pkg/coredata/document_version_approval_quorum_status.go
Normal file
79
pkg/coredata/document_version_approval_quorum_status.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// 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 (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type DocumentVersionApprovalQuorumStatus string
|
||||
|
||||
const (
|
||||
DocumentVersionApprovalQuorumStatusPending DocumentVersionApprovalQuorumStatus = "PENDING"
|
||||
DocumentVersionApprovalQuorumStatusApproved DocumentVersionApprovalQuorumStatus = "APPROVED"
|
||||
DocumentVersionApprovalQuorumStatusRejected DocumentVersionApprovalQuorumStatus = "REJECTED"
|
||||
)
|
||||
|
||||
func (s DocumentVersionApprovalQuorumStatus) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
|
||||
func (s *DocumentVersionApprovalQuorumStatus) UnmarshalText(data []byte) error {
|
||||
val := string(data)
|
||||
|
||||
switch val {
|
||||
case DocumentVersionApprovalQuorumStatusPending.String():
|
||||
*s = DocumentVersionApprovalQuorumStatusPending
|
||||
case DocumentVersionApprovalQuorumStatusApproved.String():
|
||||
*s = DocumentVersionApprovalQuorumStatusApproved
|
||||
case DocumentVersionApprovalQuorumStatusRejected.String():
|
||||
*s = DocumentVersionApprovalQuorumStatusRejected
|
||||
default:
|
||||
return fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", val)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s DocumentVersionApprovalQuorumStatus) String() string {
|
||||
var val string
|
||||
|
||||
switch s {
|
||||
case DocumentVersionApprovalQuorumStatusPending:
|
||||
val = "PENDING"
|
||||
case DocumentVersionApprovalQuorumStatusApproved:
|
||||
val = "APPROVED"
|
||||
case DocumentVersionApprovalQuorumStatusRejected:
|
||||
val = "REJECTED"
|
||||
default:
|
||||
panic(fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", string(s)))
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func (s *DocumentVersionApprovalQuorumStatus) Scan(value any) error {
|
||||
val, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid scan source for DocumentVersionApprovalQuorumStatus, expected string got %T", value)
|
||||
}
|
||||
|
||||
return s.UnmarshalText([]byte(val))
|
||||
}
|
||||
|
||||
func (s DocumentVersionApprovalQuorumStatus) Value() (driver.Value, error) {
|
||||
return s.String(), nil
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionApprover struct {
|
||||
DocumentVersionID gid.GID `db:"document_version_id"`
|
||||
ApproverProfileID gid.GID `db:"approver_profile_id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
TenantID gid.TenantID `db:"tenant_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
DocumentVersionApprovers []*DocumentVersionApprover
|
||||
)
|
||||
|
||||
func (dva DocumentVersionApprover) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO
|
||||
document_version_approvers (
|
||||
document_version_id,
|
||||
approver_profile_id,
|
||||
organization_id,
|
||||
tenant_id,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
@document_version_id,
|
||||
@approver_profile_id,
|
||||
@organization_id,
|
||||
@tenant_id,
|
||||
@created_at
|
||||
)
|
||||
ON CONFLICT (document_version_id, approver_profile_id) DO NOTHING;
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"document_version_id": dva.DocumentVersionID,
|
||||
"approver_profile_id": dva.ApproverProfileID,
|
||||
"organization_id": dva.OrganizationID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"created_at": dva.CreatedAt,
|
||||
}
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert document version approver: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dva *DocumentVersionApprovers) LoadByDocumentVersionID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentVersionID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
document_version_id,
|
||||
approver_profile_id,
|
||||
organization_id,
|
||||
tenant_id,
|
||||
created_at
|
||||
FROM
|
||||
document_version_approvers
|
||||
WHERE
|
||||
%s
|
||||
AND document_version_id = @document_version_id
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"document_version_id": documentVersionID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query document version approvers: %w", err)
|
||||
}
|
||||
|
||||
approvers, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersionApprover])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect document version approvers: %w", err)
|
||||
}
|
||||
|
||||
*dva = approvers
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dva *DocumentVersionApprovers) DeleteByDocumentVersionID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentVersionID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM
|
||||
document_version_approvers
|
||||
WHERE
|
||||
%s
|
||||
AND document_version_id = @document_version_id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"document_version_id": documentVersionID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete document version approvers: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dva *DocumentVersionApprovers) ApproverProfileIDs() []gid.GID {
|
||||
ids := make([]gid.GID, len(*dva))
|
||||
for i, a := range *dva {
|
||||
ids[i] = a.ApproverProfileID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -16,12 +16,15 @@ package coredata
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/mail"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionFilter struct {
|
||||
userEmail *mail.Addr
|
||||
statuses []DocumentVersionStatus
|
||||
userEmail *mail.Addr
|
||||
approverIdentityID *gid.GID
|
||||
}
|
||||
)
|
||||
|
||||
@@ -29,29 +32,65 @@ func NewDocumentVersionFilter() *DocumentVersionFilter {
|
||||
return &DocumentVersionFilter{}
|
||||
}
|
||||
|
||||
func (f *DocumentVersionFilter) WithStatuses(statuses ...DocumentVersionStatus) *DocumentVersionFilter {
|
||||
f.statuses = statuses
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *DocumentVersionFilter) WithUserEmail(userEmail *mail.Addr) *DocumentVersionFilter {
|
||||
f.userEmail = userEmail
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *DocumentVersionFilter) WithApproverIdentityID(identityID *gid.GID) *DocumentVersionFilter {
|
||||
f.approverIdentityID = identityID
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *DocumentVersionFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
var filterStatuses []string
|
||||
for _, s := range f.statuses {
|
||||
filterStatuses = append(filterStatuses, s.String())
|
||||
}
|
||||
|
||||
return pgx.StrictNamedArgs{
|
||||
"user_email": f.userEmail,
|
||||
"filter_statuses": filterStatuses,
|
||||
"user_email": f.userEmail,
|
||||
"approver_identity_id": f.approverIdentityID,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *DocumentVersionFilter) SQLFragment() string {
|
||||
return `
|
||||
(
|
||||
@user_email::text IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM document_version_signatures dvs
|
||||
INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id
|
||||
INNER JOIN identities i ON p.identity_id = i.id
|
||||
WHERE dvs.document_version_id = document_versions.id
|
||||
AND i.email_address = @user_email::CITEXT
|
||||
AND dvs.state IN ('REQUESTED', 'SIGNED')
|
||||
(
|
||||
@filter_statuses::text[] IS NULL
|
||||
OR document_versions.status::text = ANY(@filter_statuses::text[])
|
||||
)
|
||||
AND
|
||||
(
|
||||
@user_email::text IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM document_version_signatures dvs
|
||||
INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id
|
||||
INNER JOIN identities i ON p.identity_id = i.id
|
||||
WHERE dvs.document_version_id = document_versions.id
|
||||
AND i.email_address = @user_email::CITEXT
|
||||
AND dvs.state IN ('REQUESTED', 'SIGNED')
|
||||
)
|
||||
)
|
||||
AND
|
||||
(
|
||||
@approver_identity_id::text IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM document_version_approval_quorums dvaq
|
||||
INNER JOIN document_version_approval_decisions dvad ON dvad.quorum_id = dvaq.id
|
||||
INNER JOIN iam_membership_profiles p ON dvad.approver_id = p.id
|
||||
WHERE dvaq.version_id = document_versions.id
|
||||
AND p.identity_id = @approver_identity_id::text
|
||||
)
|
||||
)
|
||||
)`
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type ElectronicSignature struct {
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
Status ElectronicSignatureStatus `db:"status"`
|
||||
DocumentType ElectronicSignatureDocumentType `db:"document_type"`
|
||||
DocumentName *string `db:"document_name"`
|
||||
FileID gid.GID `db:"file_id"`
|
||||
SignerEmail string `db:"signer_email"`
|
||||
ConsentText string `db:"consent_text"`
|
||||
@@ -83,11 +84,11 @@ func (es *ElectronicSignature) Insert(
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO electronic_signatures (
|
||||
id, tenant_id, organization_id, status, document_type, file_id,
|
||||
id, tenant_id, organization_id, status, document_type, document_name, file_id,
|
||||
signer_email, consent_text, seal_version, attempt_count, max_attempts,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
@id, @tenant_id, @organization_id, @status, @document_type, @file_id,
|
||||
@id, @tenant_id, @organization_id, @status, @document_type, @document_name, @file_id,
|
||||
@signer_email, @consent_text, @seal_version, @attempt_count, @max_attempts,
|
||||
@created_at, @updated_at
|
||||
)
|
||||
@@ -98,6 +99,7 @@ INSERT INTO electronic_signatures (
|
||||
"organization_id": es.OrganizationID,
|
||||
"status": es.Status,
|
||||
"document_type": es.DocumentType,
|
||||
"document_name": es.DocumentName,
|
||||
"file_id": es.FileID,
|
||||
"signer_email": es.SignerEmail,
|
||||
"consent_text": es.ConsentText,
|
||||
@@ -184,7 +186,7 @@ func (es *ElectronicSignature) LoadByID(
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id, tenant_id, organization_id, status, document_type, file_id,
|
||||
id, tenant_id, organization_id, status, document_type, document_name, file_id,
|
||||
signer_email, consent_text, signer_full_name, signer_ip_address,
|
||||
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
||||
certificate_file_id, certificate_processing_started_at,
|
||||
@@ -222,7 +224,7 @@ func (es *ElectronicSignature) LoadNextAcceptedForUpdateSkipLocked(
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id, tenant_id, organization_id, status, document_type, file_id,
|
||||
id, tenant_id, organization_id, status, document_type, document_name, file_id,
|
||||
signer_email, consent_text, signer_full_name, signer_ip_address,
|
||||
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
||||
certificate_file_id, certificate_processing_started_at,
|
||||
@@ -258,7 +260,7 @@ func (es *ElectronicSignature) LoadNextCompletedWithoutCertificateForUpdate(
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id, tenant_id, organization_id, status, document_type, file_id,
|
||||
id, tenant_id, organization_id, status, document_type, document_name, file_id,
|
||||
signer_email, consent_text, signer_full_name, signer_ip_address,
|
||||
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
||||
certificate_file_id, certificate_processing_started_at,
|
||||
|
||||
@@ -31,6 +31,14 @@ const (
|
||||
ElectronicSignatureDocumentTypeSLA ElectronicSignatureDocumentType = "SLA"
|
||||
ElectronicSignatureDocumentTypeTOS ElectronicSignatureDocumentType = "TOS"
|
||||
ElectronicSignatureDocumentTypePrivacyPolicy ElectronicSignatureDocumentType = "PRIVACY_POLICY"
|
||||
ElectronicSignatureDocumentTypeGovernance ElectronicSignatureDocumentType = "GOVERNANCE"
|
||||
ElectronicSignatureDocumentTypePolicy ElectronicSignatureDocumentType = "POLICY"
|
||||
ElectronicSignatureDocumentTypeProcedure ElectronicSignatureDocumentType = "PROCEDURE"
|
||||
ElectronicSignatureDocumentTypePlan ElectronicSignatureDocumentType = "PLAN"
|
||||
ElectronicSignatureDocumentTypeRegister ElectronicSignatureDocumentType = "REGISTER"
|
||||
ElectronicSignatureDocumentTypeRecord ElectronicSignatureDocumentType = "RECORD"
|
||||
ElectronicSignatureDocumentTypeReport ElectronicSignatureDocumentType = "REPORT"
|
||||
ElectronicSignatureDocumentTypeTemplate ElectronicSignatureDocumentType = "TEMPLATE"
|
||||
ElectronicSignatureDocumentTypeOther ElectronicSignatureDocumentType = "OTHER"
|
||||
|
||||
ESignProcessConsentText = "By typing my full name and clicking Accept, I consent to sign this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature."
|
||||
@@ -45,6 +53,14 @@ func ElectronicSignatureDocumentTypes() []ElectronicSignatureDocumentType {
|
||||
ElectronicSignatureDocumentTypeSLA,
|
||||
ElectronicSignatureDocumentTypeTOS,
|
||||
ElectronicSignatureDocumentTypePrivacyPolicy,
|
||||
ElectronicSignatureDocumentTypeGovernance,
|
||||
ElectronicSignatureDocumentTypePolicy,
|
||||
ElectronicSignatureDocumentTypeProcedure,
|
||||
ElectronicSignatureDocumentTypePlan,
|
||||
ElectronicSignatureDocumentTypeRegister,
|
||||
ElectronicSignatureDocumentTypeRecord,
|
||||
ElectronicSignatureDocumentTypeReport,
|
||||
ElectronicSignatureDocumentTypeTemplate,
|
||||
ElectronicSignatureDocumentTypeOther,
|
||||
}
|
||||
}
|
||||
@@ -71,10 +87,26 @@ func (dt *ElectronicSignatureDocumentType) UnmarshalText(data []byte) error {
|
||||
*dt = ElectronicSignatureDocumentTypeTOS
|
||||
case ElectronicSignatureDocumentTypePrivacyPolicy.String():
|
||||
*dt = ElectronicSignatureDocumentTypePrivacyPolicy
|
||||
case ElectronicSignatureDocumentTypeGovernance.String():
|
||||
*dt = ElectronicSignatureDocumentTypeGovernance
|
||||
case ElectronicSignatureDocumentTypePolicy.String():
|
||||
*dt = ElectronicSignatureDocumentTypePolicy
|
||||
case ElectronicSignatureDocumentTypeProcedure.String():
|
||||
*dt = ElectronicSignatureDocumentTypeProcedure
|
||||
case ElectronicSignatureDocumentTypePlan.String():
|
||||
*dt = ElectronicSignatureDocumentTypePlan
|
||||
case ElectronicSignatureDocumentTypeRegister.String():
|
||||
*dt = ElectronicSignatureDocumentTypeRegister
|
||||
case ElectronicSignatureDocumentTypeRecord.String():
|
||||
*dt = ElectronicSignatureDocumentTypeRecord
|
||||
case ElectronicSignatureDocumentTypeReport.String():
|
||||
*dt = ElectronicSignatureDocumentTypeReport
|
||||
case ElectronicSignatureDocumentTypeTemplate.String():
|
||||
*dt = ElectronicSignatureDocumentTypeTemplate
|
||||
case ElectronicSignatureDocumentTypeOther.String():
|
||||
*dt = ElectronicSignatureDocumentTypeOther
|
||||
default:
|
||||
return fmt.Errorf("invalid ElectronicSignatureDocumentType value: %q", val)
|
||||
return fmt.Errorf("cannot unmarshal ElectronicSignatureDocumentType: invalid value %q", val)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -87,7 +119,7 @@ func (dt ElectronicSignatureDocumentType) String() string {
|
||||
func (dt *ElectronicSignatureDocumentType) Scan(value any) error {
|
||||
val, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid scan source for ElectronicSignatureDocumentType, expected string got %T", value)
|
||||
return fmt.Errorf("cannot scan ElectronicSignatureDocumentType: expected string, got %T", value)
|
||||
}
|
||||
|
||||
return dt.UnmarshalText([]byte(val))
|
||||
@@ -113,6 +145,22 @@ func (dt ElectronicSignatureDocumentType) DisplayName() string {
|
||||
return "Terms of Service"
|
||||
case ElectronicSignatureDocumentTypePrivacyPolicy:
|
||||
return "Privacy Policy"
|
||||
case ElectronicSignatureDocumentTypeGovernance:
|
||||
return "Governance Document"
|
||||
case ElectronicSignatureDocumentTypePolicy:
|
||||
return "Policy"
|
||||
case ElectronicSignatureDocumentTypeProcedure:
|
||||
return "Procedure"
|
||||
case ElectronicSignatureDocumentTypePlan:
|
||||
return "Plan"
|
||||
case ElectronicSignatureDocumentTypeRegister:
|
||||
return "Register"
|
||||
case ElectronicSignatureDocumentTypeRecord:
|
||||
return "Record"
|
||||
case ElectronicSignatureDocumentTypeReport:
|
||||
return "Report"
|
||||
case ElectronicSignatureDocumentTypeTemplate:
|
||||
return "Template"
|
||||
default:
|
||||
return string(dt)
|
||||
}
|
||||
@@ -135,11 +183,50 @@ func (dt ElectronicSignatureDocumentType) ConsentText() (string, error) {
|
||||
docAgreement = "I agree to these Terms of Service."
|
||||
case ElectronicSignatureDocumentTypePrivacyPolicy:
|
||||
docAgreement = "I agree to this Privacy Policy."
|
||||
case ElectronicSignatureDocumentTypeGovernance:
|
||||
docAgreement = "I acknowledge and agree to this Governance Document."
|
||||
case ElectronicSignatureDocumentTypePolicy:
|
||||
docAgreement = "I acknowledge and agree to this Policy."
|
||||
case ElectronicSignatureDocumentTypeProcedure:
|
||||
docAgreement = "I acknowledge and agree to this Procedure."
|
||||
case ElectronicSignatureDocumentTypePlan:
|
||||
docAgreement = "I acknowledge and agree to this Plan."
|
||||
case ElectronicSignatureDocumentTypeRegister:
|
||||
docAgreement = "I acknowledge and agree to this Register."
|
||||
case ElectronicSignatureDocumentTypeRecord:
|
||||
docAgreement = "I acknowledge and agree to this Record."
|
||||
case ElectronicSignatureDocumentTypeReport:
|
||||
docAgreement = "I acknowledge and agree to this Report."
|
||||
case ElectronicSignatureDocumentTypeTemplate:
|
||||
docAgreement = "I acknowledge and agree to this Template."
|
||||
case ElectronicSignatureDocumentTypeOther:
|
||||
return "", fmt.Errorf("document type OTHER requires explicit consent text")
|
||||
return "", fmt.Errorf("cannot get consent text: document type OTHER requires explicit consent text")
|
||||
default:
|
||||
return "", fmt.Errorf("unknown document type %q", dt)
|
||||
return "", fmt.Errorf("cannot get consent text: unknown document type %q", dt)
|
||||
}
|
||||
|
||||
return docAgreement + " " + ESignProcessConsentText, nil
|
||||
}
|
||||
|
||||
func ElectronicSignatureDocumentTypeFromDocumentType(dt DocumentType) ElectronicSignatureDocumentType {
|
||||
switch dt {
|
||||
case DocumentTypeGovernance:
|
||||
return ElectronicSignatureDocumentTypeGovernance
|
||||
case DocumentTypePolicy:
|
||||
return ElectronicSignatureDocumentTypePolicy
|
||||
case DocumentTypeProcedure:
|
||||
return ElectronicSignatureDocumentTypeProcedure
|
||||
case DocumentTypePlan:
|
||||
return ElectronicSignatureDocumentTypePlan
|
||||
case DocumentTypeRegister:
|
||||
return ElectronicSignatureDocumentTypeRegister
|
||||
case DocumentTypeRecord:
|
||||
return ElectronicSignatureDocumentTypeRecord
|
||||
case DocumentTypeReport:
|
||||
return ElectronicSignatureDocumentTypeReport
|
||||
case DocumentTypeTemplate:
|
||||
return ElectronicSignatureDocumentTypeTemplate
|
||||
default:
|
||||
return ElectronicSignatureDocumentTypeOther
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,8 @@ const (
|
||||
MailingListUpdateEntityType uint16 = 66
|
||||
FindingEntityType uint16 = 67
|
||||
AuditLogEntryEntityType uint16 = 68
|
||||
DocumentVersionApprovalQuorumEntityType uint16 = 69
|
||||
DocumentVersionApprovalDecisionEntityType uint16 = 70
|
||||
)
|
||||
|
||||
func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
@@ -226,6 +228,10 @@ func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
return &MailingListUpdate{ID: id}, true
|
||||
case AuditLogEntryEntityType:
|
||||
return &AuditLogEntry{ID: id}, true
|
||||
case DocumentVersionApprovalDecisionEntityType:
|
||||
return &DocumentVersionApprovalDecision{ID: id}, true
|
||||
case DocumentVersionApprovalQuorumEntityType:
|
||||
return &DocumentVersionApprovalQuorum{ID: id}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -559,150 +559,6 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MembershipProfiles) LoadByDocumentID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentID gid.GID,
|
||||
cursor *page.Cursor[MembershipProfileOrderField],
|
||||
) error {
|
||||
q := `
|
||||
WITH profiles AS (
|
||||
SELECT
|
||||
mp.id,
|
||||
mp.identity_id,
|
||||
mp.organization_id,
|
||||
mp.source,
|
||||
mp.state,
|
||||
mp.full_name,
|
||||
mp.kind,
|
||||
mp.additional_email_addresses,
|
||||
mp.position,
|
||||
mp.contract_start_date,
|
||||
mp.contract_end_date,
|
||||
mp.user_name,
|
||||
mp.external_id,
|
||||
mp.nickname,
|
||||
mp.locale,
|
||||
mp.timezone,
|
||||
mp.profile_url,
|
||||
mp.preferred_language,
|
||||
mp.given_name,
|
||||
mp.family_name,
|
||||
mp.formatted_name,
|
||||
mp.middle_name,
|
||||
mp.honorific_prefix,
|
||||
mp.honorific_suffix,
|
||||
mp.employee_number,
|
||||
mp.department,
|
||||
mp.cost_center,
|
||||
mp.enterprise_organization,
|
||||
mp.division,
|
||||
mp.manager_value,
|
||||
mp.created_at,
|
||||
mp.updated_at
|
||||
FROM
|
||||
iam_membership_profiles mp
|
||||
WHERE
|
||||
mp.%s
|
||||
AND mp.id IN (
|
||||
SELECT approver_profile_id
|
||||
FROM document_approvers
|
||||
WHERE document_id = @document_id
|
||||
)
|
||||
AND %s
|
||||
)
|
||||
SELECT
|
||||
p.id,
|
||||
p.identity_id,
|
||||
p.organization_id,
|
||||
i.email_address,
|
||||
p.source,
|
||||
p.state,
|
||||
p.full_name,
|
||||
p.kind,
|
||||
p.additional_email_addresses,
|
||||
p.position,
|
||||
p.contract_start_date,
|
||||
p.contract_end_date,
|
||||
'' AS organization_name,
|
||||
p.user_name,
|
||||
p.external_id,
|
||||
p.nickname,
|
||||
p.locale,
|
||||
p.timezone,
|
||||
p.profile_url,
|
||||
p.preferred_language,
|
||||
p.given_name,
|
||||
p.family_name,
|
||||
p.formatted_name,
|
||||
p.middle_name,
|
||||
p.honorific_prefix,
|
||||
p.honorific_suffix,
|
||||
p.employee_number,
|
||||
p.department,
|
||||
p.cost_center,
|
||||
p.enterprise_organization,
|
||||
p.division,
|
||||
p.manager_value,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM profiles p
|
||||
INNER JOIN identities i ON i.id = p.identity_id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"document_id": documentID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query document approver profiles: %w", err)
|
||||
}
|
||||
|
||||
profiles, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MembershipProfile])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect document approver profiles: %w", err)
|
||||
}
|
||||
|
||||
*p = profiles
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MembershipProfiles) CountByDocumentID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM
|
||||
iam_membership_profiles mp
|
||||
INNER JOIN document_approvers da ON mp.id = da.approver_profile_id
|
||||
WHERE
|
||||
mp.%s
|
||||
AND da.document_id = @document_id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"document_id": documentID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
var count int
|
||||
err := conn.QueryRow(ctx, q, args).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot query document approver profiles count: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (p *MembershipProfiles) LoadByDocumentVersionID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
@@ -711,7 +567,19 @@ func (p *MembershipProfiles) LoadByDocumentVersionID(
|
||||
cursor *page.Cursor[MembershipProfileOrderField],
|
||||
) error {
|
||||
q := `
|
||||
WITH profiles AS (
|
||||
WITH latest_quorum AS (
|
||||
SELECT id
|
||||
FROM document_version_approval_quorums
|
||||
WHERE version_id = @version_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
version_approvers AS (
|
||||
SELECT d.approver_id
|
||||
FROM document_version_approval_decisions d
|
||||
WHERE d.quorum_id = (SELECT id FROM latest_quorum)
|
||||
),
|
||||
profiles AS (
|
||||
SELECT
|
||||
mp.id,
|
||||
mp.identity_id,
|
||||
@@ -747,13 +615,9 @@ WITH profiles AS (
|
||||
mp.updated_at
|
||||
FROM
|
||||
iam_membership_profiles mp
|
||||
INNER JOIN version_approvers va ON va.approver_id = mp.id
|
||||
WHERE
|
||||
mp.%s
|
||||
AND mp.id IN (
|
||||
SELECT approver_profile_id
|
||||
FROM document_version_approvers
|
||||
WHERE document_version_id = @document_version_id
|
||||
)
|
||||
AND %s
|
||||
)
|
||||
SELECT
|
||||
@@ -797,7 +661,7 @@ INNER JOIN identities i ON i.id = p.identity_id
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"document_version_id": documentVersionID}
|
||||
args := pgx.NamedArgs{"version_id": documentVersionID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
@@ -823,19 +687,26 @@ func (p *MembershipProfiles) CountByDocumentVersionID(
|
||||
documentVersionID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
WITH latest_quorum AS (
|
||||
SELECT id
|
||||
FROM document_version_approval_quorums
|
||||
WHERE version_id = @version_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)
|
||||
COUNT(DISTINCT mp.id)
|
||||
FROM
|
||||
iam_membership_profiles mp
|
||||
INNER JOIN document_version_approvers dva ON mp.id = dva.approver_profile_id
|
||||
INNER JOIN document_version_approval_decisions dvad ON mp.id = dvad.approver_id
|
||||
INNER JOIN latest_quorum lq ON lq.id = dvad.quorum_id
|
||||
WHERE
|
||||
mp.%s
|
||||
AND dva.document_version_id = @document_version_id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"document_version_id": documentVersionID}
|
||||
args := pgx.StrictNamedArgs{"version_id": documentVersionID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
var count int
|
||||
|
||||
95
pkg/coredata/migrations/20260319T160000Z.sql
Normal file
95
pkg/coredata/migrations/20260319T160000Z.sql
Normal file
@@ -0,0 +1,95 @@
|
||||
-- Create approval quorum status enum
|
||||
CREATE TYPE document_version_approval_quorum_status AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
|
||||
-- Create approval decision state enum
|
||||
CREATE TYPE document_version_approval_decision_state AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
|
||||
-- Create approval quorum table: groups approval decisions for a document version
|
||||
CREATE TABLE document_version_approval_quorums (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL,
|
||||
version_id TEXT NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
|
||||
status document_version_approval_quorum_status NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
-- Ensure only one PENDING quorum per document version
|
||||
CREATE UNIQUE INDEX document_one_pending_quorum_idx
|
||||
ON document_version_approval_quorums (version_id)
|
||||
WHERE status = 'PENDING';
|
||||
|
||||
-- Create approval decision table: one row per approver per quorum
|
||||
CREATE TABLE document_version_approval_decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL,
|
||||
quorum_id TEXT NOT NULL REFERENCES document_version_approval_quorums(id) ON DELETE CASCADE,
|
||||
approver_id TEXT NOT NULL REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT,
|
||||
state document_version_approval_decision_state NOT NULL,
|
||||
comment TEXT,
|
||||
electronic_signature_id TEXT REFERENCES electronic_signatures(id) ON DELETE RESTRICT,
|
||||
decided_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
UNIQUE (quorum_id, approver_id)
|
||||
);
|
||||
|
||||
-- Add document types to electronic_signature_document_type enum
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'GOVERNANCE';
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'POLICY';
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'PROCEDURE';
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'PLAN';
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'REGISTER';
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'RECORD';
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'REPORT';
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'TEMPLATE';
|
||||
|
||||
-- Add document_name to electronic_signatures for email subject
|
||||
ALTER TABLE electronic_signatures ADD COLUMN document_name TEXT;
|
||||
|
||||
-- Backfill: create APPROVED quorums for existing published versions that have approvers
|
||||
INSERT INTO document_version_approval_quorums (id, tenant_id, organization_id, version_id, status, created_at, updated_at)
|
||||
SELECT DISTINCT
|
||||
generate_gid(decode_base64_unpadded(dv.tenant_id), 69),
|
||||
dv.tenant_id,
|
||||
dv.organization_id,
|
||||
dv.id,
|
||||
'APPROVED'::document_version_approval_quorum_status,
|
||||
dv.published_at,
|
||||
dv.published_at
|
||||
FROM document_versions dv
|
||||
WHERE dv.status = 'PUBLISHED'
|
||||
AND dv.published_at IS NOT NULL
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM document_version_approvers dva WHERE dva.document_version_id = dv.id)
|
||||
OR EXISTS (SELECT 1 FROM document_approvers da WHERE da.document_id = dv.document_id)
|
||||
);
|
||||
|
||||
-- Backfill: create APPROVED decisions linked to the quorums
|
||||
INSERT INTO document_version_approval_decisions (
|
||||
id, tenant_id, organization_id, quorum_id,
|
||||
approver_id, state, decided_at, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
generate_gid(decode_base64_unpadded(dv.tenant_id), 70),
|
||||
dv.tenant_id,
|
||||
dv.organization_id,
|
||||
q.id,
|
||||
COALESCE(dva.approver_profile_id, da.approver_profile_id),
|
||||
'APPROVED'::document_version_approval_decision_state,
|
||||
dv.published_at,
|
||||
dv.published_at,
|
||||
dv.published_at
|
||||
FROM document_versions dv
|
||||
JOIN document_version_approval_quorums q ON q.version_id = dv.id
|
||||
LEFT JOIN document_version_approvers dva ON dva.document_version_id = dv.id
|
||||
LEFT JOIN document_approvers da ON da.document_id = dv.document_id
|
||||
AND dva.approver_profile_id IS NULL
|
||||
WHERE dv.status = 'PUBLISHED'
|
||||
AND COALESCE(dva.approver_profile_id, da.approver_profile_id) IS NOT NULL
|
||||
ON CONFLICT (quorum_id, approver_id) DO NOTHING;
|
||||
|
||||
-- TODO: DROP TABLE document_version_approvers once confirmed safe
|
||||
-- TODO: DROP TABLE document_approvers once confirmed safe
|
||||
@@ -386,6 +386,7 @@
|
||||
<span class="classification">{{.Classification | classificationString}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{{- if gt (len .Approvers) 0}}
|
||||
<tr>
|
||||
<td>Approver{{- if gt (len .Approvers) 1}}s{{- end}}</td>
|
||||
<td>
|
||||
@@ -400,6 +401,7 @@
|
||||
{{- end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
<tr>
|
||||
<td>Version:</td>
|
||||
<td>{{.Version}}</td>
|
||||
|
||||
@@ -315,8 +315,11 @@ func (w *CompletionCertificateWorker) generateCertificate(
|
||||
}
|
||||
emailPresenter := emails.NewPresenterFromConfig(w.fileManager, presenterCfg, ref.UnrefOrZero(signature.SignerFullName))
|
||||
|
||||
docTypeName := signature.DocumentType.DisplayName()
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderElectronicSignatureCertificate(ctx, ref.UnrefOrZero(signature.SignerFullName), docTypeName)
|
||||
docName := ref.UnrefOrZero(signature.DocumentName)
|
||||
if docName == "" {
|
||||
docName = signature.DocumentType.DisplayName()
|
||||
}
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderElectronicSignatureCertificate(ctx, ref.UnrefOrZero(signature.SignerFullName), docName)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot render email: %w", err)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ type (
|
||||
CreateSignatureRequest struct {
|
||||
OrganizationID gid.GID
|
||||
DocumentType coredata.ElectronicSignatureDocumentType
|
||||
DocumentName *string
|
||||
FileID gid.GID
|
||||
SignerEmail mail.Addr
|
||||
ConsentText string // optional; required when DocumentType == OTHER
|
||||
@@ -61,6 +62,18 @@ type (
|
||||
SignerUA string
|
||||
}
|
||||
|
||||
CreateAndAcceptSignatureRequest struct {
|
||||
OrganizationID gid.GID
|
||||
DocumentType coredata.ElectronicSignatureDocumentType
|
||||
DocumentName *string
|
||||
FileID gid.GID
|
||||
SignerEmail mail.Addr
|
||||
SignerFullName string
|
||||
SignerIPAddr string
|
||||
SignerUA string
|
||||
ConsentText string
|
||||
}
|
||||
|
||||
RecordEventRequest struct {
|
||||
SignatureID gid.GID
|
||||
EventType coredata.ElectronicSignatureEventType
|
||||
@@ -161,6 +174,7 @@ func (s *Service) CreateSignature(
|
||||
OrganizationID: req.OrganizationID,
|
||||
Status: coredata.ElectronicSignatureStatusPending,
|
||||
DocumentType: req.DocumentType,
|
||||
DocumentName: req.DocumentName,
|
||||
FileID: stampedFileID,
|
||||
SignerEmail: req.SignerEmail.String(),
|
||||
ConsentText: consentText,
|
||||
@@ -178,6 +192,59 @@ func (s *Service) CreateSignature(
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateAndAcceptSignature(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
req *CreateAndAcceptSignatureRequest,
|
||||
) (*coredata.ElectronicSignature, error) {
|
||||
sig, err := s.CreateSignature(
|
||||
ctx,
|
||||
conn,
|
||||
&CreateSignatureRequest{
|
||||
OrganizationID: req.OrganizationID,
|
||||
DocumentType: req.DocumentType,
|
||||
DocumentName: req.DocumentName,
|
||||
FileID: req.FileID,
|
||||
SignerEmail: req.SignerEmail,
|
||||
ConsentText: req.ConsentText,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create signature: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
scope := coredata.NewScopeFromObjectID(req.OrganizationID)
|
||||
|
||||
sig.SignerFullName = &req.SignerFullName
|
||||
sig.SignerIPAddress = &req.SignerIPAddr
|
||||
sig.SignerUserAgent = &req.SignerUA
|
||||
sig.SignedAt = &now
|
||||
sig.Status = coredata.ElectronicSignatureStatusAccepted
|
||||
sig.UpdatedAt = now
|
||||
|
||||
if err := sig.Update(ctx, conn, scope); err != nil {
|
||||
return nil, fmt.Errorf("cannot accept signature: %w", err)
|
||||
}
|
||||
|
||||
if err := s.recordEvent(
|
||||
ctx,
|
||||
conn,
|
||||
&RecordEventRequest{
|
||||
SignatureID: sig.ID,
|
||||
EventType: coredata.ElectronicSignatureEventTypeSignatureAccepted,
|
||||
EventSource: coredata.ElectronicSignatureEventSourceServer,
|
||||
ActorEmail: req.SignerEmail,
|
||||
ActorIPAddr: req.SignerIPAddr,
|
||||
ActorUA: req.SignerUA,
|
||||
},
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("cannot record signature event: %w", err)
|
||||
}
|
||||
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
func (s *Service) createStampedDocument(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -187,15 +187,21 @@ const (
|
||||
ActionDocumentSendSigningNotifications = "core:document:send-signing-notifications"
|
||||
|
||||
// DocumentVersion actions
|
||||
ActionDocumentVersionGet = "core:document-version:get"
|
||||
ActionDocumentVersionList = "core:document-version:list"
|
||||
ActionDocumentVersionExportPDF = "core:document-version:export-pdf"
|
||||
ActionDocumentVersionExportSignable = "core:document-version:export-signable-pdf"
|
||||
ActionDocumentVersionSign = "core:document-version:sign"
|
||||
ActionDocumentVersionUpdate = "core:document-version:update"
|
||||
ActionDocumentVersionDeleteDraft = "core:document-version:delete-draft"
|
||||
ActionDocumentVersionPublish = "core:document-version:publish"
|
||||
ActionDocumentVersionExport = "core:document-version:export"
|
||||
ActionDocumentVersionGet = "core:document-version:get"
|
||||
ActionDocumentVersionList = "core:document-version:list"
|
||||
ActionDocumentVersionExportPDF = "core:document-version:export-pdf"
|
||||
ActionDocumentVersionExportSignable = "core:document-version:export-signable-pdf"
|
||||
ActionDocumentVersionSign = "core:document-version:sign"
|
||||
ActionDocumentVersionUpdate = "core:document-version:update"
|
||||
ActionDocumentVersionDeleteDraft = "core:document-version:delete-draft"
|
||||
ActionDocumentVersionRequestApproval = "core:document-version:request-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"
|
||||
|
||||
// DocumentVersionSignature actions
|
||||
ActionDocumentVersionSignatureRequest = "core:document-version-signature:request"
|
||||
|
||||
985
pkg/probo/document_approval_service.go
Normal file
985
pkg/probo/document_approval_service.go
Normal file
@@ -0,0 +1,985 @@
|
||||
// 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 probo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"net/url"
|
||||
|
||||
"go.gearno.de/crypto/uuid"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/packages/emails"
|
||||
"go.probo.inc/probo/pkg/baseurl"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/esign"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/html2pdf"
|
||||
"go.probo.inc/probo/pkg/iam"
|
||||
"go.probo.inc/probo/pkg/mail"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
"go.probo.inc/probo/pkg/statelesstoken"
|
||||
"go.probo.inc/probo/pkg/validator"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentApprovalService struct {
|
||||
svc *TenantService
|
||||
html2pdfConverter *html2pdf.Converter
|
||||
invitationTokenValidity time.Duration
|
||||
tokenSecret string
|
||||
}
|
||||
|
||||
ErrDocumentVersionNotPendingApproval struct{}
|
||||
|
||||
ErrApprovalDecisionAlreadyMade struct{}
|
||||
|
||||
RequestApprovalRequest struct {
|
||||
DocumentID gid.GID
|
||||
ApproverIDs []gid.GID
|
||||
Changelog *string
|
||||
}
|
||||
|
||||
ApproveDocumentVersionRequest struct {
|
||||
DocumentVersionID gid.GID
|
||||
IdentityID gid.GID
|
||||
Comment *string
|
||||
SignerFullName string
|
||||
SignerEmail mail.Addr
|
||||
SignerIPAddr string
|
||||
SignerUA string
|
||||
}
|
||||
|
||||
RejectDocumentVersionRequest struct {
|
||||
DocumentVersionID gid.GID
|
||||
IdentityID gid.GID
|
||||
Comment *string
|
||||
}
|
||||
)
|
||||
|
||||
func (e ErrDocumentVersionNotPendingApproval) Error() string {
|
||||
return "document version is not pending approval"
|
||||
}
|
||||
func (e ErrApprovalDecisionAlreadyMade) Error() string {
|
||||
return "approval decision has already been made"
|
||||
}
|
||||
|
||||
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(req.Changelog, "changelog", validator.Required(), validator.SafeText(5000))
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) RequestApproval(
|
||||
ctx context.Context,
|
||||
req RequestApprovalRequest,
|
||||
) (*coredata.DocumentVersionApprovalQuorum, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var quorum *coredata.DocumentVersionApprovalQuorum
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
document := &coredata.Document{}
|
||||
if err := document.LoadByID(ctx, tx, s.svc.scope, req.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
documentVersion, err := s.loadLatestVersion(ctx, tx, req.DocumentID)
|
||||
if err != nil {
|
||||
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 err := s.rejectPendingQuorum(ctx, tx, documentVersion.ID); err != nil {
|
||||
return fmt.Errorf("cannot reject pending quorum: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return quorum, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) Approve(
|
||||
ctx context.Context,
|
||||
req ApproveDocumentVersionRequest,
|
||||
) (*coredata.DocumentVersionApprovalDecision, error) {
|
||||
var (
|
||||
documentVersion *coredata.DocumentVersion
|
||||
document *coredata.Document
|
||||
quorum *coredata.DocumentVersionApprovalQuorum
|
||||
decision *coredata.DocumentVersionApprovalDecision
|
||||
)
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
documentVersion = &coredata.DocumentVersion{}
|
||||
if err := documentVersion.LoadByID(ctx, conn, s.svc.scope, req.DocumentVersionID); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
var profile *coredata.MembershipProfile
|
||||
var err error
|
||||
quorum, profile, err = s.loadQuorumAndProfile(ctx, conn, req.DocumentVersionID, req.IdentityID, documentVersion.OrganizationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load quorum and profile: %w", err)
|
||||
}
|
||||
|
||||
if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
|
||||
return &ErrDocumentVersionNotPendingApproval{}
|
||||
}
|
||||
|
||||
decision = &coredata.DocumentVersionApprovalDecision{}
|
||||
if err := decision.LoadByQuorumIDAndApproverID(ctx, conn, s.svc.scope, quorum.ID, profile.ID); err != nil {
|
||||
return fmt.Errorf("cannot load approval decision: %w", err)
|
||||
}
|
||||
|
||||
if decision.State != coredata.DocumentVersionApprovalDecisionStatePending {
|
||||
return &ErrApprovalDecisionAlreadyMade{}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
pdfData, err := s.generateApprovalPDF(ctx, req.DocumentVersionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot export document PDF: %w", err)
|
||||
}
|
||||
|
||||
fileRecord := &coredata.File{
|
||||
ID: gid.New(s.svc.scope.GetTenantID(), coredata.FileEntityType),
|
||||
OrganizationID: documentVersion.OrganizationID,
|
||||
BucketName: s.svc.bucket,
|
||||
MimeType: "application/pdf",
|
||||
FileName: fmt.Sprintf("approval-%s.pdf", decision.ID),
|
||||
FileKey: uuid.MustNewV4().String(),
|
||||
Visibility: coredata.FileVisibilityPrivate,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
fileSize, err := s.svc.fileManager.PutFile(
|
||||
ctx,
|
||||
fileRecord,
|
||||
bytes.NewReader(pdfData),
|
||||
map[string]string{
|
||||
"type": "approval-document",
|
||||
"decision-id": decision.ID.String(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot upload approval PDF: %w", err)
|
||||
}
|
||||
|
||||
fileRecord.FileSize = fileSize
|
||||
|
||||
approverID := decision.ApproverID
|
||||
|
||||
err = s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
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)
|
||||
}
|
||||
|
||||
if decision.State != coredata.DocumentVersionApprovalDecisionStatePending {
|
||||
return &ErrApprovalDecisionAlreadyMade{}
|
||||
}
|
||||
|
||||
if err := fileRecord.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert approval file record: %w", err)
|
||||
}
|
||||
|
||||
esig, err := s.svc.esign.CreateAndAcceptSignature(
|
||||
ctx,
|
||||
tx,
|
||||
&esign.CreateAndAcceptSignatureRequest{
|
||||
OrganizationID: documentVersion.OrganizationID,
|
||||
DocumentType: coredata.ElectronicSignatureDocumentTypeFromDocumentType(document.DocumentType),
|
||||
DocumentName: &document.Title,
|
||||
FileID: fileRecord.ID,
|
||||
SignerEmail: req.SignerEmail,
|
||||
SignerFullName: req.SignerFullName,
|
||||
SignerIPAddr: req.SignerIPAddr,
|
||||
SignerUA: req.SignerUA,
|
||||
ConsentText: "By clicking Approve, I consent to approve this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature.",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create electronic signature: %w", err)
|
||||
}
|
||||
|
||||
decision.State = coredata.DocumentVersionApprovalDecisionStateApproved
|
||||
decision.Comment = req.Comment
|
||||
decision.ElectronicSignatureID = &esig.ID
|
||||
decision.DecidedAt = &now
|
||||
decision.UpdatedAt = now
|
||||
|
||||
if err := decision.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update approval decision: %w", err)
|
||||
}
|
||||
|
||||
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 nil, err
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) Reject(
|
||||
ctx context.Context,
|
||||
req RejectDocumentVersionRequest,
|
||||
) (*coredata.DocumentVersionApprovalDecision, error) {
|
||||
var decision *coredata.DocumentVersionApprovalDecision
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
decision = &coredata.DocumentVersionApprovalDecision{}
|
||||
if err := decision.LoadByQuorumIDAndApproverID(ctx, tx, s.svc.scope, quorum.ID, profile.ID); err != nil {
|
||||
return fmt.Errorf("cannot load approval decision: %w", err)
|
||||
}
|
||||
|
||||
if decision.State != coredata.DocumentVersionApprovalDecisionStatePending {
|
||||
return &ErrApprovalDecisionAlreadyMade{}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
decision.State = coredata.DocumentVersionApprovalDecisionStateRejected
|
||||
decision.Comment = req.Comment
|
||||
decision.DecidedAt = &now
|
||||
decision.UpdatedAt = now
|
||||
|
||||
if err := decision.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update approval decision: %w", err)
|
||||
}
|
||||
|
||||
quorum.Status = coredata.DocumentVersionApprovalQuorumStatusRejected
|
||||
quorum.UpdatedAt = now
|
||||
|
||||
if err := quorum.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update approval quorum: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) AddApprover(
|
||||
ctx context.Context,
|
||||
documentVersionID gid.GID,
|
||||
approverID gid.GID,
|
||||
) (*coredata.DocumentVersionApprovalDecision, error) {
|
||||
var decision *coredata.DocumentVersionApprovalDecision
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
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 {
|
||||
return &ErrDocumentVersionNotPendingApproval{}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
if err := decision.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert approval decision: %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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return 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(tx pg.Conn) 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
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) GetQuorum(
|
||||
ctx context.Context,
|
||||
quorumID gid.GID,
|
||||
) (*coredata.DocumentVersionApprovalQuorum, error) {
|
||||
quorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := quorum.LoadByID(ctx, conn, s.svc.scope, quorumID); err != nil {
|
||||
return fmt.Errorf("cannot load approval quorum: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return quorum, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) ListQuorums(
|
||||
ctx context.Context,
|
||||
documentVersionID gid.GID,
|
||||
cursor *page.Cursor[coredata.DocumentVersionApprovalQuorumOrderField],
|
||||
) (*page.Page[*coredata.DocumentVersionApprovalQuorum, coredata.DocumentVersionApprovalQuorumOrderField], error) {
|
||||
var quorums coredata.DocumentVersionApprovalQuorums
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := quorums.LoadAllByDocumentVersionID(ctx, conn, s.svc.scope, documentVersionID, cursor); err != nil {
|
||||
return fmt.Errorf("cannot list approval quorums: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return page.NewPage(quorums, cursor), nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) CountQuorums(
|
||||
ctx context.Context,
|
||||
documentVersionID gid.GID,
|
||||
) (int, error) {
|
||||
var count int
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) (err error) {
|
||||
quorums := &coredata.DocumentVersionApprovalQuorums{}
|
||||
count, err = quorums.CountByDocumentVersionID(ctx, conn, s.svc.scope, documentVersionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count approval quorums: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) ListDecisions(
|
||||
ctx context.Context,
|
||||
quorumID gid.GID,
|
||||
cursor *page.Cursor[coredata.DocumentVersionApprovalDecisionOrderField],
|
||||
filter *coredata.DocumentVersionApprovalDecisionFilter,
|
||||
) (*page.Page[*coredata.DocumentVersionApprovalDecision, coredata.DocumentVersionApprovalDecisionOrderField], error) {
|
||||
var decisions coredata.DocumentVersionApprovalDecisions
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := decisions.LoadByQuorumID(ctx, conn, s.svc.scope, quorumID, cursor, filter); err != nil {
|
||||
return fmt.Errorf("cannot list approval decisions: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return page.NewPage(decisions, cursor), nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) CountDecisions(
|
||||
ctx context.Context,
|
||||
quorumID gid.GID,
|
||||
filter *coredata.DocumentVersionApprovalDecisionFilter,
|
||||
) (int, error) {
|
||||
var count int
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) (err error) {
|
||||
decisions := &coredata.DocumentVersionApprovalDecisions{}
|
||||
count, err = decisions.CountByQuorumID(ctx, conn, s.svc.scope, quorumID, filter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count approval decisions: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) GetViewerDecision(
|
||||
ctx context.Context,
|
||||
documentVersionID gid.GID,
|
||||
identityID gid.GID,
|
||||
) (*coredata.DocumentVersionApprovalDecision, error) {
|
||||
var decision *coredata.DocumentVersionApprovalDecision
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
documentVersion := &coredata.DocumentVersion{}
|
||||
if err := documentVersion.LoadByID(ctx, conn, s.svc.scope, documentVersionID); err != nil {
|
||||
return fmt.Errorf("cannot load document version: %w", err)
|
||||
}
|
||||
|
||||
profile := &coredata.MembershipProfile{}
|
||||
if err := profile.LoadByIdentityIDAndOrganizationID(
|
||||
ctx,
|
||||
conn,
|
||||
s.svc.scope,
|
||||
identityID,
|
||||
documentVersion.OrganizationID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot load viewer profile: %w", err)
|
||||
}
|
||||
|
||||
quorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := quorum.LoadLastByDocumentVersionID(ctx, conn, s.svc.scope, documentVersionID); err != nil {
|
||||
return fmt.Errorf("cannot load last approval quorum: %w", err)
|
||||
}
|
||||
|
||||
d := &coredata.DocumentVersionApprovalDecision{}
|
||||
if err := d.LoadByQuorumIDAndApproverID(ctx, conn, s.svc.scope, quorum.ID, profile.ID); err != nil {
|
||||
return fmt.Errorf("cannot load viewer approval decision: %w", err)
|
||||
}
|
||||
|
||||
decision = d
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) loadLatestVersion(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
documentID gid.GID,
|
||||
) (*coredata.DocumentVersion, error) {
|
||||
version := &coredata.DocumentVersion{}
|
||||
if err := version.LoadLatestVersion(ctx, conn, s.svc.scope, documentID); err != nil {
|
||||
return nil, fmt.Errorf("cannot load latest version for document %q: %w", documentID, err)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) loadQuorumAndProfile(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
documentVersionID gid.GID,
|
||||
identityID gid.GID,
|
||||
organizationID gid.GID,
|
||||
) (*coredata.DocumentVersionApprovalQuorum, *coredata.MembershipProfile, error) {
|
||||
quorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := quorum.LoadLastByDocumentVersionID(ctx, conn, s.svc.scope, documentVersionID); err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, nil, &ErrDocumentVersionNotPendingApproval{}
|
||||
}
|
||||
return nil, nil, fmt.Errorf("cannot load last approval quorum: %w", err)
|
||||
}
|
||||
|
||||
profile := &coredata.MembershipProfile{}
|
||||
if err := profile.LoadByIdentityIDAndOrganizationID(ctx, conn, s.svc.scope, identityID, organizationID); err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot find profile for identity: %w", err)
|
||||
}
|
||||
|
||||
return quorum, profile, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) rejectPendingQuorum(
|
||||
ctx context.Context,
|
||||
tx pg.Conn,
|
||||
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.Conn,
|
||||
quorum *coredata.DocumentVersionApprovalQuorum,
|
||||
organizationID gid.GID,
|
||||
approverIDs []gid.GID,
|
||||
now time.Time,
|
||||
) error {
|
||||
decisions := make(coredata.DocumentVersionApprovalDecisions, 0, len(approverIDs))
|
||||
for _, approverID := range approverIDs {
|
||||
decisions = append(decisions, &coredata.DocumentVersionApprovalDecision{
|
||||
ID: gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionApprovalDecisionEntityType),
|
||||
OrganizationID: organizationID,
|
||||
QuorumID: quorum.ID,
|
||||
ApproverID: approverID,
|
||||
State: coredata.DocumentVersionApprovalDecisionStatePending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
if err := decisions.BulkInsert(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert approval decisions: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) sendApprovalEmails(
|
||||
ctx context.Context,
|
||||
tx pg.Conn,
|
||||
profiles coredata.MembershipProfiles,
|
||||
document *coredata.Document,
|
||||
organization *coredata.Organization,
|
||||
documentVersionID gid.GID,
|
||||
) error {
|
||||
now := time.Now()
|
||||
approvalURLPath := "/organizations/" + document.OrganizationID.String() + "/employee/approvals/" + document.ID.String()
|
||||
|
||||
approvalEmails := make(coredata.Emails, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
emailPresenter := emails.NewPresenter(s.svc.fileManager, s.svc.bucket, s.svc.baseURL, profile.FullName)
|
||||
|
||||
var (
|
||||
emailLinkURLPath = approvalURLPath
|
||||
query = make(url.Values)
|
||||
)
|
||||
if profile.State != coredata.ProfileStateActive {
|
||||
if profile.Source != coredata.ProfileSourceSCIM {
|
||||
invitation := &coredata.Invitation{
|
||||
ID: gid.New(document.OrganizationID.TenantID(), coredata.InvitationEntityType),
|
||||
OrganizationID: document.OrganizationID,
|
||||
UserID: profile.ID,
|
||||
Status: coredata.InvitationStatusPending,
|
||||
ExpiresAt: now.Add(s.invitationTokenValidity),
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := invitation.Insert(ctx, tx, coredata.NewScopeFromObjectID(document.OrganizationID)); err != nil {
|
||||
return fmt.Errorf("cannot insert invitation: %w", err)
|
||||
}
|
||||
|
||||
invitationToken, err := statelesstoken.NewToken(
|
||||
s.tokenSecret,
|
||||
iam.TokenTypeOrganizationInvitation,
|
||||
s.invitationTokenValidity,
|
||||
iam.InvitationTokenData{InvitationID: invitation.ID},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot generate invitation token: %w", err)
|
||||
}
|
||||
|
||||
emailLinkURLPath = "/auth/activate-account"
|
||||
continueURL := baseurl.MustParse(s.svc.baseURL).AppendPath(approvalURLPath).MustString()
|
||||
query.Add("token", invitationToken)
|
||||
query.Add("continue", continueURL)
|
||||
}
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderDocumentApproval(
|
||||
ctx,
|
||||
emailLinkURLPath,
|
||||
query,
|
||||
organization.Name,
|
||||
document.Title,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render approval request email: %w", err)
|
||||
}
|
||||
|
||||
approvalEmails = append(approvalEmails, coredata.NewEmail(
|
||||
profile.FullName,
|
||||
profile.EmailAddress,
|
||||
subject,
|
||||
textBody,
|
||||
htmlBody,
|
||||
&coredata.EmailOptions{
|
||||
SenderName: new(organization.Name),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
if err := approvalEmails.BulkInsert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert approval emails: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) generateApprovalPDF(
|
||||
ctx context.Context,
|
||||
documentVersionID gid.GID,
|
||||
) ([]byte, error) {
|
||||
var pdfData []byte
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
var err error
|
||||
pdfData, err = exportDocumentPDF(
|
||||
ctx,
|
||||
s.svc,
|
||||
s.html2pdfConverter,
|
||||
conn,
|
||||
s.svc.scope,
|
||||
documentVersionID,
|
||||
ExportPDFOptions{},
|
||||
)
|
||||
return err
|
||||
},
|
||||
)
|
||||
|
||||
return pdfData, err
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) countDecisions(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
quorumID gid.GID,
|
||||
) (int, error) {
|
||||
decisions := &coredata.DocumentVersionApprovalDecisions{}
|
||||
count, err := decisions.CountByQuorumID(
|
||||
ctx,
|
||||
conn,
|
||||
s.svc.scope,
|
||||
quorumID,
|
||||
coredata.NewDocumentVersionApprovalDecisionFilter(nil),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count decisions: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) maybeApproveQuorum(
|
||||
ctx context.Context,
|
||||
tx pg.Conn,
|
||||
quorumID gid.GID,
|
||||
) error {
|
||||
totalCount, err := s.countDecisions(ctx, tx, quorumID)
|
||||
if err != nil {
|
||||
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 approvedCount != totalCount {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
quorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := quorum.LoadByID(ctx, tx, s.svc.scope, quorumID); err != nil {
|
||||
return fmt.Errorf("cannot load quorum: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
quorum.Status = coredata.DocumentVersionApprovalQuorumStatusApproved
|
||||
quorum.UpdatedAt = now
|
||||
|
||||
if err := quorum.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update quorum: %w", err)
|
||||
}
|
||||
|
||||
if err := s.publishVersion(ctx, tx, quorum.VersionID); err != nil {
|
||||
return fmt.Errorf("cannot publish version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) publishVersion(
|
||||
ctx context.Context,
|
||||
tx pg.Conn,
|
||||
versionID gid.GID,
|
||||
) error {
|
||||
version := &coredata.DocumentVersion{}
|
||||
if err := version.LoadByID(ctx, tx, s.svc.scope, versionID); err != nil {
|
||||
return fmt.Errorf("cannot load document version: %w", err)
|
||||
}
|
||||
|
||||
document := &coredata.Document{}
|
||||
if err := document.LoadByID(ctx, tx, s.svc.scope, version.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
document.CurrentPublishedVersion = &version.VersionNumber
|
||||
document.UpdatedAt = now
|
||||
|
||||
version.Status = coredata.DocumentVersionStatusPublished
|
||||
version.PublishedAt = &now
|
||||
version.UpdatedAt = now
|
||||
|
||||
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update document: %w", err)
|
||||
}
|
||||
|
||||
if err := version.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update document version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
@@ -76,7 +77,6 @@ type (
|
||||
OrganizationID gid.GID
|
||||
Title string
|
||||
Content string
|
||||
ApproverIDs []gid.GID
|
||||
Classification coredata.DocumentClassification
|
||||
DocumentType coredata.DocumentType
|
||||
TrustCenterVisibility *coredata.TrustCenterVisibility
|
||||
@@ -85,7 +85,6 @@ type (
|
||||
UpdateDocumentRequest struct {
|
||||
DocumentID gid.GID
|
||||
Title *string
|
||||
ApproverIDs []gid.GID
|
||||
Classification *coredata.DocumentClassification
|
||||
DocumentType *coredata.DocumentType
|
||||
TrustCenterVisibility *coredata.TrustCenterVisibility
|
||||
@@ -123,10 +122,6 @@ func (cdr *CreateDocumentRequest) Validate() error {
|
||||
v.Check(cdr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(cdr.Title, "title", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(cdr.Content, "content", validator.Required(), validator.NotEmpty(), validator.MaxLen(documentMaxLength))
|
||||
v.Check(cdr.ApproverIDs, "approver_ids", validator.Required(), validator.NotEmpty())
|
||||
for _, id := range cdr.ApproverIDs {
|
||||
v.Check(id, "approver_ids", validator.Required(), validator.GID(coredata.MembershipProfileEntityType))
|
||||
}
|
||||
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()))
|
||||
@@ -139,9 +134,6 @@ 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))
|
||||
for _, id := range udr.ApproverIDs {
|
||||
v.Check(id, "approver_ids", validator.Required(), validator.GID(coredata.MembershipProfileEntityType))
|
||||
}
|
||||
v.Check(udr.Classification, "classification", validator.OneOfSlice(coredata.DocumentClassifications()))
|
||||
v.Check(udr.DocumentType, "document_type", validator.OneOfSlice(coredata.DocumentTypes()))
|
||||
v.Check(udr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
|
||||
@@ -244,57 +236,6 @@ func (s *DocumentService) GetByIDs(
|
||||
return documents, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) ListApprovers(
|
||||
ctx context.Context,
|
||||
documentID gid.GID,
|
||||
cursor *page.Cursor[coredata.MembershipProfileOrderField],
|
||||
) (*page.Page[*coredata.MembershipProfile, coredata.MembershipProfileOrderField], error) {
|
||||
var profiles coredata.MembershipProfiles
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := profiles.LoadByDocumentID(ctx, conn, s.svc.scope, documentID, cursor); err != nil {
|
||||
return fmt.Errorf("cannot load document approvers: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return page.NewPage(profiles, cursor), nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) CountApprovers(
|
||||
ctx context.Context,
|
||||
documentID gid.GID,
|
||||
) (int, error) {
|
||||
var count int
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) (err error) {
|
||||
profiles := coredata.MembershipProfiles{}
|
||||
count, err = profiles.CountByDocumentID(ctx, conn, s.svc.scope, documentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count document approvers: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) ListVersionApprovers(
|
||||
ctx context.Context,
|
||||
documentVersionID gid.GID,
|
||||
@@ -602,54 +543,18 @@ func (s *DocumentService) Create(
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
// Validate all approver profiles exist
|
||||
approverProfiles := coredata.MembershipProfiles{}
|
||||
if err := approverProfiles.LoadByIDs(ctx, conn, s.svc.scope, req.ApproverIDs); err != nil {
|
||||
return fmt.Errorf("cannot load approver profiles: %w", err)
|
||||
}
|
||||
|
||||
if len(approverProfiles) != len(req.ApproverIDs) {
|
||||
return fmt.Errorf("one or more approver profiles not found")
|
||||
}
|
||||
|
||||
document.OrganizationID = organization.ID
|
||||
|
||||
if err := document.Insert(ctx, conn, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert document: %w", err)
|
||||
}
|
||||
|
||||
// Insert document approvers
|
||||
for _, approverID := range req.ApproverIDs {
|
||||
da := coredata.DocumentApprover{
|
||||
DocumentID: documentID,
|
||||
ApproverProfileID: approverID,
|
||||
OrganizationID: organization.ID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := da.Insert(ctx, conn, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert document approver: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
documentVersion.OrganizationID = organization.ID
|
||||
|
||||
if err := documentVersion.Insert(ctx, conn, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot create document version: %w", err)
|
||||
}
|
||||
|
||||
// Insert document version approvers
|
||||
for _, approverID := range req.ApproverIDs {
|
||||
dva := coredata.DocumentVersionApprover{
|
||||
DocumentVersionID: documentVersionID,
|
||||
ApproverProfileID: approverID,
|
||||
OrganizationID: organization.ID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := dva.Insert(ctx, conn, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert document version approver: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
@@ -870,28 +775,6 @@ func (s *DocumentService) UpdateVersion(
|
||||
return fmt.Errorf("cannot update document version: %w", err)
|
||||
}
|
||||
|
||||
docApprovers := &coredata.DocumentApprovers{}
|
||||
if err := docApprovers.LoadByDocumentID(ctx, conn, s.svc.scope, document.ID); err != nil {
|
||||
return fmt.Errorf("cannot load document approvers: %w", err)
|
||||
}
|
||||
|
||||
versionApprovers := &coredata.DocumentVersionApprovers{}
|
||||
if err := versionApprovers.DeleteByDocumentVersionID(ctx, conn, s.svc.scope, documentVersion.ID); err != nil {
|
||||
return fmt.Errorf("cannot delete document version approvers: %w", err)
|
||||
}
|
||||
|
||||
for _, da := range *docApprovers {
|
||||
dva := coredata.DocumentVersionApprover{
|
||||
DocumentVersionID: documentVersion.ID,
|
||||
ApproverProfileID: da.ApproverProfileID,
|
||||
OrganizationID: da.OrganizationID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := dva.Insert(ctx, conn, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert document version approver: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
@@ -1132,23 +1015,6 @@ func (s *DocumentService) CreateDraft(
|
||||
return fmt.Errorf("cannot create draft: %w", err)
|
||||
}
|
||||
|
||||
docApprovers := &coredata.DocumentApprovers{}
|
||||
if err := docApprovers.LoadByDocumentID(ctx, conn, s.svc.scope, documentID); err != nil {
|
||||
return fmt.Errorf("cannot load document approvers: %w", err)
|
||||
}
|
||||
|
||||
for _, da := range *docApprovers {
|
||||
dva := coredata.DocumentVersionApprover{
|
||||
DocumentVersionID: draftVersionID,
|
||||
ApproverProfileID: da.ApproverProfileID,
|
||||
OrganizationID: da.OrganizationID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := dva.Insert(ctx, conn, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert document version approver: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
@@ -1460,6 +1326,36 @@ func (s *DocumentService) IsSigned(
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) GetViewerApprovalState(
|
||||
ctx context.Context,
|
||||
documentID gid.GID,
|
||||
identityID gid.GID,
|
||||
) (coredata.DocumentVersionApprovalDecisionState, error) {
|
||||
document := &coredata.Document{}
|
||||
|
||||
var state coredata.DocumentVersionApprovalDecisionState
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
var err error
|
||||
state, err = document.GetViewerApprovalStateForLastVersion(
|
||||
ctx,
|
||||
conn,
|
||||
s.svc.scope,
|
||||
documentID,
|
||||
identityID,
|
||||
)
|
||||
return err
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot get viewer approval state: %w", err)
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) CountForOrganizationID(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
@@ -1656,33 +1552,6 @@ func (s *DocumentService) Update(
|
||||
document.TrustCenterVisibility = *req.TrustCenterVisibility
|
||||
}
|
||||
|
||||
if len(req.ApproverIDs) > 0 {
|
||||
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)
|
||||
}
|
||||
if len(approverProfiles) != len(req.ApproverIDs) {
|
||||
return fmt.Errorf("one or more approver profiles not found")
|
||||
}
|
||||
|
||||
docApprovers := &coredata.DocumentApprovers{}
|
||||
if err := docApprovers.DeleteByDocumentID(ctx, tx, s.svc.scope, req.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot delete document approvers: %w", err)
|
||||
}
|
||||
|
||||
for _, approverID := range req.ApproverIDs {
|
||||
da := coredata.DocumentApprover{
|
||||
DocumentID: req.DocumentID,
|
||||
ApproverProfileID: approverID,
|
||||
OrganizationID: document.OrganizationID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := da.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert document approver: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.UpdatedAt = now
|
||||
|
||||
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
@@ -1699,25 +1568,6 @@ func (s *DocumentService) Update(
|
||||
if err := draftVersion.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update draft version: %w", err)
|
||||
}
|
||||
|
||||
if len(req.ApproverIDs) > 0 {
|
||||
versionApprovers := &coredata.DocumentVersionApprovers{}
|
||||
if err := versionApprovers.DeleteByDocumentVersionID(ctx, tx, s.svc.scope, draftVersion.ID); err != nil {
|
||||
return fmt.Errorf("cannot delete draft version approvers: %w", err)
|
||||
}
|
||||
|
||||
for _, approverID := range req.ApproverIDs {
|
||||
dva := coredata.DocumentVersionApprover{
|
||||
DocumentVersionID: draftVersion.ID,
|
||||
ApproverProfileID: approverID,
|
||||
OrganizationID: document.OrganizationID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := dva.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert draft version approver: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -2014,25 +1864,53 @@ func exportDocumentPDF(
|
||||
return nil, fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
versionApprovers := &coredata.DocumentVersionApprovers{}
|
||||
if err := versionApprovers.LoadByDocumentVersionID(ctx, conn, scope, documentVersionID); err != nil {
|
||||
return nil, fmt.Errorf("cannot load document version approvers: %w", err)
|
||||
}
|
||||
// Only show approvers from the last approved quorum in the export.
|
||||
var approverNames []string
|
||||
|
||||
approverProfiles := coredata.MembershipProfiles{}
|
||||
if err := approverProfiles.LoadByIDs(ctx, conn, scope, versionApprovers.ApproverProfileIDs()); err != nil {
|
||||
return nil, fmt.Errorf("cannot load document approver profiles: %w", err)
|
||||
}
|
||||
lastQuorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := lastQuorum.LoadLastByDocumentVersionID(ctx, conn, scope, documentVersionID); err != nil {
|
||||
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, fmt.Errorf("cannot load last approval quorum: %w", err)
|
||||
}
|
||||
} else if lastQuorum.Status == coredata.DocumentVersionApprovalQuorumStatusApproved {
|
||||
approvedDecisions := &coredata.DocumentVersionApprovalDecisions{}
|
||||
approvedFilter := coredata.NewDocumentVersionApprovalDecisionFilter(
|
||||
coredata.DocumentVersionApprovalDecisionStates{coredata.DocumentVersionApprovalDecisionStateApproved},
|
||||
)
|
||||
if err := approvedDecisions.LoadByQuorumID(
|
||||
ctx,
|
||||
conn,
|
||||
scope,
|
||||
lastQuorum.ID,
|
||||
page.NewCursor(
|
||||
100,
|
||||
nil,
|
||||
page.Head,
|
||||
page.OrderBy[coredata.DocumentVersionApprovalDecisionOrderField]{
|
||||
Field: coredata.DocumentVersionApprovalDecisionOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionAsc,
|
||||
},
|
||||
),
|
||||
approvedFilter,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("cannot load approved decisions: %w", err)
|
||||
}
|
||||
|
||||
profileByID := make(map[gid.GID]*coredata.MembershipProfile, len(approverProfiles))
|
||||
for _, p := range approverProfiles {
|
||||
profileByID[p.ID] = p
|
||||
}
|
||||
approverProfileIDs := make([]gid.GID, 0, len(*approvedDecisions))
|
||||
for _, d := range *approvedDecisions {
|
||||
approverProfileIDs = append(approverProfileIDs, d.ApproverID)
|
||||
}
|
||||
|
||||
approverNames := make([]string, 0, len(*versionApprovers))
|
||||
for _, a := range *versionApprovers {
|
||||
if p, ok := profileByID[a.ApproverProfileID]; ok {
|
||||
approverNames = append(approverNames, p.FullName)
|
||||
if len(approverProfileIDs) > 0 {
|
||||
approverProfiles := coredata.MembershipProfiles{}
|
||||
if err := approverProfiles.LoadByIDs(ctx, conn, scope, approverProfileIDs); err != nil {
|
||||
return nil, fmt.Errorf("cannot load approver profiles: %w", err)
|
||||
}
|
||||
|
||||
approverNames = make([]string, 0, len(approverProfiles))
|
||||
for _, p := range approverProfiles {
|
||||
approverNames = append(approverNames, p.FullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@ var (
|
||||
ActionDocumentSendSigningNotifications,
|
||||
ActionDocumentVersionUpdate,
|
||||
ActionDocumentVersionPublish,
|
||||
ActionDocumentVersionRequestApproval,
|
||||
ActionDocumentVersionApprove,
|
||||
ActionDocumentVersionReject,
|
||||
ActionDocumentVersionAddApprover,
|
||||
ActionDocumentVersionRemoveApprover,
|
||||
ActionDocumentVersionDeleteDraft,
|
||||
ActionDocumentVersionSignatureRequest,
|
||||
ActionDocumentVersionCancelSignature,
|
||||
@@ -42,6 +47,31 @@ var (
|
||||
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"),
|
||||
)
|
||||
)
|
||||
|
||||
// OwnerPolicy defines permissions for organization owners.
|
||||
@@ -50,6 +80,11 @@ var OwnerPolicy = policy.NewPolicy(
|
||||
"Probo Owner",
|
||||
documentWriteActiveOnly,
|
||||
documentUnarchiveArchivedOnly,
|
||||
|
||||
documentRequestApprovalNoPendingQuorum,
|
||||
documentRequestApprovalNotPublished,
|
||||
|
||||
documentApproverRequiresPendingQuorum,
|
||||
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
||||
).WithDescription("Full probo access for organization owners")
|
||||
|
||||
@@ -59,6 +94,11 @@ var AdminPolicy = policy.NewPolicy(
|
||||
"Probo Admin",
|
||||
documentWriteActiveOnly,
|
||||
documentUnarchiveArchivedOnly,
|
||||
|
||||
documentRequestApprovalNoPendingQuorum,
|
||||
documentRequestApprovalNotPublished,
|
||||
|
||||
documentApproverRequiresPendingQuorum,
|
||||
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
||||
).WithDescription("Probo admin access - can manage core entities")
|
||||
|
||||
@@ -66,6 +106,7 @@ var AdminPolicy = policy.NewPolicy(
|
||||
var ViewerPolicy = policy.NewPolicy(
|
||||
"probo:viewer",
|
||||
"Probo Viewer",
|
||||
documentWriteActiveOnly,
|
||||
policy.Allow(
|
||||
ActionOrganizationGet,
|
||||
ActionOrganizationGetLogoUrl,
|
||||
@@ -88,6 +129,7 @@ var ViewerPolicy = policy.NewPolicy(
|
||||
ActionDocumentGet, ActionDocumentList,
|
||||
ActionDocumentVersionGet, ActionDocumentVersionList,
|
||||
ActionDocumentVersionSignatureGet, ActionDocumentVersionSignatureList,
|
||||
ActionDocumentVersionApprovalList,
|
||||
ActionRiskGet, ActionRiskList,
|
||||
ActionAssetGet, ActionAssetList,
|
||||
ActionDatumGet, ActionDatumList,
|
||||
@@ -123,6 +165,10 @@ var ViewerPolicy = policy.NewPolicy(
|
||||
ActionDocumentVersionExportPDF, ActionDocumentVersionExportSignable, ActionDocumentVersionSign,
|
||||
).WithSID("document-signing").When(organizationCondition),
|
||||
|
||||
policy.Allow(
|
||||
ActionDocumentVersionApprove, ActionDocumentVersionReject,
|
||||
).WithSID("document-approval").When(organizationCondition),
|
||||
|
||||
policy.Allow(
|
||||
ActionProcessingActivityExport,
|
||||
ActionDataProtectionImpactAssessmentExport,
|
||||
@@ -155,6 +201,7 @@ var AuditorPolicy = policy.NewPolicy(
|
||||
ActionDocumentGet, ActionDocumentList,
|
||||
ActionDocumentVersionGet, ActionDocumentVersionList,
|
||||
ActionDocumentVersionSignatureGet, ActionDocumentVersionSignatureList,
|
||||
ActionDocumentVersionApprovalList,
|
||||
ActionRiskGet, ActionRiskList,
|
||||
ActionAssetGet, ActionAssetList,
|
||||
ActionDatumGet, ActionDatumList,
|
||||
@@ -184,6 +231,7 @@ var AuditorPolicy = policy.NewPolicy(
|
||||
var EmployeePolicy = policy.NewPolicy(
|
||||
"probo:employee",
|
||||
"Probo Employee",
|
||||
documentWriteActiveOnly,
|
||||
policy.Allow(
|
||||
ActionOrganizationGet,
|
||||
ActionOrganizationGetLogoUrl,
|
||||
@@ -198,7 +246,14 @@ var EmployeePolicy = policy.NewPolicy(
|
||||
ActionDocumentVersionSign,
|
||||
ActionDocumentVersionExportSignable,
|
||||
).WithSID("document-version-signing").When(organizationCondition),
|
||||
).WithDescription("Employee access - can sign documents and view internal content")
|
||||
|
||||
policy.Allow(
|
||||
ActionDocumentVersionApprovalList,
|
||||
ActionDocumentVersionApprove,
|
||||
ActionDocumentVersionReject,
|
||||
ActionDocumentVersionExportPDF,
|
||||
).WithSID("document-version-approval").When(organizationCondition),
|
||||
).WithDescription("Employee access - can sign documents, approve documents, and view internal content")
|
||||
|
||||
// ProboPolicySet returns the PolicySet for the probo service.
|
||||
func ProboPolicySet() *iam.PolicySet {
|
||||
|
||||
@@ -87,6 +87,7 @@ type (
|
||||
Organizations *OrganizationService
|
||||
Vendors *VendorService
|
||||
Documents *DocumentService
|
||||
DocumentApprovals *DocumentApprovalService
|
||||
Controls *ControlService
|
||||
Risks *RiskService
|
||||
VendorComplianceReports *VendorComplianceReportService
|
||||
@@ -212,6 +213,12 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
||||
invitationTokenValidity: s.invitationTokenValidity,
|
||||
tokenSecret: s.tokenSecret,
|
||||
}
|
||||
tenantService.DocumentApprovals = &DocumentApprovalService{
|
||||
svc: tenantService,
|
||||
html2pdfConverter: s.html2pdfConverter,
|
||||
invitationTokenValidity: s.invitationTokenValidity,
|
||||
tokenSecret: s.tokenSecret,
|
||||
}
|
||||
tenantService.Organizations = &OrganizationService{
|
||||
svc: tenantService,
|
||||
fileValidator: filevalidation.NewValidator(
|
||||
|
||||
@@ -1623,7 +1623,7 @@ input ApplicabilityStatementOrder
|
||||
}
|
||||
|
||||
input DocumentVersionFilter {
|
||||
status: DocumentVersionStatus
|
||||
statuses: [DocumentVersionStatus!]
|
||||
}
|
||||
|
||||
# Input Types for Filtering
|
||||
@@ -2441,13 +2441,6 @@ type Document implements Node {
|
||||
classification: DocumentClassification!
|
||||
currentPublishedVersion: Int
|
||||
trustCenterVisibility: TrustCenterVisibility!
|
||||
approvers(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: ProfileOrder
|
||||
): ProfileConnection! @goField(forceResolver: true)
|
||||
organization: Organization! @goField(forceResolver: true)
|
||||
|
||||
versions(
|
||||
@@ -2477,16 +2470,17 @@ type Document implements Node {
|
||||
permission(action: String!): Boolean! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
type SignableDocument
|
||||
type EmployeeDocument
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SignableDocument"
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocument"
|
||||
) {
|
||||
id: ID!
|
||||
title: String!
|
||||
description: String
|
||||
documentType: DocumentType!
|
||||
classification: DocumentClassification!
|
||||
signed: Boolean! @goField(forceResolver: true)
|
||||
signed: Boolean @goField(forceResolver: true)
|
||||
approvalState: DocumentVersionApprovalDecisionState @goField(forceResolver: true)
|
||||
|
||||
versions(
|
||||
first: Int
|
||||
@@ -2494,13 +2488,26 @@ type SignableDocument
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: DocumentVersionOrder
|
||||
filter: DocumentVersionFilter
|
||||
): DocumentVersionConnection! @goField(forceResolver: true)
|
||||
): EmployeeDocumentVersionConnection! @goField(forceResolver: true)
|
||||
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
type EmployeeDocumentVersion
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentVersion"
|
||||
) {
|
||||
id: ID!
|
||||
version: Int!
|
||||
status: DocumentVersionStatus!
|
||||
signed: Boolean! @goField(forceResolver: true)
|
||||
approvalDecision: DocumentVersionApprovalDecision @goField(forceResolver: true)
|
||||
publishedAt: Datetime
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
type Meeting implements Node {
|
||||
id: ID!
|
||||
name: String!
|
||||
@@ -2952,9 +2959,20 @@ type Viewer {
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: DocumentOrder
|
||||
): SignableDocumentConnection! @goField(forceResolver: true)
|
||||
): EmployeeDocumentConnection! @goField(forceResolver: true)
|
||||
|
||||
signableDocument(id: ID!): SignableDocument @goField(forceResolver: true)
|
||||
signableDocument(id: ID!): EmployeeDocument @goField(forceResolver: true)
|
||||
|
||||
approvableDocuments(
|
||||
organizationId: ID!
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: DocumentOrder
|
||||
): EmployeeDocumentConnection! @goField(forceResolver: true)
|
||||
|
||||
approvableDocument(id: ID!): EmployeeDocument @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
type TrustCenterConnection {
|
||||
@@ -3226,20 +3244,36 @@ type EvidenceEdge {
|
||||
node: Evidence!
|
||||
}
|
||||
|
||||
type SignableDocumentConnection
|
||||
type EmployeeDocumentConnection
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SignableDocumentConnection"
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentConnection"
|
||||
) {
|
||||
edges: [SignableDocumentEdge!]!
|
||||
edges: [EmployeeDocumentEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type SignableDocumentEdge
|
||||
type EmployeeDocumentEdge
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SignableDocumentEdge"
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentEdge"
|
||||
) {
|
||||
cursor: CursorKey!
|
||||
node: SignableDocument!
|
||||
node: EmployeeDocument!
|
||||
}
|
||||
|
||||
type EmployeeDocumentVersionConnection
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentVersionConnection"
|
||||
) {
|
||||
edges: [EmployeeDocumentVersionEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type EmployeeDocumentVersionEdge
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentVersionEdge"
|
||||
) {
|
||||
cursor: CursorKey!
|
||||
node: EmployeeDocumentVersion!
|
||||
}
|
||||
|
||||
type DocumentConnection
|
||||
@@ -3762,6 +3796,9 @@ type Mutation {
|
||||
bulkPublishDocumentVersions(
|
||||
input: BulkPublishDocumentVersionsInput!
|
||||
): BulkPublishDocumentVersionsPayload!
|
||||
requestDocumentVersionApproval(
|
||||
input: RequestDocumentVersionApprovalInput!
|
||||
): RequestDocumentVersionApprovalPayload!
|
||||
bulkDeleteDocuments(
|
||||
input: BulkDeleteDocumentsInput!
|
||||
): BulkDeleteDocumentsPayload!
|
||||
@@ -3797,6 +3834,18 @@ type Mutation {
|
||||
input: CancelSignatureRequestInput!
|
||||
): CancelSignatureRequestPayload!
|
||||
signDocument(input: SignDocumentInput!): SignDocumentPayload!
|
||||
addDocumentVersionApprover(
|
||||
input: AddDocumentVersionApproverInput!
|
||||
): AddDocumentVersionApproverPayload!
|
||||
removeDocumentVersionApprover(
|
||||
input: RemoveDocumentVersionApproverInput!
|
||||
): RemoveDocumentVersionApproverPayload!
|
||||
approveDocumentVersion(
|
||||
input: ApproveDocumentVersionInput!
|
||||
): ApproveDocumentVersionPayload!
|
||||
rejectDocumentVersion(
|
||||
input: RejectDocumentVersionInput!
|
||||
): RejectDocumentVersionPayload!
|
||||
|
||||
exportDocumentVersionPDF(
|
||||
input: ExportDocumentVersionPDFInput!
|
||||
@@ -4409,7 +4458,6 @@ input CreateDocumentInput {
|
||||
organizationId: ID!
|
||||
title: String!
|
||||
content: String!
|
||||
approverIds: [ID!]!
|
||||
documentType: DocumentType!
|
||||
classification: DocumentClassification!
|
||||
trustCenterVisibility: TrustCenterVisibility
|
||||
@@ -4419,7 +4467,6 @@ input UpdateDocumentInput {
|
||||
id: ID!
|
||||
title: String
|
||||
content: String
|
||||
approverIds: [ID!]
|
||||
documentType: DocumentType
|
||||
classification: DocumentClassification
|
||||
trustCenterVisibility: TrustCenterVisibility
|
||||
@@ -5324,6 +5371,14 @@ type DocumentVersion implements Node {
|
||||
filter: DocumentVersionSignatureFilter
|
||||
): DocumentVersionSignatureConnection! @goField(forceResolver: true)
|
||||
|
||||
approvalQuorums(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: DocumentVersionApprovalQuorumOrder
|
||||
): DocumentVersionApprovalQuorumConnection! @goField(forceResolver: true)
|
||||
|
||||
signed: Boolean! @goField(forceResolver: true)
|
||||
|
||||
publishedAt: Datetime
|
||||
@@ -5398,6 +5453,170 @@ type DocumentVersionSignature implements Node {
|
||||
permission(action: String!): Boolean! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
enum DocumentVersionApprovalDecisionState
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionState"
|
||||
) {
|
||||
PENDING
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStatePending"
|
||||
)
|
||||
APPROVED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateApproved"
|
||||
)
|
||||
REJECTED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateRejected"
|
||||
)
|
||||
}
|
||||
|
||||
enum DocumentVersionApprovalDecisionOrderField
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionOrderField"
|
||||
) {
|
||||
CREATED_AT
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionOrderFieldCreatedAt"
|
||||
)
|
||||
}
|
||||
|
||||
enum DocumentVersionApprovalQuorumStatus
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatus"
|
||||
) {
|
||||
PENDING
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusPending"
|
||||
)
|
||||
APPROVED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusApproved"
|
||||
)
|
||||
REJECTED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusRejected"
|
||||
)
|
||||
}
|
||||
|
||||
enum DocumentVersionApprovalQuorumOrderField
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumOrderField"
|
||||
) {
|
||||
CREATED_AT
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumOrderFieldCreatedAt"
|
||||
)
|
||||
}
|
||||
|
||||
input DocumentVersionApprovalQuorumOrder {
|
||||
field: DocumentVersionApprovalQuorumOrderField!
|
||||
direction: OrderDirection!
|
||||
}
|
||||
|
||||
type DocumentVersionApprovalQuorumConnection
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DocumentVersionApprovalQuorumConnection"
|
||||
) {
|
||||
edges: [DocumentVersionApprovalQuorumEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
type DocumentVersionApprovalQuorumEdge {
|
||||
cursor: CursorKey!
|
||||
node: DocumentVersionApprovalQuorum!
|
||||
}
|
||||
|
||||
type DocumentVersionApprovalQuorum implements Node {
|
||||
id: ID!
|
||||
documentVersion: DocumentVersion! @goField(forceResolver: true)
|
||||
status: DocumentVersionApprovalQuorumStatus!
|
||||
decisions(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: DocumentVersionApprovalDecisionOrder
|
||||
filter: DocumentVersionApprovalDecisionFilter
|
||||
): DocumentVersionApprovalDecisionConnection! @goField(forceResolver: true)
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
permission(action: String!): Boolean! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
input DocumentVersionApprovalDecisionFilter {
|
||||
states: [DocumentVersionApprovalDecisionState!]
|
||||
}
|
||||
|
||||
input DocumentVersionApprovalDecisionOrder {
|
||||
field: DocumentVersionApprovalDecisionOrderField!
|
||||
direction: OrderDirection!
|
||||
}
|
||||
|
||||
type DocumentVersionApprovalDecisionConnection
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DocumentVersionApprovalDecisionConnection"
|
||||
) {
|
||||
edges: [DocumentVersionApprovalDecisionEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
type DocumentVersionApprovalDecisionEdge {
|
||||
cursor: CursorKey!
|
||||
node: DocumentVersionApprovalDecision!
|
||||
}
|
||||
|
||||
type DocumentVersionApprovalDecision implements Node {
|
||||
id: ID!
|
||||
quorum: DocumentVersionApprovalQuorum! @goField(forceResolver: true)
|
||||
documentVersion: DocumentVersion! @goField(forceResolver: true)
|
||||
approver: Profile! @goField(forceResolver: true)
|
||||
state: DocumentVersionApprovalDecisionState!
|
||||
comment: String
|
||||
decidedAt: Datetime
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
permission(action: String!): Boolean! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
input ApproveDocumentVersionInput {
|
||||
documentVersionId: ID!
|
||||
comment: String
|
||||
}
|
||||
|
||||
type ApproveDocumentVersionPayload {
|
||||
approvalDecision: DocumentVersionApprovalDecision!
|
||||
}
|
||||
|
||||
input RejectDocumentVersionInput {
|
||||
documentVersionId: ID!
|
||||
comment: String
|
||||
}
|
||||
|
||||
type RejectDocumentVersionPayload {
|
||||
approvalDecision: DocumentVersionApprovalDecision!
|
||||
}
|
||||
|
||||
input AddDocumentVersionApproverInput {
|
||||
documentVersionId: ID!
|
||||
approverId: ID!
|
||||
}
|
||||
|
||||
type AddDocumentVersionApproverPayload {
|
||||
approvalDecisionEdge: DocumentVersionApprovalDecisionEdge!
|
||||
}
|
||||
|
||||
input RemoveDocumentVersionApproverInput {
|
||||
approvalDecisionId: ID!
|
||||
}
|
||||
|
||||
type RemoveDocumentVersionApproverPayload {
|
||||
deletedApprovalDecisionId: ID!
|
||||
documentVersion: DocumentVersion!
|
||||
}
|
||||
|
||||
input RequestSignatureInput {
|
||||
documentVersionId: ID!
|
||||
signatoryId: ID!
|
||||
@@ -5416,16 +5635,6 @@ type BulkRequestSignaturesPayload {
|
||||
documentVersionSignatureEdges: [DocumentVersionSignatureEdge!]!
|
||||
}
|
||||
|
||||
input BulkPublishDocumentVersionsInput {
|
||||
documentIds: [ID!]!
|
||||
changelog: String!
|
||||
}
|
||||
|
||||
type BulkPublishDocumentVersionsPayload {
|
||||
documentVersionEdges: [DocumentVersionEdge!]!
|
||||
documentEdges: [DocumentEdge!]!
|
||||
}
|
||||
|
||||
input BulkDeleteDocumentsInput {
|
||||
documentIds: [ID!]!
|
||||
}
|
||||
@@ -5461,14 +5670,34 @@ type BulkExportDocumentsPayload {
|
||||
exportJobId: ID!
|
||||
}
|
||||
|
||||
input RequestDocumentVersionApprovalInput {
|
||||
documentId: ID!
|
||||
approverIds: [ID!]!
|
||||
changelog: String
|
||||
}
|
||||
|
||||
type RequestDocumentVersionApprovalPayload {
|
||||
approvalQuorum: DocumentVersionApprovalQuorum!
|
||||
}
|
||||
|
||||
input PublishDocumentVersionInput {
|
||||
documentId: ID!
|
||||
changelog: String
|
||||
}
|
||||
|
||||
type PublishDocumentVersionPayload {
|
||||
documentVersion: DocumentVersion!
|
||||
document: Document!
|
||||
documentVersion: DocumentVersion!
|
||||
}
|
||||
|
||||
input BulkPublishDocumentVersionsInput {
|
||||
documentIds: [ID!]!
|
||||
changelog: String!
|
||||
}
|
||||
|
||||
type BulkPublishDocumentVersionsPayload {
|
||||
documentVersions: [DocumentVersion!]!
|
||||
documents: [Document!]!
|
||||
}
|
||||
|
||||
type CreateDraftDocumentVersionPayload {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalDecisionOrderBy OrderBy[coredata.DocumentVersionApprovalDecisionOrderField]
|
||||
|
||||
DocumentVersionApprovalDecisionConnection struct {
|
||||
TotalCount int
|
||||
Edges []*DocumentVersionApprovalDecisionEdge
|
||||
PageInfo PageInfo
|
||||
|
||||
Resolver any
|
||||
ParentID gid.GID
|
||||
Filters *coredata.DocumentVersionApprovalDecisionFilter
|
||||
}
|
||||
)
|
||||
|
||||
func NewDocumentVersionApprovalDecisionConnection(
|
||||
page *page.Page[*coredata.DocumentVersionApprovalDecision, coredata.DocumentVersionApprovalDecisionOrderField],
|
||||
parentType any,
|
||||
parentID gid.GID,
|
||||
filter *coredata.DocumentVersionApprovalDecisionFilter,
|
||||
) *DocumentVersionApprovalDecisionConnection {
|
||||
edges := make([]*DocumentVersionApprovalDecisionEdge, len(page.Data))
|
||||
for i, decision := range page.Data {
|
||||
edges[i] = NewDocumentVersionApprovalDecisionEdge(decision, page.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &DocumentVersionApprovalDecisionConnection{
|
||||
Edges: edges,
|
||||
PageInfo: *NewPageInfo(page),
|
||||
|
||||
Resolver: parentType,
|
||||
ParentID: parentID,
|
||||
Filters: filter,
|
||||
}
|
||||
}
|
||||
|
||||
func NewDocumentVersionApprovalDecisionEdge(decision *coredata.DocumentVersionApprovalDecision, orderBy coredata.DocumentVersionApprovalDecisionOrderField) *DocumentVersionApprovalDecisionEdge {
|
||||
return &DocumentVersionApprovalDecisionEdge{
|
||||
Cursor: decision.CursorKey(orderBy),
|
||||
Node: NewDocumentVersionApprovalDecision(decision),
|
||||
}
|
||||
}
|
||||
|
||||
func NewDocumentVersionApprovalDecision(decision *coredata.DocumentVersionApprovalDecision) *DocumentVersionApprovalDecision {
|
||||
return &DocumentVersionApprovalDecision{
|
||||
Quorum: &DocumentVersionApprovalQuorum{
|
||||
ID: decision.QuorumID,
|
||||
},
|
||||
Approver: &Profile{
|
||||
ID: decision.ApproverID,
|
||||
},
|
||||
ID: decision.ID,
|
||||
State: decision.State,
|
||||
Comment: decision.Comment,
|
||||
DecidedAt: decision.DecidedAt,
|
||||
CreatedAt: decision.CreatedAt,
|
||||
UpdatedAt: decision.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalQuorumConnection struct {
|
||||
TotalCount int
|
||||
Edges []*DocumentVersionApprovalQuorumEdge
|
||||
PageInfo PageInfo
|
||||
|
||||
Resolver any
|
||||
ParentID gid.GID
|
||||
}
|
||||
)
|
||||
|
||||
func NewDocumentVersionApprovalQuorumConnection(
|
||||
page *page.Page[*coredata.DocumentVersionApprovalQuorum, coredata.DocumentVersionApprovalQuorumOrderField],
|
||||
parentType any,
|
||||
parentID gid.GID,
|
||||
) *DocumentVersionApprovalQuorumConnection {
|
||||
edges := make([]*DocumentVersionApprovalQuorumEdge, len(page.Data))
|
||||
for i, quorum := range page.Data {
|
||||
edges[i] = NewDocumentVersionApprovalQuorumEdge(quorum, page.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &DocumentVersionApprovalQuorumConnection{
|
||||
Edges: edges,
|
||||
PageInfo: *NewPageInfo(page),
|
||||
|
||||
Resolver: parentType,
|
||||
ParentID: parentID,
|
||||
}
|
||||
}
|
||||
|
||||
func NewDocumentVersionApprovalQuorumEdge(
|
||||
quorum *coredata.DocumentVersionApprovalQuorum,
|
||||
orderBy coredata.DocumentVersionApprovalQuorumOrderField,
|
||||
) *DocumentVersionApprovalQuorumEdge {
|
||||
return &DocumentVersionApprovalQuorumEdge{
|
||||
Cursor: quorum.CursorKey(orderBy),
|
||||
Node: NewDocumentVersionApprovalQuorum(quorum),
|
||||
}
|
||||
}
|
||||
|
||||
func NewDocumentVersionApprovalQuorum(quorum *coredata.DocumentVersionApprovalQuorum) *DocumentVersionApprovalQuorum {
|
||||
return &DocumentVersionApprovalQuorum{
|
||||
ID: quorum.ID,
|
||||
DocumentVersion: &DocumentVersion{
|
||||
ID: quorum.VersionID,
|
||||
},
|
||||
Status: quorum.Status,
|
||||
CreatedAt: quorum.CreatedAt,
|
||||
UpdatedAt: quorum.UpdatedAt,
|
||||
}
|
||||
}
|
||||
141
pkg/server/api/console/v1/types/employee_document.go
Normal file
141
pkg/server/api/console/v1/types/employee_document.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type EmployeeDocumentFilterMode int
|
||||
|
||||
const (
|
||||
EmployeeDocumentFilterModeSignature EmployeeDocumentFilterMode = iota
|
||||
EmployeeDocumentFilterModeApproval
|
||||
)
|
||||
|
||||
type (
|
||||
EmployeeDocumentConnection struct {
|
||||
Edges []*EmployeeDocumentEdge
|
||||
PageInfo *PageInfo
|
||||
}
|
||||
|
||||
EmployeeDocumentEdge struct {
|
||||
Cursor page.CursorKey
|
||||
Node *EmployeeDocument
|
||||
}
|
||||
|
||||
EmployeeDocument struct {
|
||||
ID gid.GID
|
||||
Title string
|
||||
Description *string
|
||||
DocumentType coredata.DocumentType
|
||||
Classification coredata.DocumentClassification
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
FilterMode EmployeeDocumentFilterMode
|
||||
}
|
||||
|
||||
EmployeeDocumentVersionConnection struct {
|
||||
Edges []*EmployeeDocumentVersionEdge
|
||||
PageInfo *PageInfo
|
||||
}
|
||||
|
||||
EmployeeDocumentVersionEdge struct {
|
||||
Cursor page.CursorKey
|
||||
Node *EmployeeDocumentVersion
|
||||
}
|
||||
|
||||
EmployeeDocumentVersion struct {
|
||||
ID gid.GID
|
||||
OrganizationID gid.GID
|
||||
Version int
|
||||
Status coredata.DocumentVersionStatus
|
||||
PublishedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
)
|
||||
|
||||
func NewEmployeeDocumentConnection(
|
||||
p *page.Page[*EmployeeDocument, coredata.DocumentOrderField],
|
||||
) *EmployeeDocumentConnection {
|
||||
var edges = make([]*EmployeeDocumentEdge, len(p.Data))
|
||||
|
||||
for i := range edges {
|
||||
edges[i] = NewEmployeeDocumentEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &EmployeeDocumentConnection{
|
||||
Edges: edges,
|
||||
PageInfo: NewPageInfo(p),
|
||||
}
|
||||
}
|
||||
|
||||
func NewEmployeeDocumentEdge(document *EmployeeDocument, orderBy coredata.DocumentOrderField) *EmployeeDocumentEdge {
|
||||
return &EmployeeDocumentEdge{
|
||||
Cursor: document.CursorKey(orderBy),
|
||||
Node: document,
|
||||
}
|
||||
}
|
||||
|
||||
func (d EmployeeDocument) CursorKey(orderBy coredata.DocumentOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case coredata.DocumentOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(d.ID, d.CreatedAt)
|
||||
case coredata.DocumentOrderFieldTitle:
|
||||
return page.NewCursorKey(d.ID, d.Title)
|
||||
case coredata.DocumentOrderFieldDocumentType:
|
||||
return page.NewCursorKey(d.ID, d.DocumentType)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func NewEmployeeDocumentVersionConnection(
|
||||
p *page.Page[*EmployeeDocumentVersion, coredata.DocumentVersionOrderField],
|
||||
) *EmployeeDocumentVersionConnection {
|
||||
var edges = make([]*EmployeeDocumentVersionEdge, len(p.Data))
|
||||
|
||||
for i := range edges {
|
||||
edges[i] = NewEmployeeDocumentVersionEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &EmployeeDocumentVersionConnection{
|
||||
Edges: edges,
|
||||
PageInfo: NewPageInfo(p),
|
||||
}
|
||||
}
|
||||
|
||||
func NewEmployeeDocumentVersionEdge(version *EmployeeDocumentVersion, orderBy coredata.DocumentVersionOrderField) *EmployeeDocumentVersionEdge {
|
||||
return &EmployeeDocumentVersionEdge{
|
||||
Cursor: version.CursorKey(orderBy),
|
||||
Node: version,
|
||||
}
|
||||
}
|
||||
|
||||
func (v EmployeeDocumentVersion) CursorKey(orderBy coredata.DocumentVersionOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case coredata.DocumentVersionOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(v.ID, v.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
SignableDocumentConnection struct {
|
||||
Edges []*SignableDocumentEdge
|
||||
PageInfo *PageInfo
|
||||
}
|
||||
|
||||
SignableDocumentEdge struct {
|
||||
Cursor page.CursorKey
|
||||
Node *SignableDocument
|
||||
}
|
||||
|
||||
SignableDocument struct {
|
||||
ID gid.GID
|
||||
Title string
|
||||
Description *string
|
||||
DocumentType coredata.DocumentType
|
||||
Classification coredata.DocumentClassification
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
)
|
||||
|
||||
func (SignableDocument) IsNode() {}
|
||||
func (d SignableDocument) GetID() gid.GID { return d.ID }
|
||||
|
||||
func NewSignableDocumentConnection(
|
||||
p *page.Page[*SignableDocument, coredata.DocumentOrderField],
|
||||
) *SignableDocumentConnection {
|
||||
var edges = make([]*SignableDocumentEdge, len(p.Data))
|
||||
|
||||
for i := range edges {
|
||||
edges[i] = NewSignableDocumentEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &SignableDocumentConnection{
|
||||
Edges: edges,
|
||||
PageInfo: NewPageInfo(p),
|
||||
}
|
||||
}
|
||||
|
||||
func NewSignableDocumentEdge(document *SignableDocument, orderBy coredata.DocumentOrderField) *SignableDocumentEdge {
|
||||
return &SignableDocumentEdge{
|
||||
Cursor: document.CursorKey(orderBy),
|
||||
Node: document,
|
||||
}
|
||||
}
|
||||
|
||||
func (d SignableDocument) CursorKey(orderBy coredata.DocumentOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case coredata.DocumentOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(d.ID, d.CreatedAt)
|
||||
case coredata.DocumentOrderFieldTitle:
|
||||
return page.NewCursorKey(d.ID, d.Title)
|
||||
case coredata.DocumentOrderFieldDocumentType:
|
||||
return page.NewCursorKey(d.ID, d.DocumentType)
|
||||
}
|
||||
|
||||
panic("unsupported order by")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package mcp_v1
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
func allApproversCursor() *page.Cursor[coredata.MembershipProfileOrderField] {
|
||||
return page.NewCursor(
|
||||
100,
|
||||
nil,
|
||||
page.Head,
|
||||
page.OrderBy[coredata.MembershipProfileOrderField]{
|
||||
Field: coredata.MembershipProfileOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func profileIDs(p *page.Page[*coredata.MembershipProfile, coredata.MembershipProfileOrderField]) []gid.GID {
|
||||
ids := make([]gid.GID, len(p.Data))
|
||||
for i, profile := range p.Data {
|
||||
ids[i] = profile.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -1685,16 +1685,7 @@ func (r *Resolver) ListControlDocumentsTool(ctx context.Context, req *mcp.CallTo
|
||||
return nil, types.ListControlDocumentsOutput{}, fmt.Errorf("failed to list control documents: %w", err)
|
||||
}
|
||||
|
||||
approverIDsMap := make(map[gid.GID][]gid.GID)
|
||||
for _, d := range docPage.Data {
|
||||
approverPage, err := prb.Documents.ListApprovers(ctx, d.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
return nil, types.ListControlDocumentsOutput{}, fmt.Errorf("failed to list document approvers: %w", err)
|
||||
}
|
||||
approverIDsMap[d.ID] = profileIDs(approverPage)
|
||||
}
|
||||
|
||||
return nil, types.NewListControlDocumentsOutput(docPage, approverIDsMap), nil
|
||||
return nil, types.NewListControlDocumentsOutput(docPage), nil
|
||||
}
|
||||
|
||||
func (r *Resolver) ListControlAuditsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListControlAuditsInput) (*mcp.CallToolResult, types.ListControlAuditsOutput, error) {
|
||||
@@ -2059,16 +2050,7 @@ func (r *Resolver) ListDocumentsTool(ctx context.Context, req *mcp.CallToolReque
|
||||
panic(fmt.Errorf("cannot list organization documents: %w", err))
|
||||
}
|
||||
|
||||
approverIDsMap := make(map[gid.GID][]gid.GID)
|
||||
for _, d := range docPage.Data {
|
||||
approverPage, err := prb.Documents.ListApprovers(ctx, d.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document approvers: %w", err))
|
||||
}
|
||||
approverIDsMap[d.ID] = profileIDs(approverPage)
|
||||
}
|
||||
|
||||
return nil, types.NewListDocumentsOutput(docPage, approverIDsMap), nil
|
||||
return nil, types.NewListDocumentsOutput(docPage), nil
|
||||
}
|
||||
|
||||
func (r *Resolver) GetDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDocumentInput) (*mcp.CallToolResult, types.GetDocumentOutput, error) {
|
||||
@@ -2081,13 +2063,8 @@ func (r *Resolver) GetDocumentTool(ctx context.Context, req *mcp.CallToolRequest
|
||||
panic(fmt.Errorf("cannot get document: %w", err))
|
||||
}
|
||||
|
||||
approverPage, err := prb.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document approvers: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.GetDocumentOutput{
|
||||
Document: types.NewDocument(document, profileIDs(approverPage)),
|
||||
Document: types.NewDocument(document),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2107,7 +2084,6 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest
|
||||
OrganizationID: input.OrganizationID,
|
||||
Title: input.Title,
|
||||
Content: input.Content,
|
||||
ApproverIDs: input.ApproverIds,
|
||||
Classification: input.Classification,
|
||||
DocumentType: input.DocumentType,
|
||||
TrustCenterVisibility: trustCenterVisibility,
|
||||
@@ -2117,7 +2093,7 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest
|
||||
panic(fmt.Errorf("cannot create document: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.NewAddDocumentOutput(document, documentVersion, input.ApproverIds, input.ApproverIds), nil
|
||||
return nil, types.NewAddDocumentOutput(document, documentVersion), nil
|
||||
}
|
||||
|
||||
func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateDocumentInput) (*mcp.CallToolResult, types.UpdateDocumentOutput, error) {
|
||||
@@ -2130,7 +2106,6 @@ func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequ
|
||||
probo.UpdateDocumentRequest{
|
||||
DocumentID: input.ID,
|
||||
Title: input.Title,
|
||||
ApproverIDs: input.ApproverIds,
|
||||
Classification: input.Classification,
|
||||
DocumentType: input.DocumentType,
|
||||
TrustCenterVisibility: input.TrustCenterVisibility,
|
||||
@@ -2140,13 +2115,8 @@ func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequ
|
||||
panic(fmt.Errorf("cannot update document: %w", err))
|
||||
}
|
||||
|
||||
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document approvers: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.UpdateDocumentOutput{
|
||||
Document: types.NewDocument(document, profileIDs(approverPage)),
|
||||
Document: types.NewDocument(document),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2172,16 +2142,7 @@ func (r *Resolver) ListDocumentVersionsTool(ctx context.Context, req *mcp.CallTo
|
||||
panic(fmt.Errorf("cannot list document versions: %w", err))
|
||||
}
|
||||
|
||||
approverIDsMap := make(map[gid.GID][]gid.GID)
|
||||
for _, v := range versionPage.Data {
|
||||
approverPage, err := svc.Documents.ListVersionApprovers(ctx, v.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document version approvers: %w", err))
|
||||
}
|
||||
approverIDsMap[v.ID] = profileIDs(approverPage)
|
||||
}
|
||||
|
||||
return nil, types.NewListDocumentVersionsOutput(versionPage, approverIDsMap), nil
|
||||
return nil, types.NewListDocumentVersionsOutput(versionPage), nil
|
||||
}
|
||||
|
||||
func (r *Resolver) GetDocumentVersionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDocumentVersionInput) (*mcp.CallToolResult, types.GetDocumentVersionOutput, error) {
|
||||
@@ -2194,13 +2155,8 @@ func (r *Resolver) GetDocumentVersionTool(ctx context.Context, req *mcp.CallTool
|
||||
panic(fmt.Errorf("cannot get document version: %w", err))
|
||||
}
|
||||
|
||||
approverPage, err := svc.Documents.ListVersionApprovers(ctx, input.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document version approvers: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.GetDocumentVersionOutput{
|
||||
DocumentVersion: types.NewDocumentVersion(version, profileIDs(approverPage)),
|
||||
DocumentVersion: types.NewDocumentVersion(version),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2214,13 +2170,8 @@ func (r *Resolver) CreateDraftDocumentVersionTool(ctx context.Context, req *mcp.
|
||||
panic(fmt.Errorf("cannot create draft document version: %w", err))
|
||||
}
|
||||
|
||||
approverPage, err := svc.Documents.ListVersionApprovers(ctx, draftVersion.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document version approvers: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.CreateDraftDocumentVersionOutput{
|
||||
DocumentVersion: types.NewDocumentVersion(draftVersion, profileIDs(approverPage)),
|
||||
DocumentVersion: types.NewDocumentVersion(draftVersion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2240,13 +2191,8 @@ func (r *Resolver) UpdateDocumentVersionTool(ctx context.Context, req *mcp.CallT
|
||||
panic(fmt.Errorf("cannot update document version: %w", err))
|
||||
}
|
||||
|
||||
versionApproverPage, err := svc.Documents.ListVersionApprovers(ctx, documentVersion.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document version approvers: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.UpdateDocumentVersionOutput{
|
||||
DocumentVersion: types.NewDocumentVersion(documentVersion, profileIDs(versionApproverPage)),
|
||||
DocumentVersion: types.NewDocumentVersion(documentVersion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2262,19 +2208,9 @@ func (r *Resolver) PublishDocumentVersionTool(ctx context.Context, req *mcp.Call
|
||||
panic(fmt.Errorf("cannot publish document version: %w", err))
|
||||
}
|
||||
|
||||
docApproverPage, err := svc.Documents.ListApprovers(ctx, document.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document approvers: %w", err))
|
||||
}
|
||||
|
||||
versionApproverPage, err := svc.Documents.ListVersionApprovers(ctx, documentVersion.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document version approvers: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.PublishDocumentVersionOutput{
|
||||
Document: types.NewDocument(document, profileIDs(docApproverPage)),
|
||||
DocumentVersion: types.NewDocumentVersion(documentVersion, profileIDs(versionApproverPage)),
|
||||
Document: types.NewDocument(document),
|
||||
DocumentVersion: types.NewDocumentVersion(documentVersion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3243,13 +3179,8 @@ func (r *Resolver) ArchiveDocumentTool(ctx context.Context, req *mcp.CallToolReq
|
||||
return nil, types.ArchiveDocumentOutput{}, fmt.Errorf("cannot archive document: %w", err)
|
||||
}
|
||||
|
||||
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
return nil, types.ArchiveDocumentOutput{}, fmt.Errorf("cannot list document approvers: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.ArchiveDocumentOutput{
|
||||
Document: types.NewDocument(document, profileIDs(approverPage)),
|
||||
Document: types.NewDocument(document),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3263,13 +3194,8 @@ func (r *Resolver) UnarchiveDocumentTool(ctx context.Context, req *mcp.CallToolR
|
||||
return nil, types.UnarchiveDocumentOutput{}, fmt.Errorf("cannot unarchive document: %w", err)
|
||||
}
|
||||
|
||||
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
return nil, types.UnarchiveDocumentOutput{}, fmt.Errorf("cannot list document approvers: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.UnarchiveDocumentOutput{
|
||||
Document: types.NewDocument(document, profileIDs(approverPage)),
|
||||
Document: types.NewDocument(document),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3374,3 +3300,27 @@ func (r *Resolver) GetAuditLogEntryTool(ctx context.Context, req *mcp.CallToolRe
|
||||
AuditLogEntry: types.NewAuditLogEntry(entry),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) RequestDocumentVersionApprovalTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RequestDocumentVersionApprovalInput) (*mcp.CallToolResult, types.RequestDocumentVersionApprovalOutput, error) {
|
||||
r.MustAuthorize(ctx, input.DocumentID, probo.ActionDocumentVersionRequestApproval)
|
||||
|
||||
svc := r.ProboService(ctx, input.DocumentID)
|
||||
|
||||
quorum, err := svc.DocumentApprovals.RequestApproval(ctx, probo.RequestApprovalRequest{
|
||||
DocumentID: input.DocumentID,
|
||||
ApproverIDs: input.ApproverIds,
|
||||
Changelog: input.Changelog,
|
||||
})
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot request document version approval: %w", err))
|
||||
}
|
||||
|
||||
documentVersion, err := svc.Documents.GetVersion(ctx, quorum.VersionID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot get document version: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.RequestDocumentVersionApprovalOutput{
|
||||
DocumentVersion: types.NewDocumentVersion(documentVersion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -5184,7 +5184,6 @@ components:
|
||||
required:
|
||||
- id
|
||||
- organization_id
|
||||
- approver_ids
|
||||
- title
|
||||
- document_type
|
||||
- classification
|
||||
@@ -5199,11 +5198,6 @@ components:
|
||||
organization_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Organization ID
|
||||
approver_ids:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Approver IDs
|
||||
title:
|
||||
type: string
|
||||
description: Document title
|
||||
@@ -5246,7 +5240,6 @@ components:
|
||||
- organization_id
|
||||
- document_id
|
||||
- title
|
||||
- approver_ids
|
||||
- version_number
|
||||
- classification
|
||||
- content
|
||||
@@ -5267,11 +5260,6 @@ components:
|
||||
title:
|
||||
type: string
|
||||
description: Document version title
|
||||
approver_ids:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Approver IDs
|
||||
version_number:
|
||||
type: integer
|
||||
description: Version number
|
||||
@@ -5418,7 +5406,6 @@ components:
|
||||
- organization_id
|
||||
- title
|
||||
- content
|
||||
- approver_ids
|
||||
- classification
|
||||
- document_type
|
||||
properties:
|
||||
@@ -5431,11 +5418,6 @@ components:
|
||||
content:
|
||||
type: string
|
||||
description: Document content
|
||||
approver_ids:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Approver IDs
|
||||
classification:
|
||||
$ref: "#/components/schemas/DocumentClassification"
|
||||
description: Document classification
|
||||
@@ -5468,11 +5450,6 @@ components:
|
||||
title:
|
||||
type: string
|
||||
description: Document title
|
||||
approver_ids:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Approver IDs
|
||||
classification:
|
||||
$ref: "#/components/schemas/DocumentClassification"
|
||||
description: Document classification
|
||||
@@ -5639,7 +5616,7 @@ components:
|
||||
description: Document ID
|
||||
changelog:
|
||||
type: string
|
||||
description: Changelog
|
||||
description: Changelog for this version
|
||||
|
||||
PublishDocumentVersionOutput:
|
||||
type: object
|
||||
@@ -5652,6 +5629,32 @@ components:
|
||||
document_version:
|
||||
$ref: "#/components/schemas/DocumentVersion"
|
||||
|
||||
RequestDocumentVersionApprovalInput:
|
||||
type: object
|
||||
required:
|
||||
- document_id
|
||||
- approver_ids
|
||||
properties:
|
||||
document_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Document ID
|
||||
approver_ids:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Approver profile IDs
|
||||
changelog:
|
||||
type: string
|
||||
description: Changelog for this version
|
||||
|
||||
RequestDocumentVersionApprovalOutput:
|
||||
type: object
|
||||
required:
|
||||
- document_version
|
||||
properties:
|
||||
document_version:
|
||||
$ref: "#/components/schemas/DocumentVersion"
|
||||
|
||||
DeleteDocumentInput:
|
||||
type: object
|
||||
required:
|
||||
@@ -7476,6 +7479,14 @@ tools:
|
||||
$ref: "#/components/schemas/PublishDocumentVersionInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/PublishDocumentVersionOutput"
|
||||
- name: requestDocumentVersionApproval
|
||||
description: Request approval for a document version
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/RequestDocumentVersionApprovalInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/RequestDocumentVersionApprovalOutput"
|
||||
- name: deleteDocument
|
||||
description: Delete a document
|
||||
hints:
|
||||
|
||||
@@ -16,15 +16,13 @@ package types
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
func NewDocument(d *coredata.Document, approverIDs []gid.GID) *Document {
|
||||
func NewDocument(d *coredata.Document) *Document {
|
||||
return &Document{
|
||||
ID: d.ID,
|
||||
OrganizationID: d.OrganizationID,
|
||||
ApproverIds: approverIDs,
|
||||
Title: d.Title,
|
||||
DocumentType: d.DocumentType,
|
||||
Classification: d.Classification,
|
||||
@@ -37,10 +35,10 @@ func NewDocument(d *coredata.Document, approverIDs []gid.GID) *Document {
|
||||
}
|
||||
}
|
||||
|
||||
func NewListControlDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField], approverIDsMap map[gid.GID][]gid.GID) ListControlDocumentsOutput {
|
||||
func NewListControlDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField]) ListControlDocumentsOutput {
|
||||
documents := make([]*Document, 0, len(documentPage.Data))
|
||||
for _, d := range documentPage.Data {
|
||||
documents = append(documents, NewDocument(d, approverIDsMap[d.ID]))
|
||||
documents = append(documents, NewDocument(d))
|
||||
}
|
||||
|
||||
var nextCursor *page.CursorKey
|
||||
@@ -55,10 +53,10 @@ func NewListControlDocumentsOutput(documentPage *page.Page[*coredata.Document, c
|
||||
}
|
||||
}
|
||||
|
||||
func NewListDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField], approverIDsMap map[gid.GID][]gid.GID) ListDocumentsOutput {
|
||||
func NewListDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField]) ListDocumentsOutput {
|
||||
documents := make([]*Document, 0, len(documentPage.Data))
|
||||
for _, d := range documentPage.Data {
|
||||
documents = append(documents, NewDocument(d, approverIDsMap[d.ID]))
|
||||
documents = append(documents, NewDocument(d))
|
||||
}
|
||||
|
||||
var nextCursor *page.CursorKey
|
||||
@@ -73,20 +71,19 @@ func NewListDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata
|
||||
}
|
||||
}
|
||||
|
||||
func NewAddDocumentOutput(doc *coredata.Document, docVersion *coredata.DocumentVersion, docApproverIDs []gid.GID, versionApproverIDs []gid.GID) AddDocumentOutput {
|
||||
func NewAddDocumentOutput(doc *coredata.Document, docVersion *coredata.DocumentVersion) AddDocumentOutput {
|
||||
return AddDocumentOutput{
|
||||
Document: NewDocument(doc, docApproverIDs),
|
||||
DocumentVersion: NewDocumentVersion(docVersion, versionApproverIDs),
|
||||
Document: NewDocument(doc),
|
||||
DocumentVersion: NewDocumentVersion(docVersion),
|
||||
}
|
||||
}
|
||||
|
||||
func NewDocumentVersion(dv *coredata.DocumentVersion, approverIDs []gid.GID) *DocumentVersion {
|
||||
func NewDocumentVersion(dv *coredata.DocumentVersion) *DocumentVersion {
|
||||
return &DocumentVersion{
|
||||
ID: dv.ID,
|
||||
OrganizationID: dv.OrganizationID,
|
||||
DocumentID: dv.DocumentID,
|
||||
Title: dv.Title,
|
||||
ApproverIds: approverIDs,
|
||||
VersionNumber: dv.VersionNumber,
|
||||
Classification: dv.Classification,
|
||||
Content: dv.Content,
|
||||
@@ -98,10 +95,10 @@ func NewDocumentVersion(dv *coredata.DocumentVersion, approverIDs []gid.GID) *Do
|
||||
}
|
||||
}
|
||||
|
||||
func NewListDocumentVersionsOutput(versionPage *page.Page[*coredata.DocumentVersion, coredata.DocumentVersionOrderField], approverIDsMap map[gid.GID][]gid.GID) ListDocumentVersionsOutput {
|
||||
func NewListDocumentVersionsOutput(versionPage *page.Page[*coredata.DocumentVersion, coredata.DocumentVersionOrderField]) ListDocumentVersionsOutput {
|
||||
versions := make([]*DocumentVersion, 0, len(versionPage.Data))
|
||||
for _, v := range versionPage.Data {
|
||||
versions = append(versions, NewDocumentVersion(v, approverIDsMap[v.ID]))
|
||||
versions = append(versions, NewDocumentVersion(v))
|
||||
}
|
||||
|
||||
var nextCursor *page.CursorKey
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"errors"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/docgen"
|
||||
@@ -160,19 +161,50 @@ func (s *DocumentService) exportPDFData(
|
||||
return fmt.Errorf("cannot load latest published document version: %w", err)
|
||||
}
|
||||
|
||||
// Load approvers
|
||||
docApprovers := &coredata.DocumentApprovers{}
|
||||
if err := docApprovers.LoadByDocumentID(ctx, conn, s.svc.scope, documentID); err != nil {
|
||||
return fmt.Errorf("cannot load document approvers: %w", err)
|
||||
}
|
||||
lastQuorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := lastQuorum.LoadLastByDocumentVersionID(ctx, conn, s.svc.scope, version.ID); err != nil {
|
||||
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return fmt.Errorf("cannot load last approval quorum: %w", err)
|
||||
}
|
||||
} else if lastQuorum.Status == coredata.DocumentVersionApprovalQuorumStatusApproved {
|
||||
approvedDecisions := &coredata.DocumentVersionApprovalDecisions{}
|
||||
approvedFilter := coredata.NewDocumentVersionApprovalDecisionFilter(
|
||||
coredata.DocumentVersionApprovalDecisionStates{coredata.DocumentVersionApprovalDecisionStateApproved},
|
||||
)
|
||||
if err := approvedDecisions.LoadByQuorumID(
|
||||
ctx,
|
||||
conn,
|
||||
s.svc.scope,
|
||||
lastQuorum.ID,
|
||||
page.NewCursor(
|
||||
100,
|
||||
nil,
|
||||
page.Head,
|
||||
page.OrderBy[coredata.DocumentVersionApprovalDecisionOrderField]{
|
||||
Field: coredata.DocumentVersionApprovalDecisionOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionAsc,
|
||||
},
|
||||
),
|
||||
approvedFilter,
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot load approved decisions: %w", err)
|
||||
}
|
||||
|
||||
profiles := coredata.MembershipProfiles{}
|
||||
if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, docApprovers.ApproverProfileIDs()); err != nil {
|
||||
return fmt.Errorf("cannot load document approver profiles: %w", err)
|
||||
}
|
||||
approverProfileIDs := make([]gid.GID, 0, len(*approvedDecisions))
|
||||
for _, d := range *approvedDecisions {
|
||||
approverProfileIDs = append(approverProfileIDs, d.ApproverID)
|
||||
}
|
||||
|
||||
for _, p := range profiles {
|
||||
approverNames = append(approverNames, p.FullName)
|
||||
if len(approverProfileIDs) > 0 {
|
||||
profiles := coredata.MembershipProfiles{}
|
||||
if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, approverProfileIDs); err != nil {
|
||||
return fmt.Errorf("cannot load approver profiles: %w", err)
|
||||
}
|
||||
|
||||
for _, p := range profiles {
|
||||
approverNames = append(approverNames, p.FullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := organization.LoadByID(ctx, conn, s.svc.scope, document.OrganizationID); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user