Redesign document approval flow
Replace the per-approver add/remove model with a quorum-based approval system. Documents now have default approvers that are pre-populated when requesting approval, and the publish dialog lets users adjust the list before submitting. Key changes: - Add PENDING_APPROVAL document version status with dedicated transitions - Introduce approval quorums with request/approve/reject/void lifecycle - Add default approvers per document (stored in document_default_approvers) with MERGE-based upsert for efficient sync - Add NoDuplicates validator for slice fields - Split ALTER TYPE ADD VALUE migrations into separate files (required by PostgreSQL when run inside transactions) - Use VOIDED consistently for both quorum status and decision state enums - Expose void/approve/reject through GraphQL and MCP, with e2e tests - Add approval management UI: publish dialog with approver selection, approval list with void support, and external approve/reject page Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -66,45 +66,14 @@ func (p Document) CursorKey(orderBy DocumentOrderField) page.CursorKey {
|
||||
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
||||
func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
|
||||
q := `
|
||||
WITH document AS (
|
||||
SELECT id, organization_id, status
|
||||
FROM documents
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
),
|
||||
latest_version AS (
|
||||
SELECT dv.id, dv.document_id, dv.status AS version_status
|
||||
FROM document_versions dv
|
||||
INNER JOIN document ON dv.document_id = document.id
|
||||
ORDER BY dv.created_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
last_quorum AS (
|
||||
SELECT
|
||||
lv.document_id,
|
||||
q.status::text AS status
|
||||
FROM document_version_approval_quorums q
|
||||
INNER JOIN latest_version lv ON lv.id = q.version_id
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
document.organization_id,
|
||||
document.status,
|
||||
COALESCE(lv.version_status::text, ''),
|
||||
COALESCE(lq.status, '')
|
||||
FROM document
|
||||
LEFT JOIN latest_version lv ON lv.document_id = document.id
|
||||
LEFT JOIN last_quorum lq ON lq.document_id = document.id;
|
||||
SELECT organization_id
|
||||
FROM documents
|
||||
WHERE id = $1
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
var (
|
||||
organizationID gid.GID
|
||||
documentStatus DocumentStatus
|
||||
latestVersionStatus string
|
||||
lastQuorumStatus string
|
||||
)
|
||||
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID, &documentStatus, &latestVersionStatus, &lastQuorumStatus); err != nil {
|
||||
var organizationID gid.GID
|
||||
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
@@ -112,10 +81,7 @@ LEFT JOIN last_quorum lq ON lq.document_id = document.id;
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"organization_id": organizationID.String(),
|
||||
"document_status": documentStatus.String(),
|
||||
"version_status": latestVersionStatus,
|
||||
"last_quorum_status": lastQuorumStatus,
|
||||
"organization_id": organizationID.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1117,7 +1083,7 @@ LIMIT 1
|
||||
state, err := pgx.CollectOneRow(rows, pgx.RowTo[DocumentVersionApprovalDecisionState])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DocumentVersionApprovalDecisionStatePending, nil
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("cannot collect approval state: %w", err)
|
||||
}
|
||||
|
||||
143
pkg/coredata/document_default_approver.go
Normal file
143
pkg/coredata/document_default_approver.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentDefaultApprover struct {
|
||||
DocumentID gid.GID `db:"document_id"`
|
||||
ApproverProfileID gid.GID `db:"approver_profile_id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
DocumentDefaultApprovers []*DocumentDefaultApprover
|
||||
)
|
||||
|
||||
// LoadByDocumentID loads all default approvers for a document.
|
||||
func (das *DocumentDefaultApprovers) LoadByDocumentID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
documentID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
document_id,
|
||||
approver_profile_id,
|
||||
organization_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM document_default_approvers
|
||||
WHERE
|
||||
%s
|
||||
AND document_id = @document_id
|
||||
ORDER BY created_at ASC;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"document_id": documentID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query document default approvers: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentDefaultApprover])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect document default approvers: %w", err)
|
||||
}
|
||||
|
||||
*das = result
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergeByDocumentID merges the given approver profile IDs for a document,
|
||||
// inserting new ones, keeping existing ones, and deleting removed ones.
|
||||
func (das *DocumentDefaultApprovers) MergeByDocumentID(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
documentID gid.GID,
|
||||
organizationID gid.GID,
|
||||
approverProfileIDs []gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
MERGE INTO document_default_approvers AS target
|
||||
USING (
|
||||
SELECT unnest(@approver_profile_ids::text[]) AS approver_profile_id
|
||||
) AS source
|
||||
ON
|
||||
%s
|
||||
AND target.document_id = @document_id
|
||||
AND target.approver_profile_id = source.approver_profile_id
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (document_id, approver_profile_id, tenant_id, organization_id, created_at, updated_at)
|
||||
VALUES (@document_id, source.approver_profile_id, @tenant_id, @organization_id, @now, @now)
|
||||
WHEN NOT MATCHED BY SOURCE
|
||||
AND %s
|
||||
AND target.document_id = @document_id THEN
|
||||
DELETE;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment())
|
||||
|
||||
now := time.Now()
|
||||
|
||||
ids := make([]string, len(approverProfileIDs))
|
||||
for i, id := range approverProfileIDs {
|
||||
ids[i] = id.String()
|
||||
}
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"document_id": documentID,
|
||||
"approver_profile_ids": ids,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": organizationID,
|
||||
"now": now,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
if _, err := conn.Exec(ctx, q, args); err != nil {
|
||||
return fmt.Errorf("cannot merge document default approvers: %w", err)
|
||||
}
|
||||
|
||||
result := make(DocumentDefaultApprovers, 0, len(approverProfileIDs))
|
||||
for _, profileID := range approverProfileIDs {
|
||||
result = append(result, &DocumentDefaultApprover{
|
||||
DocumentID: documentID,
|
||||
ApproverProfileID: profileID,
|
||||
OrganizationID: organizationID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
*das = result
|
||||
return nil
|
||||
}
|
||||
@@ -52,44 +52,15 @@ type (
|
||||
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
||||
func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
|
||||
q := `
|
||||
WITH document_version AS (
|
||||
SELECT id, document_id, organization_id, status AS version_status
|
||||
FROM document_versions
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
),
|
||||
document AS (
|
||||
SELECT d.id, d.status
|
||||
FROM documents d
|
||||
INNER JOIN document_version ON d.id = document_version.document_id
|
||||
),
|
||||
last_quorum AS (
|
||||
SELECT
|
||||
q.version_id,
|
||||
q.status::text AS status
|
||||
FROM document_version_approval_quorums q
|
||||
INNER JOIN document_version ON q.version_id = document_version.id
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
document_version.organization_id,
|
||||
document.status,
|
||||
document_version.version_status,
|
||||
COALESCE(lq.status, '')
|
||||
FROM document_version
|
||||
INNER JOIN document ON document.id = document_version.document_id
|
||||
LEFT JOIN last_quorum lq ON lq.version_id = document_version.id;
|
||||
SELECT organization_id
|
||||
FROM document_versions
|
||||
WHERE id = $1
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
var (
|
||||
organizationID gid.GID
|
||||
documentStatus DocumentStatus
|
||||
documentVersionStatus DocumentVersionStatus
|
||||
lastQuorumStatus string
|
||||
)
|
||||
var organizationID gid.GID
|
||||
|
||||
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID, &documentStatus, &documentVersionStatus, &lastQuorumStatus); err != nil {
|
||||
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
@@ -97,10 +68,7 @@ LEFT JOIN last_quorum lq ON lq.version_id = document_version.id;
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"organization_id": organizationID.String(),
|
||||
"document_status": documentStatus.String(),
|
||||
"version_status": documentVersionStatus.String(),
|
||||
"last_quorum_status": lastQuorumStatus,
|
||||
"organization_id": organizationID.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -213,6 +181,9 @@ LIMIT 1;
|
||||
|
||||
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect document version: %w", err)
|
||||
}
|
||||
|
||||
@@ -343,6 +314,9 @@ LIMIT 1;
|
||||
|
||||
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect document version: %w", err)
|
||||
}
|
||||
|
||||
@@ -395,6 +369,9 @@ LIMIT 1;
|
||||
|
||||
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect document version: %w", err)
|
||||
}
|
||||
|
||||
@@ -449,6 +426,9 @@ LIMIT 1;
|
||||
|
||||
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect document version: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -424,6 +424,40 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecisions) VoidPendingByQuorumID(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
quorumID gid.GID,
|
||||
now time.Time,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE document_version_approval_decisions
|
||||
SET
|
||||
state = 'VOIDED',
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
AND quorum_id = @quorum_id
|
||||
AND state = 'PENDING'
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"quorum_id": quorumID,
|
||||
"updated_at": now,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot void pending approval decisions: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecisions) CountByQuorumID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
DocumentVersionApprovalDecisionStatePending DocumentVersionApprovalDecisionState = "PENDING"
|
||||
DocumentVersionApprovalDecisionStateApproved DocumentVersionApprovalDecisionState = "APPROVED"
|
||||
DocumentVersionApprovalDecisionStateRejected DocumentVersionApprovalDecisionState = "REJECTED"
|
||||
DocumentVersionApprovalDecisionStateVoided DocumentVersionApprovalDecisionState = "VOIDED"
|
||||
)
|
||||
|
||||
func (s DocumentVersionApprovalDecisionState) MarshalText() ([]byte, error) {
|
||||
@@ -45,6 +46,8 @@ func (s *DocumentVersionApprovalDecisionState) UnmarshalText(data []byte) error
|
||||
*s = DocumentVersionApprovalDecisionStateApproved
|
||||
case DocumentVersionApprovalDecisionStateRejected.String():
|
||||
*s = DocumentVersionApprovalDecisionStateRejected
|
||||
case DocumentVersionApprovalDecisionStateVoided.String():
|
||||
*s = DocumentVersionApprovalDecisionStateVoided
|
||||
default:
|
||||
return fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", val)
|
||||
}
|
||||
@@ -62,6 +65,8 @@ func (s DocumentVersionApprovalDecisionState) String() string {
|
||||
val = "APPROVED"
|
||||
case DocumentVersionApprovalDecisionStateRejected:
|
||||
val = "REJECTED"
|
||||
case DocumentVersionApprovalDecisionStateVoided:
|
||||
val = "VOIDED"
|
||||
default:
|
||||
panic(fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", string(s)))
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
DocumentVersionApprovalQuorumStatusPending DocumentVersionApprovalQuorumStatus = "PENDING"
|
||||
DocumentVersionApprovalQuorumStatusApproved DocumentVersionApprovalQuorumStatus = "APPROVED"
|
||||
DocumentVersionApprovalQuorumStatusRejected DocumentVersionApprovalQuorumStatus = "REJECTED"
|
||||
DocumentVersionApprovalQuorumStatusVoided DocumentVersionApprovalQuorumStatus = "VOIDED"
|
||||
)
|
||||
|
||||
func (s DocumentVersionApprovalQuorumStatus) MarshalText() ([]byte, error) {
|
||||
@@ -41,6 +42,8 @@ func (s *DocumentVersionApprovalQuorumStatus) UnmarshalText(data []byte) error {
|
||||
*s = DocumentVersionApprovalQuorumStatusApproved
|
||||
case DocumentVersionApprovalQuorumStatusRejected.String():
|
||||
*s = DocumentVersionApprovalQuorumStatusRejected
|
||||
case DocumentVersionApprovalQuorumStatusVoided.String():
|
||||
*s = DocumentVersionApprovalQuorumStatusVoided
|
||||
default:
|
||||
return fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", val)
|
||||
}
|
||||
@@ -58,6 +61,8 @@ func (s DocumentVersionApprovalQuorumStatus) String() string {
|
||||
val = "APPROVED"
|
||||
case DocumentVersionApprovalQuorumStatusRejected:
|
||||
val = "REJECTED"
|
||||
case DocumentVersionApprovalQuorumStatusVoided:
|
||||
val = "VOIDED"
|
||||
default:
|
||||
panic(fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", string(s)))
|
||||
}
|
||||
|
||||
@@ -20,12 +20,13 @@ import (
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionStatus uint8
|
||||
DocumentVersionStatus string
|
||||
)
|
||||
|
||||
const (
|
||||
DocumentVersionStatusDraft DocumentVersionStatus = iota
|
||||
DocumentVersionStatusPublished
|
||||
DocumentVersionStatusDraft DocumentVersionStatus = "DRAFT"
|
||||
DocumentVersionStatusPendingApproval DocumentVersionStatus = "PENDING_APPROVAL"
|
||||
DocumentVersionStatusPublished DocumentVersionStatus = "PUBLISHED"
|
||||
)
|
||||
|
||||
func (ps DocumentVersionStatus) MarshalText() ([]byte, error) {
|
||||
@@ -38,6 +39,8 @@ func (ps *DocumentVersionStatus) UnmarshalText(data []byte) error {
|
||||
switch val {
|
||||
case DocumentVersionStatusDraft.String():
|
||||
*ps = DocumentVersionStatusDraft
|
||||
case DocumentVersionStatusPendingApproval.String():
|
||||
*ps = DocumentVersionStatusPendingApproval
|
||||
case DocumentVersionStatusPublished.String():
|
||||
*ps = DocumentVersionStatusPublished
|
||||
default:
|
||||
@@ -48,16 +51,16 @@ func (ps *DocumentVersionStatus) UnmarshalText(data []byte) error {
|
||||
}
|
||||
|
||||
func (ps DocumentVersionStatus) String() string {
|
||||
var val string
|
||||
|
||||
switch ps {
|
||||
case DocumentVersionStatusDraft:
|
||||
val = "DRAFT"
|
||||
return "DRAFT"
|
||||
case DocumentVersionStatusPendingApproval:
|
||||
return "PENDING_APPROVAL"
|
||||
case DocumentVersionStatusPublished:
|
||||
val = "PUBLISHED"
|
||||
return "PUBLISHED"
|
||||
default:
|
||||
panic(fmt.Errorf("invalid DocumentVersionStatus value: %q", string(ps)))
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func (ps *DocumentVersionStatus) Scan(value any) error {
|
||||
|
||||
17
pkg/coredata/migrations/20260408T120000Z.sql
Normal file
17
pkg/coredata/migrations/20260408T120000Z.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
--
|
||||
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||
-- purpose with or without fee is hereby granted, provided that the above
|
||||
-- copyright notice and this permission notice appear in all copies.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
-- PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
ALTER TYPE document_version_status ADD VALUE 'PENDING_APPROVAL' BEFORE 'PUBLISHED';
|
||||
ALTER TYPE document_version_approval_quorum_status ADD VALUE 'VOIDED';
|
||||
ALTER TYPE document_version_approval_decision_state ADD VALUE 'VOIDED';
|
||||
35
pkg/coredata/migrations/20260408T120004Z.sql
Normal file
35
pkg/coredata/migrations/20260408T120004Z.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
--
|
||||
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||
-- purpose with or without fee is hereby granted, provided that the above
|
||||
-- copyright notice and this permission notice appear in all copies.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
-- PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
-- Backfill: set every draft version that has a pending approval quorum to PENDING_APPROVAL
|
||||
UPDATE document_versions dv
|
||||
SET status = 'PENDING_APPROVAL'
|
||||
WHERE dv.status = 'DRAFT'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM document_version_approval_quorums q
|
||||
WHERE q.version_id = dv.id
|
||||
AND q.status = 'PENDING'
|
||||
);
|
||||
|
||||
-- Backfill: void pending decisions in rejected or voided quorums
|
||||
UPDATE document_version_approval_decisions d
|
||||
SET state = 'VOIDED'
|
||||
WHERE d.state = 'PENDING'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM document_version_approval_quorums q
|
||||
WHERE q.id = d.quorum_id
|
||||
AND q.status IN ('REJECTED', 'VOIDED')
|
||||
);
|
||||
47
pkg/coredata/migrations/20260408T130000Z.sql
Normal file
47
pkg/coredata/migrations/20260408T130000Z.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
--
|
||||
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||
-- purpose with or without fee is hereby granted, provided that the above
|
||||
-- copyright notice and this permission notice appear in all copies.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
-- PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
CREATE TABLE document_default_approvers (
|
||||
document_id text NOT NULL,
|
||||
approver_profile_id text NOT NULL,
|
||||
tenant_id text NOT NULL,
|
||||
organization_id text NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL,
|
||||
PRIMARY KEY (document_id, approver_profile_id),
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
FOREIGN KEY (approver_profile_id) REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Backfill default approvers from the last quorum of the last version of each document.
|
||||
INSERT INTO document_default_approvers (document_id, approver_profile_id, tenant_id, organization_id, created_at, updated_at)
|
||||
SELECT DISTINCT
|
||||
dv.document_id,
|
||||
d.approver_id,
|
||||
d.tenant_id,
|
||||
d.organization_id,
|
||||
d.created_at,
|
||||
d.created_at
|
||||
FROM document_version_approval_decisions d
|
||||
JOIN document_version_approval_quorums q ON q.id = d.quorum_id
|
||||
JOIN document_versions dv ON dv.id = q.version_id
|
||||
WHERE q.id = (
|
||||
SELECT q2.id
|
||||
FROM document_version_approval_quorums q2
|
||||
JOIN document_versions dv2 ON dv2.id = q2.version_id
|
||||
WHERE dv2.document_id = dv.document_id
|
||||
ORDER BY dv2.created_at DESC, q2.created_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
ON CONFLICT (document_id, approver_profile_id) DO NOTHING;
|
||||
Reference in New Issue
Block a user