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
|
||||
Reference in New Issue
Block a user