Redesign document approval flow

Replace the per-approver add/remove model with a quorum-based approval
system. Documents now have default approvers that are pre-populated when
requesting approval, and the publish dialog lets users adjust the list
before submitting.

Key changes:

- Add PENDING_APPROVAL document version status with dedicated transitions
- Introduce approval quorums with request/approve/reject/void lifecycle
- Add default approvers per document (stored in document_default_approvers)
  with MERGE-based upsert for efficient sync
- Add NoDuplicates validator for slice fields
- Split ALTER TYPE ADD VALUE migrations into separate files (required by
  PostgreSQL when run inside transactions)
- Use VOIDED consistently for both quorum status and decision state enums
- Expose void/approve/reject through GraphQL and MCP, with e2e tests
- Add approval management UI: publish dialog with approver selection,
  approval list with void support, and external approve/reject page

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-09 11:17:51 +02:00
parent 17f579b8a2
commit ab5f42ad74
38 changed files with 2125 additions and 922 deletions

View File

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

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
DocumentDefaultApprover struct {
DocumentID gid.GID `db:"document_id"`
ApproverProfileID gid.GID `db:"approver_profile_id"`
OrganizationID gid.GID `db:"organization_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
DocumentDefaultApprovers []*DocumentDefaultApprover
)
// LoadByDocumentID loads all default approvers for a document.
func (das *DocumentDefaultApprovers) LoadByDocumentID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
documentID gid.GID,
) error {
q := `
SELECT
document_id,
approver_profile_id,
organization_id,
created_at,
updated_at
FROM document_default_approvers
WHERE
%s
AND document_id = @document_id
ORDER BY created_at ASC;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"document_id": documentID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query document default approvers: %w", err)
}
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentDefaultApprover])
if err != nil {
return fmt.Errorf("cannot collect document default approvers: %w", err)
}
*das = result
return nil
}
// MergeByDocumentID merges the given approver profile IDs for a document,
// inserting new ones, keeping existing ones, and deleting removed ones.
func (das *DocumentDefaultApprovers) MergeByDocumentID(
ctx context.Context,
conn pg.Tx,
scope Scoper,
documentID gid.GID,
organizationID gid.GID,
approverProfileIDs []gid.GID,
) error {
q := `
MERGE INTO document_default_approvers AS target
USING (
SELECT unnest(@approver_profile_ids::text[]) AS approver_profile_id
) AS source
ON
%s
AND target.document_id = @document_id
AND target.approver_profile_id = source.approver_profile_id
WHEN NOT MATCHED THEN
INSERT (document_id, approver_profile_id, tenant_id, organization_id, created_at, updated_at)
VALUES (@document_id, source.approver_profile_id, @tenant_id, @organization_id, @now, @now)
WHEN NOT MATCHED BY SOURCE
AND %s
AND target.document_id = @document_id THEN
DELETE;
`
q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment())
now := time.Now()
ids := make([]string, len(approverProfileIDs))
for i, id := range approverProfileIDs {
ids[i] = id.String()
}
args := pgx.StrictNamedArgs{
"document_id": documentID,
"approver_profile_ids": ids,
"tenant_id": scope.GetTenantID(),
"organization_id": organizationID,
"now": now,
}
maps.Copy(args, scope.SQLArguments())
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot merge document default approvers: %w", err)
}
result := make(DocumentDefaultApprovers, 0, len(approverProfileIDs))
for _, profileID := range approverProfileIDs {
result = append(result, &DocumentDefaultApprover{
DocumentID: documentID,
ApproverProfileID: profileID,
OrganizationID: organizationID,
CreatedAt: now,
UpdatedAt: now,
})
}
*das = result
return nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
-- Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
ALTER TYPE document_version_status ADD VALUE 'PENDING_APPROVAL' BEFORE 'PUBLISHED';
ALTER TYPE document_version_approval_quorum_status ADD VALUE 'VOIDED';
ALTER TYPE document_version_approval_decision_state ADD VALUE 'VOIDED';

View File

@@ -0,0 +1,35 @@
-- Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
-- Backfill: set every draft version that has a pending approval quorum to PENDING_APPROVAL
UPDATE document_versions dv
SET status = 'PENDING_APPROVAL'
WHERE dv.status = 'DRAFT'
AND EXISTS (
SELECT 1
FROM document_version_approval_quorums q
WHERE q.version_id = dv.id
AND q.status = 'PENDING'
);
-- Backfill: void pending decisions in rejected or voided quorums
UPDATE document_version_approval_decisions d
SET state = 'VOIDED'
WHERE d.state = 'PENDING'
AND EXISTS (
SELECT 1
FROM document_version_approval_quorums q
WHERE q.id = d.quorum_id
AND q.status IN ('REJECTED', 'VOIDED')
);

View File

@@ -0,0 +1,47 @@
-- Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
CREATE TABLE document_default_approvers (
document_id text NOT NULL,
approver_profile_id text NOT NULL,
tenant_id text NOT NULL,
organization_id text NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
PRIMARY KEY (document_id, approver_profile_id),
FOREIGN KEY (document_id) REFERENCES documents(id) ON UPDATE CASCADE ON DELETE CASCADE,
FOREIGN KEY (approver_profile_id) REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE CASCADE
);
-- Backfill default approvers from the last quorum of the last version of each document.
INSERT INTO document_default_approvers (document_id, approver_profile_id, tenant_id, organization_id, created_at, updated_at)
SELECT DISTINCT
dv.document_id,
d.approver_id,
d.tenant_id,
d.organization_id,
d.created_at,
d.created_at
FROM document_version_approval_decisions d
JOIN document_version_approval_quorums q ON q.id = d.quorum_id
JOIN document_versions dv ON dv.id = q.version_id
WHERE q.id = (
SELECT q2.id
FROM document_version_approval_quorums q2
JOIN document_versions dv2 ON dv2.id = q2.version_id
WHERE dv2.document_id = dv.document_id
ORDER BY dv2.created_at DESC, q2.created_at DESC
LIMIT 1
)
ON CONFLICT (document_id, approver_profile_id) DO NOTHING;