Add multi-approver support

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-02-13 12:49:22 +01:00
committed by Sacha Al Himdani
parent 3d8c7c4dd6
commit 93c7b0c2dc
41 changed files with 1721 additions and 461 deletions

View File

@@ -32,7 +32,6 @@ type (
Document struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
ApproverID gid.GID `db:"approver_profile_id"`
Title string `db:"title"`
DocumentType DocumentType `db:"document_type"`
Classification DocumentClassification `db:"classification"`
@@ -83,7 +82,6 @@ func (p *Document) LoadByID(
SELECT
id,
organization_id,
approver_profile_id,
title,
document_type,
classification,
@@ -135,7 +133,6 @@ func (p *Document) LoadByIDWithFilter(
SELECT
id,
organization_id,
approver_profile_id,
title,
document_type,
classification,
@@ -188,7 +185,6 @@ func (p *Documents) LoadByIDs(
SELECT
id,
organization_id,
approver_profile_id,
title,
document_type,
classification,
@@ -270,7 +266,6 @@ func (p *Documents) LoadByOrganizationID(
SELECT
id,
organization_id,
approver_profile_id,
title,
document_type,
classification,
@@ -321,7 +316,6 @@ func (p *Documents) LoadAllByOrganizationID(
SELECT
id,
organization_id,
approver_profile_id,
title,
document_type,
classification,
@@ -371,7 +365,6 @@ INSERT INTO
tenant_id,
id,
organization_id,
approver_profile_id,
title,
document_type,
classification,
@@ -384,7 +377,6 @@ VALUES (
@tenant_id,
@document_id,
@organization_id,
@approver_profile_id,
@title,
@document_type,
@classification,
@@ -399,7 +391,6 @@ VALUES (
"tenant_id": scope.GetTenantID(),
"document_id": p.ID,
"organization_id": p.OrganizationID,
"approver_profile_id": p.ApproverID,
"title": p.Title,
"document_type": p.DocumentType,
"classification": p.Classification,
@@ -460,7 +451,6 @@ UPDATE
SET
title = @title,
current_published_version = @current_published_version,
approver_profile_id = @approver_profile_id,
document_type = @document_type,
classification = @classification,
trust_center_visibility = @trust_center_visibility,
@@ -477,7 +467,6 @@ WHERE
"updated_at": time.Now(),
"title": p.Title,
"current_published_version": p.CurrentPublishedVersion,
"approver_profile_id": p.ApproverID,
"document_type": p.DocumentType,
"classification": p.Classification,
"trust_center_visibility": p.TrustCenterVisibility,
@@ -548,7 +537,6 @@ WITH scoped_documents AS (
SELECT
scoped_documents.id,
scoped_documents.organization_id,
scoped_documents.approver_profile_id,
scoped_documents.title,
scoped_documents.document_type,
scoped_documents.classification,
@@ -638,7 +626,6 @@ WITH scoped_documents AS (
SELECT
scoped_documents.id,
scoped_documents.organization_id,
scoped_documents.approver_profile_id,
scoped_documents.title,
scoped_documents.document_type,
scoped_documents.classification,

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
DocumentApprover struct {
DocumentID gid.GID `db:"document_id"`
ApproverProfileID gid.GID `db:"approver_profile_id"`
OrganizationID gid.GID `db:"organization_id"`
TenantID gid.TenantID `db:"tenant_id"`
CreatedAt time.Time `db:"created_at"`
}
DocumentApprovers []*DocumentApprover
)
func (da DocumentApprover) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO
document_approvers (
document_id,
approver_profile_id,
organization_id,
tenant_id,
created_at
)
VALUES (
@document_id,
@approver_profile_id,
@organization_id,
@tenant_id,
@created_at
)
ON CONFLICT (document_id, approver_profile_id) DO NOTHING;
`
args := pgx.StrictNamedArgs{
"document_id": da.DocumentID,
"approver_profile_id": da.ApproverProfileID,
"organization_id": da.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": da.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert document approver: %w", err)
}
return nil
}
func (da *DocumentApprovers) LoadByDocumentID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
documentID gid.GID,
) error {
q := `
SELECT
document_id,
approver_profile_id,
organization_id,
tenant_id,
created_at
FROM
document_approvers
WHERE
%s
AND document_id = @document_id
ORDER BY created_at ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"document_id": documentID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query document approvers: %w", err)
}
approvers, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentApprover])
if err != nil {
return fmt.Errorf("cannot collect document approvers: %w", err)
}
*da = approvers
return nil
}
func (da *DocumentApprovers) DeleteByDocumentID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
documentID gid.GID,
) error {
q := `
DELETE FROM
document_approvers
WHERE
%s
AND document_id = @document_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"document_id": documentID}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete document approvers: %w", err)
}
return nil
}
func (da *DocumentApprovers) ApproverProfileIDs() []gid.GID {
ids := make([]gid.GID, len(*da))
for i, a := range *da {
ids[i] = a.ApproverProfileID
}
return ids
}

View File

@@ -34,7 +34,6 @@ type (
OrganizationID gid.GID `db:"organization_id"`
DocumentID gid.GID `db:"document_id"`
Title string `db:"title"`
ApproverID gid.GID `db:"approver_profile_id"`
VersionNumber int `db:"version_number"`
Classification DocumentClassification `db:"classification"`
Content string `db:"content"`
@@ -77,7 +76,6 @@ SELECT
organization_id,
document_id,
title,
approver_profile_id,
version_number,
classification,
content,
@@ -139,7 +137,6 @@ SELECT
organization_id,
document_id,
title,
approver_profile_id,
version_number,
classification,
content,
@@ -190,7 +187,6 @@ INSERT INTO document_versions (
organization_id,
document_id,
title,
approver_profile_id,
version_number,
classification,
content,
@@ -205,7 +201,6 @@ VALUES (
@organization_id,
@document_id,
@title,
@approver_profile_id,
@version_number,
@classification,
@content,
@@ -216,19 +211,18 @@ VALUES (
)
`
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"id": dv.ID,
"organization_id": dv.OrganizationID,
"document_id": dv.DocumentID,
"title": dv.Title,
"approver_profile_id": dv.ApproverID,
"version_number": dv.VersionNumber,
"classification": dv.Classification,
"content": dv.Content,
"changelog": dv.Changelog,
"status": dv.Status,
"created_at": dv.CreatedAt,
"updated_at": dv.UpdatedAt,
"tenant_id": scope.GetTenantID(),
"id": dv.ID,
"organization_id": dv.OrganizationID,
"document_id": dv.DocumentID,
"title": dv.Title,
"version_number": dv.VersionNumber,
"classification": dv.Classification,
"content": dv.Content,
"changelog": dv.Changelog,
"status": dv.Status,
"created_at": dv.CreatedAt,
"updated_at": dv.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
@@ -260,7 +254,6 @@ SELECT
organization_id,
document_id,
title,
approver_profile_id,
version_number,
classification,
content,
@@ -313,7 +306,6 @@ SELECT
organization_id,
document_id,
title,
approver_profile_id,
version_number,
classification,
content,
@@ -364,7 +356,6 @@ SELECT
organization_id,
document_id,
title,
approver_profile_id,
version_number,
classification,
content,
@@ -413,7 +404,6 @@ func (dv DocumentVersion) Update(
q := `
UPDATE document_versions SET
title = @title,
approver_profile_id = @approver_profile_id,
changelog = @changelog,
status = @status,
content = @content,
@@ -429,7 +419,6 @@ WHERE %s
args := pgx.StrictNamedArgs{
"document_version_id": dv.ID,
"title": dv.Title,
"approver_profile_id": dv.ApproverID,
"changelog": dv.Changelog,
"status": dv.Status,
"content": dv.Content,

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
DocumentVersionApprover struct {
DocumentVersionID gid.GID `db:"document_version_id"`
ApproverProfileID gid.GID `db:"approver_profile_id"`
OrganizationID gid.GID `db:"organization_id"`
TenantID gid.TenantID `db:"tenant_id"`
CreatedAt time.Time `db:"created_at"`
}
DocumentVersionApprovers []*DocumentVersionApprover
)
func (dva DocumentVersionApprover) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO
document_version_approvers (
document_version_id,
approver_profile_id,
organization_id,
tenant_id,
created_at
)
VALUES (
@document_version_id,
@approver_profile_id,
@organization_id,
@tenant_id,
@created_at
)
ON CONFLICT (document_version_id, approver_profile_id) DO NOTHING;
`
args := pgx.StrictNamedArgs{
"document_version_id": dva.DocumentVersionID,
"approver_profile_id": dva.ApproverProfileID,
"organization_id": dva.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": dva.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert document version approver: %w", err)
}
return nil
}
func (dva *DocumentVersionApprovers) LoadByDocumentVersionID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
documentVersionID gid.GID,
) error {
q := `
SELECT
document_version_id,
approver_profile_id,
organization_id,
tenant_id,
created_at
FROM
document_version_approvers
WHERE
%s
AND document_version_id = @document_version_id
ORDER BY created_at ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"document_version_id": documentVersionID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query document version approvers: %w", err)
}
approvers, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersionApprover])
if err != nil {
return fmt.Errorf("cannot collect document version approvers: %w", err)
}
*dva = approvers
return nil
}
func (dva *DocumentVersionApprovers) DeleteByDocumentVersionID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
documentVersionID gid.GID,
) error {
q := `
DELETE FROM
document_version_approvers
WHERE
%s
AND document_version_id = @document_version_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"document_version_id": documentVersionID}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete document version approvers: %w", err)
}
return nil
}
func (dva *DocumentVersionApprovers) ApproverProfileIDs() []gid.GID {
ids := make([]gid.GID, len(*dva))
for i, a := range *dva {
ids[i] = a.ApproverProfileID
}
return ids
}

View File

@@ -364,7 +364,7 @@ WITH sigs AS (
FROM
document_version_signatures dvs
INNER JOIN
iam_membership_profile_id p ON dvs.signed_by_profile_id = p.id
iam_membership_profiles p ON dvs.signed_by_profile_id = p.id
WHERE
dvs.document_version_id = @document_version_id
ORDER BY

View File

@@ -368,6 +368,212 @@ INNER JOIN identities i ON i.id = p.identity_id
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.membership_id,
mp.full_name,
mp.kind,
mp.additional_email_addresses,
mp.position,
mp.contract_start_date,
mp.contract_end_date,
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,
p.membership_id,
i.email_address,
p.full_name,
p.kind,
p.additional_email_addresses,
p.position,
p.contract_start_date,
p.contract_end_date,
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,
scope Scoper,
documentVersionID gid.GID,
cursor *page.Cursor[MembershipProfileOrderField],
) error {
q := `
WITH profiles AS (
SELECT
mp.id,
mp.identity_id,
mp.organization_id,
mp.membership_id,
mp.full_name,
mp.kind,
mp.additional_email_addresses,
mp.position,
mp.contract_start_date,
mp.contract_end_date,
mp.created_at,
mp.updated_at
FROM
iam_membership_profiles mp
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
p.id,
p.identity_id,
p.organization_id,
p.membership_id,
i.email_address,
p.full_name,
p.kind,
p.additional_email_addresses,
p.position,
p.contract_start_date,
p.contract_end_date,
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_version_id": documentVersionID}
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 version approver profiles: %w", err)
}
profiles, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MembershipProfile])
if err != nil {
return fmt.Errorf("cannot collect document version approver profiles: %w", err)
}
*p = profiles
return nil
}
func (p *MembershipProfiles) CountByDocumentVersionID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
documentVersionID gid.GID,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM
iam_membership_profiles mp
INNER JOIN document_version_approvers dva ON mp.id = dva.approver_profile_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}
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 version approver profiles count: %w", err)
}
return count, nil
}
func (p *MembershipProfiles) LoadByMeetingID(
ctx context.Context,
conn pg.Conn,

View File

@@ -0,0 +1,36 @@
-- Create document_approvers join table
CREATE TABLE document_approvers (
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
approver_profile_id TEXT NOT NULL REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT,
tenant_id TEXT NOT NULL,
organization_id TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
PRIMARY KEY (document_id, approver_profile_id)
);
-- Create document_version_approvers join table
CREATE TABLE document_version_approvers (
document_version_id TEXT NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
approver_profile_id TEXT NOT NULL REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT,
tenant_id TEXT NOT NULL,
organization_id TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
PRIMARY KEY (document_version_id, approver_profile_id)
);
-- Migrate existing approver data from documents to document_approvers
INSERT INTO document_approvers (document_id, approver_profile_id, tenant_id, organization_id, created_at)
SELECT id, approver_profile_id, tenant_id, organization_id, created_at
FROM documents
WHERE approver_profile_id IS NOT NULL
AND deleted_at IS NULL;
-- Migrate existing approver data from document_versions to document_version_approvers
INSERT INTO document_version_approvers (document_version_id, approver_profile_id, tenant_id, organization_id, created_at)
SELECT id, approver_profile_id, tenant_id, organization_id, created_at
FROM document_versions
WHERE approver_profile_id IS NOT NULL;
-- Drop the old columns
ALTER TABLE documents DROP COLUMN approver_profile_id;
ALTER TABLE document_versions DROP COLUMN approver_profile_id;