Add document approval workflow

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

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

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

View File

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

View File

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

View File

@@ -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[])

View File

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

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

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

View 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 (
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
}

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

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

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

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

@@ -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

View 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