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

@@ -50,7 +50,7 @@ type (
OrganizationID gid.GID
Title string
Content string
ApproverID gid.GID
ApproverIDs []gid.GID
Classification coredata.DocumentClassification
DocumentType coredata.DocumentType
TrustCenterVisibility *coredata.TrustCenterVisibility
@@ -59,7 +59,7 @@ type (
UpdateDocumentRequest struct {
DocumentID gid.GID
Title *string
ApproverID *gid.GID
ApproverIDs []gid.GID
Classification *coredata.DocumentClassification
DocumentType *coredata.DocumentType
TrustCenterVisibility *coredata.TrustCenterVisibility
@@ -102,7 +102,10 @@ func (cdr *CreateDocumentRequest) Validate() error {
v.Check(cdr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(cdr.Title, "title", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(cdr.Content, "content", validator.Required(), validator.NotEmpty(), validator.MaxLen(documentMaxLength))
v.Check(cdr.ApproverID, "approver_id", validator.Required(), validator.GID(coredata.MembershipProfileEntityType))
v.Check(cdr.ApproverIDs, "approver_ids", validator.Required(), validator.NotEmpty())
for _, id := range cdr.ApproverIDs {
v.Check(id, "approver_ids", validator.Required(), validator.GID(coredata.MembershipProfileEntityType))
}
v.Check(cdr.Classification, "classification", validator.Required(), validator.OneOfSlice(coredata.DocumentClassifications()))
v.Check(cdr.DocumentType, "document_type", validator.Required(), validator.OneOfSlice(coredata.DocumentTypes()))
v.Check(cdr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
@@ -115,7 +118,9 @@ func (udr *UpdateDocumentRequest) Validate() error {
v.Check(udr.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType))
v.Check(udr.Title, "title", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(udr.ApproverID, "approver_id", validator.GID(coredata.MembershipProfileEntityType))
for _, id := range udr.ApproverIDs {
v.Check(id, "approver_ids", validator.Required(), validator.GID(coredata.MembershipProfileEntityType))
}
v.Check(udr.Classification, "classification", validator.OneOfSlice(coredata.DocumentClassifications()))
v.Check(udr.DocumentType, "document_type", validator.OneOfSlice(coredata.DocumentTypes()))
v.Check(udr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
@@ -177,6 +182,108 @@ func (s *DocumentService) Get(
return document, nil
}
func (s *DocumentService) ListApprovers(
ctx context.Context,
documentID gid.GID,
cursor *page.Cursor[coredata.MembershipProfileOrderField],
) (*page.Page[*coredata.MembershipProfile, coredata.MembershipProfileOrderField], error) {
var profiles coredata.MembershipProfiles
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := profiles.LoadByDocumentID(ctx, conn, s.svc.scope, documentID, cursor); err != nil {
return fmt.Errorf("cannot load document approvers: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(profiles, cursor), nil
}
func (s *DocumentService) CountApprovers(
ctx context.Context,
documentID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
profiles := coredata.MembershipProfiles{}
count, err = profiles.CountByDocumentID(ctx, conn, s.svc.scope, documentID)
if err != nil {
return fmt.Errorf("cannot count document approvers: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *DocumentService) ListVersionApprovers(
ctx context.Context,
documentVersionID gid.GID,
cursor *page.Cursor[coredata.MembershipProfileOrderField],
) (*page.Page[*coredata.MembershipProfile, coredata.MembershipProfileOrderField], error) {
var profiles coredata.MembershipProfiles
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := profiles.LoadByDocumentVersionID(ctx, conn, s.svc.scope, documentVersionID, cursor); err != nil {
return fmt.Errorf("cannot load document version approvers: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(profiles, cursor), nil
}
func (s *DocumentService) CountVersionApprovers(
ctx context.Context,
documentVersionID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
profiles := coredata.MembershipProfiles{}
count, err = profiles.CountByDocumentVersionID(ctx, conn, s.svc.scope, documentVersionID)
if err != nil {
return fmt.Errorf("cannot count document version approvers: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *DocumentService) GetWithFilter(
ctx context.Context,
documentID gid.GID,
@@ -354,8 +461,7 @@ func (s *DocumentService) publishVersionInTx(
return nil, nil, fmt.Errorf("cannot load published version: %w", err)
}
if publishedVersion.Content == documentVersion.Content &&
publishedVersion.Title == documentVersion.Title &&
publishedVersion.ApproverID == documentVersion.ApproverID {
publishedVersion.Title == documentVersion.Title {
return nil, nil, &ErrDocumentVersionNoChanges{}
}
}
@@ -395,7 +501,6 @@ func (s *DocumentService) Create(
documentVersionID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionEntityType)
organization := &coredata.Organization{}
approver := &coredata.MembershipProfile{}
document := &coredata.Document{
ID: documentID,
@@ -415,7 +520,6 @@ func (s *DocumentService) Create(
ID: documentVersionID,
DocumentID: documentID,
Title: req.Title,
ApproverID: req.ApproverID,
VersionNumber: 1,
Content: req.Content,
Status: coredata.DocumentStatusDraft,
@@ -431,23 +535,54 @@ func (s *DocumentService) Create(
return fmt.Errorf("cannot load organization: %w", err)
}
if err := approver.LoadByID(ctx, conn, s.svc.scope, req.ApproverID); err != nil {
return fmt.Errorf("cannot load approver profile: %w", err)
// Validate all approver profiles exist
approverProfiles := coredata.MembershipProfiles{}
if err := approverProfiles.LoadByIDs(ctx, conn, s.svc.scope, req.ApproverIDs); err != nil {
return fmt.Errorf("cannot load approver profiles: %w", err)
}
if len(approverProfiles) != len(req.ApproverIDs) {
return fmt.Errorf("one or more approver profiles not found")
}
document.OrganizationID = organization.ID
document.ApproverID = approver.ID
if err := document.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document: %w", err)
}
// Insert document approvers
for _, approverID := range req.ApproverIDs {
da := coredata.DocumentApprover{
DocumentID: documentID,
ApproverProfileID: approverID,
OrganizationID: organization.ID,
CreatedAt: now,
}
if err := da.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document approver: %w", err)
}
}
documentVersion.OrganizationID = organization.ID
if err := documentVersion.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot create document version: %w", err)
}
// Insert document version approvers
for _, approverID := range req.ApproverIDs {
dva := coredata.DocumentVersionApprover{
DocumentVersionID: documentVersionID,
ApproverProfileID: approverID,
OrganizationID: organization.ID,
CreatedAt: now,
}
if err := dva.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document version approver: %w", err)
}
}
return nil
},
)
@@ -706,7 +841,6 @@ func (s *DocumentService) UpdateVersion(
}
documentVersion.Title = document.Title
documentVersion.ApproverID = document.ApproverID
documentVersion.Classification = document.Classification
documentVersion.Content = req.Content
documentVersion.UpdatedAt = time.Now()
@@ -715,6 +849,28 @@ func (s *DocumentService) UpdateVersion(
return fmt.Errorf("cannot update document version: %w", err)
}
docApprovers := &coredata.DocumentApprovers{}
if err := docApprovers.LoadByDocumentID(ctx, conn, s.svc.scope, document.ID); err != nil {
return fmt.Errorf("cannot load document approvers: %w", err)
}
versionApprovers := &coredata.DocumentVersionApprovers{}
if err := versionApprovers.DeleteByDocumentVersionID(ctx, conn, s.svc.scope, documentVersion.ID); err != nil {
return fmt.Errorf("cannot delete document version approvers: %w", err)
}
for _, da := range *docApprovers {
dva := coredata.DocumentVersionApprover{
DocumentVersionID: documentVersion.ID,
ApproverProfileID: da.ApproverProfileID,
OrganizationID: da.OrganizationID,
CreatedAt: time.Now(),
}
if err := dva.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document version approver: %w", err)
}
}
return nil
},
)
@@ -944,7 +1100,6 @@ func (s *DocumentService) CreateDraft(
draftVersion.OrganizationID = document.OrganizationID
draftVersion.DocumentID = documentID
draftVersion.Title = document.Title
draftVersion.ApproverID = document.ApproverID
draftVersion.VersionNumber = latestVersion.VersionNumber + 1
draftVersion.Classification = document.Classification
draftVersion.Content = latestVersion.Content
@@ -956,6 +1111,23 @@ func (s *DocumentService) CreateDraft(
return fmt.Errorf("cannot create draft: %w", err)
}
docApprovers := &coredata.DocumentApprovers{}
if err := docApprovers.LoadByDocumentID(ctx, conn, s.svc.scope, documentID); err != nil {
return fmt.Errorf("cannot load document approvers: %w", err)
}
for _, da := range *docApprovers {
dva := coredata.DocumentVersionApprover{
DocumentVersionID: draftVersionID,
ApproverProfileID: da.ApproverProfileID,
OrganizationID: da.OrganizationID,
CreatedAt: now,
}
if err := dva.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document version approver: %w", err)
}
}
return nil
},
)
@@ -1384,7 +1556,6 @@ func (s *DocumentService) Update(
}
document := &coredata.Document{}
approver := &coredata.MembershipProfile{}
now := time.Now()
err := s.svc.pg.WithTx(
@@ -1414,11 +1585,31 @@ func (s *DocumentService) Update(
document.TrustCenterVisibility = *req.TrustCenterVisibility
}
if req.ApproverID != nil {
if err := approver.LoadByID(ctx, tx, s.svc.scope, *req.ApproverID); err != nil {
return fmt.Errorf("cannot load approver profile %q: %w", *req.ApproverID, err)
if len(req.ApproverIDs) > 0 {
approverProfiles := coredata.MembershipProfiles{}
if err := approverProfiles.LoadByIDs(ctx, tx, s.svc.scope, req.ApproverIDs); err != nil {
return fmt.Errorf("cannot load approver profiles: %w", err)
}
if len(approverProfiles) != len(req.ApproverIDs) {
return fmt.Errorf("one or more approver profiles not found")
}
docApprovers := &coredata.DocumentApprovers{}
if err := docApprovers.DeleteByDocumentID(ctx, tx, s.svc.scope, req.DocumentID); err != nil {
return fmt.Errorf("cannot delete document approvers: %w", err)
}
for _, approverID := range req.ApproverIDs {
da := coredata.DocumentApprover{
DocumentID: req.DocumentID,
ApproverProfileID: approverID,
OrganizationID: document.OrganizationID,
CreatedAt: now,
}
if err := da.Insert(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document approver: %w", err)
}
}
document.ApproverID = approver.ID
}
document.UpdatedAt = now
@@ -1427,18 +1618,35 @@ func (s *DocumentService) Update(
return fmt.Errorf("cannot update document: %w", err)
}
// Update the draft version if it exists to keep it in sync with the document
draftVersion := &coredata.DocumentVersion{}
err := draftVersion.LoadLatestVersion(ctx, tx, s.svc.scope, req.DocumentID)
if err == nil && draftVersion.Status == coredata.DocumentStatusDraft {
draftVersion.Title = document.Title
draftVersion.ApproverID = document.ApproverID
draftVersion.Classification = document.Classification
draftVersion.UpdatedAt = now
if err := draftVersion.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update draft version: %w", err)
}
if len(req.ApproverIDs) > 0 {
versionApprovers := &coredata.DocumentVersionApprovers{}
if err := versionApprovers.DeleteByDocumentVersionID(ctx, tx, s.svc.scope, draftVersion.ID); err != nil {
return fmt.Errorf("cannot delete draft version approvers: %w", err)
}
for _, approverID := range req.ApproverIDs {
dva := coredata.DocumentVersionApprover{
DocumentVersionID: draftVersion.ID,
ApproverProfileID: approverID,
OrganizationID: document.OrganizationID,
CreatedAt: now,
}
if err := dva.Insert(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert draft version approver: %w", err)
}
}
}
}
return nil
@@ -1638,7 +1846,6 @@ func exportDocumentPDF(
) ([]byte, error) {
document := &coredata.Document{}
version := &coredata.DocumentVersion{}
approver := &coredata.MembershipProfile{}
organization := &coredata.Organization{}
if err := version.LoadByID(ctx, conn, scope, documentVersionID); err != nil {
@@ -1649,8 +1856,26 @@ func exportDocumentPDF(
return nil, fmt.Errorf("cannot load document: %w", err)
}
if err := approver.LoadByID(ctx, conn, scope, document.ApproverID); err != nil {
return nil, fmt.Errorf("cannot load document approver profile: %w", err)
versionApprovers := &coredata.DocumentVersionApprovers{}
if err := versionApprovers.LoadByDocumentVersionID(ctx, conn, scope, documentVersionID); err != nil {
return nil, fmt.Errorf("cannot load document version approvers: %w", err)
}
approverProfiles := coredata.MembershipProfiles{}
if err := approverProfiles.LoadByIDs(ctx, conn, scope, versionApprovers.ApproverProfileIDs()); err != nil {
return nil, fmt.Errorf("cannot load document approver profiles: %w", err)
}
profileByID := make(map[gid.GID]*coredata.MembershipProfile, len(approverProfiles))
for _, p := range approverProfiles {
profileByID[p.ID] = p
}
approverNames := make([]string, 0, len(*versionApprovers))
for _, a := range *versionApprovers {
if p, ok := profileByID[a.ApproverProfileID]; ok {
approverNames = append(approverNames, p.FullName)
}
}
if err := organization.LoadByID(ctx, conn, scope, document.OrganizationID); err != nil {
@@ -1702,7 +1927,7 @@ func exportDocumentPDF(
Content: version.Content,
Version: version.VersionNumber,
Classification: classification,
Approver: approver.FullName,
Approver: strings.Join(approverNames, ", "),
PublishedAt: version.PublishedAt,
Signatures: signatureData,
CompanyHorizontalLogoBase64: horizontalLogoBase64,