Files
probo/pkg/probo/document_approval_service.go
Cursor Agent 70d0424022 Rename document approval button to Approve
The previous label implied review plus approval, but the action only
approves. Update the consent text to match the new button wording.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
2026-07-20 17:27:12 +02:00

1137 lines
32 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package probo
import (
"bytes"
"context"
"errors"
"fmt"
"time"
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/html2pdf"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
)
const DocumentApprovalConsentText = "By clicking \"Approve\", I consent to approve this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature."
type (
DocumentApprovalService struct {
svc *Service
html2pdfConverter *html2pdf.Converter
invitationTokenValidity time.Duration
tokenSecret string
}
ErrDocumentVersionNotPendingApproval struct{}
ErrApprovalDecisionAlreadyMade struct{}
ApproveDocumentVersionRequest struct {
DocumentVersionID gid.GID
IdentityID gid.GID
Comment *string
SignerFullName string
SignerEmail mail.Addr
SignerIPAddr string
SignerUA string
}
RejectDocumentVersionRequest struct {
DocumentVersionID gid.GID
IdentityID gid.GID
Comment *string
}
)
func (e ErrDocumentVersionNotPendingApproval) Error() string {
return "document version is not pending approval"
}
func (e ErrApprovalDecisionAlreadyMade) Error() string {
return "approval decision has already been made"
}
func (s *DocumentApprovalService) RequestApproval(
ctx context.Context,
scope coredata.Scoper,
tx pg.Tx,
document *coredata.Document,
documentVersion *coredata.DocumentVersion,
approverIDs []gid.GID,
changelog *string,
) (*coredata.DocumentVersionApprovalQuorum, error) {
now := time.Now()
documentVersion.Status = coredata.DocumentVersionStatusPendingApproval
if changelog != nil {
documentVersion.Changelog = *changelog
}
if document.CurrentPublishedMajor != nil {
documentVersion.Major = *document.CurrentPublishedMajor + 1
} else {
documentVersion.Major = 1
}
documentVersion.Minor = 0
documentVersion.UpdatedAt = now
if err := documentVersion.Update(ctx, tx, scope); err != nil {
return nil, fmt.Errorf("cannot update document version: %w", err)
}
quorum := &coredata.DocumentVersionApprovalQuorum{
ID: gid.New(scope.GetTenantID(), coredata.DocumentVersionApprovalQuorumEntityType),
OrganizationID: document.OrganizationID,
VersionID: documentVersion.ID,
Status: coredata.DocumentVersionApprovalQuorumStatusPending,
CreatedAt: now,
UpdatedAt: now,
}
if err := quorum.Insert(ctx, tx, scope); err != nil {
return nil, fmt.Errorf("cannot insert approval quorum: %w", err)
}
if err := s.createDecisions(ctx, scope, tx, quorum, document.OrganizationID, approverIDs, now); err != nil {
return nil, fmt.Errorf("cannot create approval decisions: %w", err)
}
// Approval notifications are sent asynchronously and debounced by the
// document notification worker, which batches all pending approvals per
// recipient into a single email.
return quorum, nil
}
// BulkPublishVersions publishes (or requests approval for) the latest draft of
// each document. When req.Minor is true each draft is published as a minor
// bump and approvers are not consulted. When req.Minor is false, each
// document's saved default approvers are honoured: if the document has any,
// an approval is requested for it; otherwise it is published as a major
// bump. Documents with no draft (or already pending approval) are skipped.
func (s *DocumentApprovalService) BulkPublishVersions(
ctx context.Context,
scope coredata.Scoper,
req BulkPublishVersionsRequest,
) ([]*coredata.DocumentVersion, []*coredata.Document, error) {
var (
publishedVersions []*coredata.DocumentVersion
updatedDocuments []*coredata.Document
)
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
for _, documentID := range req.DocumentIDs {
dv := &coredata.DocumentVersion{}
if err := dv.LoadLatestVersion(ctx, tx, scope, documentID); err != nil {
return fmt.Errorf("cannot load latest version for %q: %w", documentID, err)
}
if dv.Status == coredata.DocumentVersionStatusPendingApproval {
continue
}
document := &coredata.Document{}
if err := document.LoadByID(ctx, tx, scope, documentID); err != nil {
return fmt.Errorf("cannot load document %q: %w", documentID, err)
}
if document.ArchivedAt != nil {
return &ErrDocumentArchived{}
}
// Treat minor on an already-published version as a no-op so the
// operation is idempotent: the doc is included in the result
// without modification.
if req.Minor && dv.Status == coredata.DocumentVersionStatusPublished {
publishedVersions = append(publishedVersions, dv)
updatedDocuments = append(updatedDocuments, document)
continue
}
if dv.Status != coredata.DocumentVersionStatusDraft {
continue
}
var requestedQuorum *coredata.DocumentVersionApprovalQuorum
if req.Minor {
var err error
document, dv, err = s.svc.Documents.publishMinor(
ctx,
scope,
tx,
documentID,
&req.Changelog,
true,
)
if err != nil {
return fmt.Errorf("cannot publish document %q: %w", documentID, err)
}
} else {
defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := defaultApprovers.LoadByDocumentID(ctx, tx, scope, documentID); err != nil {
return fmt.Errorf("cannot load default approvers for %q: %w", documentID, err)
}
if len(*defaultApprovers) > 0 {
approverIDs := make([]gid.GID, len(*defaultApprovers))
for i, a := range *defaultApprovers {
approverIDs[i] = a.ApproverProfileID
}
quorum, err := s.RequestApproval(
ctx,
scope,
tx,
document,
dv,
approverIDs,
&req.Changelog,
)
if err != nil {
return fmt.Errorf("cannot request approval for %q: %w", documentID, err)
}
requestedQuorum = quorum
} else {
var err error
document, dv, err = s.svc.Documents.publishMajor(
ctx,
scope,
tx,
documentID,
&req.Changelog,
true,
)
if err != nil {
return fmt.Errorf("cannot publish document %q: %w", documentID, err)
}
}
}
publishedVersions = append(publishedVersions, dv)
updatedDocuments = append(updatedDocuments, document)
// A direct publish emits the version-published webhook inside
// publishMinor/publishMajor; only the approval-quorum case needs
// its event emitted here.
if requestedQuorum != nil {
if err := s.svc.Documents.emitDocumentEvent(
ctx,
scope,
tx,
dv.DocumentID,
coredata.WebhookEventTypeDocumentVersionApprovalQuorumRequested,
dv,
nil,
&requestedQuorum.ID,
nil,
); err != nil {
return fmt.Errorf("cannot emit approval quorum requested webhook: %w", err)
}
}
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return publishedVersions, updatedDocuments, nil
}
func (s *DocumentApprovalService) Approve(
ctx context.Context, scope coredata.Scoper,
req ApproveDocumentVersionRequest,
) (*coredata.DocumentVersionApprovalDecision, error) {
var (
documentVersion *coredata.DocumentVersion
document *coredata.Document
quorum *coredata.DocumentVersionApprovalQuorum
decision *coredata.DocumentVersionApprovalDecision
)
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
documentVersion = &coredata.DocumentVersion{}
if err := documentVersion.LoadByID(ctx, conn, scope, req.DocumentVersionID); err != nil {
return fmt.Errorf("cannot load document version: %w", err)
}
document = &coredata.Document{}
if err := document.LoadByID(ctx, conn, scope, documentVersion.DocumentID); err != nil {
return fmt.Errorf("cannot load document: %w", err)
}
if document.ArchivedAt != nil {
return &ErrDocumentArchived{}
}
var (
profile *coredata.MembershipProfile
err error
)
quorum, profile, err = s.loadQuorumAndProfile(ctx, scope, conn, req.DocumentVersionID, req.IdentityID, documentVersion.OrganizationID)
if err != nil {
return fmt.Errorf("cannot load quorum and profile: %w", err)
}
if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
return &ErrDocumentVersionNotPendingApproval{}
}
decision = &coredata.DocumentVersionApprovalDecision{}
if err := decision.LoadByQuorumIDAndApproverID(ctx, conn, scope, quorum.ID, profile.ID); err != nil {
return fmt.Errorf("cannot load approval decision: %w", err)
}
if decision.State != coredata.DocumentVersionApprovalDecisionStatePending {
return &ErrApprovalDecisionAlreadyMade{}
}
return nil
},
)
if err != nil {
return nil, err
}
now := time.Now()
pdfData, err := s.generateApprovalPDF(ctx, scope, req.DocumentVersionID)
if err != nil {
return nil, fmt.Errorf("cannot export document PDF: %w", err)
}
fileRecord := &coredata.File{
ID: gid.New(scope.GetTenantID(), coredata.FileEntityType),
OrganizationID: documentVersion.OrganizationID,
BucketName: s.svc.bucket,
MimeType: "application/pdf",
FileName: fmt.Sprintf("approval-%s.pdf", decision.ID),
FileKey: uuid.MustNewV4().String(),
Visibility: coredata.FileVisibilityPrivate,
CreatedAt: now,
UpdatedAt: now,
}
fileSize, err := s.svc.fileManager.PutFile(
ctx,
fileRecord,
bytes.NewReader(pdfData),
map[string]string{
"type": "approval-document",
"decision-id": decision.ID.String(),
},
)
if err != nil {
return nil, fmt.Errorf("cannot upload approval PDF: %w", err)
}
fileRecord.FileSize = fileSize
approverID := decision.ApproverID
quorumID := quorum.ID
err = s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
quorum = &coredata.DocumentVersionApprovalQuorum{}
if err := quorum.LoadByID(ctx, tx, scope, quorumID); err != nil {
return fmt.Errorf("cannot load quorum: %w", err)
}
if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
return &ErrDocumentVersionNotPendingApproval{}
}
decision = &coredata.DocumentVersionApprovalDecision{}
if err := decision.LoadByQuorumIDAndApproverID(ctx, tx, scope, quorum.ID, approverID); err != nil {
return fmt.Errorf("cannot load approval decision: %w", err)
}
if decision.State != coredata.DocumentVersionApprovalDecisionStatePending {
return &ErrApprovalDecisionAlreadyMade{}
}
if err := fileRecord.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert approval file record: %w", err)
}
esig, err := s.svc.esign.CreateAndAcceptSignature(
ctx,
tx,
&esign.CreateAndAcceptSignatureRequest{
OrganizationID: documentVersion.OrganizationID,
DocumentType: coredata.ElectronicSignatureDocumentTypeFromDocumentType(documentVersion.DocumentType),
DocumentName: &document.Title,
FileID: fileRecord.ID,
SignerEmail: req.SignerEmail,
SignerFullName: req.SignerFullName,
SignerIPAddr: req.SignerIPAddr,
SignerUA: req.SignerUA,
ConsentText: DocumentApprovalConsentText,
EmailSubject: fmt.Sprintf("Your approved %s - Certificate of Completion", document.Title),
},
)
if err != nil {
return fmt.Errorf("cannot create electronic signature: %w", err)
}
// Snapshot the quorum before mutating it, but only when a
// subscriber can actually receive the resulting
// `...:updated` event. Otherwise the emitter short-circuits
// on the same subscription check and this extra load would
// be wasted work that could still fail the approval.
var updatedFromQuorum any
subscriptions := coredata.WebhookSubscriptions{}
hasSubscription, err := subscriptions.ExistsByOrganizationIDAndEventType(
ctx,
tx,
scope,
document.OrganizationID,
coredata.WebhookEventTypeDocumentVersionApprovalQuorumUpdated,
)
if err != nil {
return fmt.Errorf("cannot check webhook subscriptions for approval quorum: %w", err)
}
if hasSubscription {
updatedFromQuorum, err = s.svc.Documents.loadDocumentApprovalQuorumForWebhook(ctx, scope, tx, quorum.ID, documentVersion, document)
if err != nil {
return fmt.Errorf("cannot load approval quorum snapshot for webhook: %w", err)
}
}
decision.State = coredata.DocumentVersionApprovalDecisionStateApproved
decision.Comment = req.Comment
decision.ElectronicSignatureID = &esig.ID
decision.DecidedAt = &now
decision.UpdatedAt = now
if err := decision.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update approval decision: %w", err)
}
if err := s.maybeApproveQuorum(ctx, scope, tx, quorum.ID); err != nil {
return fmt.Errorf("cannot check quorum approval: %w", err)
}
if err := documentVersion.LoadByID(ctx, tx, scope, req.DocumentVersionID); err != nil {
return fmt.Errorf("cannot reload document version: %w", err)
}
if err := quorum.LoadByID(ctx, tx, scope, quorum.ID); err != nil {
return fmt.Errorf("cannot reload approval quorum: %w", err)
}
if quorum.Status == coredata.DocumentVersionApprovalQuorumStatusApproved {
return nil
}
if err := s.svc.Documents.emitDocumentEvent(
ctx,
scope,
tx,
documentVersion.DocumentID,
coredata.WebhookEventTypeDocumentVersionApprovalQuorumUpdated,
documentVersion,
nil,
&quorum.ID,
updatedFromQuorum,
); err != nil {
return fmt.Errorf("cannot emit approval quorum updated webhook: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return decision, nil
}
func (s *DocumentApprovalService) Reject(
ctx context.Context, scope coredata.Scoper,
req RejectDocumentVersionRequest,
) (*coredata.DocumentVersionApprovalDecision, error) {
var decision *coredata.DocumentVersionApprovalDecision
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
documentVersion := &coredata.DocumentVersion{}
if err := documentVersion.LoadByID(ctx, tx, scope, req.DocumentVersionID); err != nil {
return fmt.Errorf("cannot load document version: %w", err)
}
document := &coredata.Document{}
if err := document.LoadByID(ctx, tx, scope, documentVersion.DocumentID); err != nil {
return fmt.Errorf("cannot load document: %w", err)
}
if document.ArchivedAt != nil {
return &ErrDocumentArchived{}
}
quorum, profile, err := s.loadQuorumAndProfile(ctx, scope, tx, req.DocumentVersionID, req.IdentityID, documentVersion.OrganizationID)
if err != nil {
return fmt.Errorf("cannot load quorum and profile: %w", err)
}
decision = &coredata.DocumentVersionApprovalDecision{}
if err := decision.LoadByQuorumIDAndApproverID(ctx, tx, scope, quorum.ID, profile.ID); err != nil {
return fmt.Errorf("cannot load approval decision: %w", err)
}
if decision.State != coredata.DocumentVersionApprovalDecisionStatePending {
return &ErrApprovalDecisionAlreadyMade{}
}
now := time.Now()
decision.State = coredata.DocumentVersionApprovalDecisionStateRejected
decision.Comment = req.Comment
decision.DecidedAt = &now
decision.UpdatedAt = now
if err := decision.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update approval decision: %w", err)
}
quorum.Status = coredata.DocumentVersionApprovalQuorumStatusRejected
quorum.UpdatedAt = now
if err := quorum.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update approval quorum: %w", err)
}
decisions := &coredata.DocumentVersionApprovalDecisions{}
if err := decisions.VoidPendingByQuorumID(ctx, tx, scope, quorum.ID, now); err != nil {
return fmt.Errorf("cannot void pending decisions: %w", err)
}
documentVersion.Status = coredata.DocumentVersionStatusDraft
if document.CurrentPublishedMajor != nil {
documentVersion.Major = *document.CurrentPublishedMajor
documentVersion.Minor = *document.CurrentPublishedMinor + 1
} else {
documentVersion.Major = 0
documentVersion.Minor = 1
}
documentVersion.UpdatedAt = now
if err := documentVersion.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update document version status: %w", err)
}
if err := s.svc.Documents.emitDocumentEvent(
ctx,
scope,
tx,
documentVersion.DocumentID,
coredata.WebhookEventTypeDocumentVersionApprovalQuorumRejected,
documentVersion,
nil,
&quorum.ID,
nil,
); err != nil {
return fmt.Errorf("cannot emit approval quorum rejected webhook: %w", err)
}
if err := s.svc.Documents.emitDocumentEvent(
ctx,
scope,
tx,
documentVersion.DocumentID,
coredata.WebhookEventTypeDocumentVersionRejected,
documentVersion,
nil,
nil,
nil,
); err != nil {
return fmt.Errorf("cannot emit document version rejected webhook: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return decision, nil
}
func (s *DocumentApprovalService) VoidApproval(
ctx context.Context, scope coredata.Scoper,
documentVersionID gid.GID,
) (*coredata.DocumentVersionApprovalQuorum, *coredata.DocumentVersion, error) {
var (
quorum *coredata.DocumentVersionApprovalQuorum
documentVersion *coredata.DocumentVersion
)
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
documentVersion = &coredata.DocumentVersion{}
if err := documentVersion.LoadByID(ctx, tx, scope, documentVersionID); err != nil {
return fmt.Errorf("cannot load document version: %w", err)
}
document := &coredata.Document{}
if err := document.LoadByID(ctx, tx, scope, documentVersion.DocumentID); err != nil {
return fmt.Errorf("cannot load document: %w", err)
}
if document.ArchivedAt != nil {
return &ErrDocumentArchived{}
}
if documentVersion.Status != coredata.DocumentVersionStatusPendingApproval {
return &ErrDocumentVersionNotPendingApproval{}
}
quorum = &coredata.DocumentVersionApprovalQuorum{}
if err := quorum.LoadLastByDocumentVersionID(ctx, tx, scope, documentVersionID); err != nil {
return fmt.Errorf("cannot load approval quorum: %w", err)
}
if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
return &ErrDocumentVersionNotPendingApproval{}
}
return s.voidApprovalInTx(ctx, scope, tx, document, documentVersion, quorum)
},
)
if err != nil {
return nil, nil, err
}
return quorum, documentVersion, nil
}
func (s *DocumentApprovalService) voidApprovalInTx(
ctx context.Context, scope coredata.Scoper,
tx pg.Tx,
document *coredata.Document,
documentVersion *coredata.DocumentVersion,
quorum *coredata.DocumentVersionApprovalQuorum,
) error {
now := time.Now()
quorum.Status = coredata.DocumentVersionApprovalQuorumStatusVoided
quorum.UpdatedAt = now
if err := quorum.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update approval quorum: %w", err)
}
decisions := &coredata.DocumentVersionApprovalDecisions{}
if err := decisions.VoidPendingByQuorumID(ctx, tx, scope, quorum.ID, now); err != nil {
return fmt.Errorf("cannot void pending decisions: %w", err)
}
documentVersion.Status = coredata.DocumentVersionStatusDraft
if document.CurrentPublishedMajor != nil {
documentVersion.Major = *document.CurrentPublishedMajor
documentVersion.Minor = *document.CurrentPublishedMinor + 1
} else {
documentVersion.Major = 0
documentVersion.Minor = 1
}
documentVersion.UpdatedAt = now
if err := documentVersion.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update document version status: %w", err)
}
if err := s.svc.Documents.emitDocumentEvent(
ctx,
scope,
tx,
documentVersion.DocumentID,
coredata.WebhookEventTypeDocumentVersionApprovalQuorumVoided,
documentVersion,
nil,
&quorum.ID,
nil,
); err != nil {
return fmt.Errorf("cannot emit approval quorum voided webhook: %w", err)
}
return nil
}
func (s *DocumentApprovalService) voidPendingApprovalForLatestVersionInTx(
ctx context.Context, scope coredata.Scoper,
tx pg.Tx,
document *coredata.Document,
documentVersion *coredata.DocumentVersion,
) error {
if documentVersion.Status != coredata.DocumentVersionStatusPendingApproval {
return nil
}
quorum := &coredata.DocumentVersionApprovalQuorum{}
if err := quorum.LoadLastByDocumentVersionID(ctx, tx, scope, documentVersion.ID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil
}
return fmt.Errorf("cannot load approval quorum: %w", err)
}
if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
return nil
}
if err := s.voidApprovalInTx(ctx, scope, tx, document, documentVersion, quorum); err != nil {
return fmt.Errorf("cannot void pending approval: %w", err)
}
return nil
}
func (s *DocumentApprovalService) GetQuorum(
ctx context.Context, scope coredata.Scoper,
quorumID gid.GID,
) (*coredata.DocumentVersionApprovalQuorum, error) {
quorum := &coredata.DocumentVersionApprovalQuorum{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := quorum.LoadByID(ctx, conn, scope, quorumID); err != nil {
return fmt.Errorf("cannot load approval quorum: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return quorum, nil
}
func (s *DocumentApprovalService) ListQuorums(
ctx context.Context, scope coredata.Scoper,
documentVersionID gid.GID,
cursor *page.Cursor[coredata.DocumentVersionApprovalQuorumOrderField],
) (*page.Page[*coredata.DocumentVersionApprovalQuorum, coredata.DocumentVersionApprovalQuorumOrderField], error) {
var quorums coredata.DocumentVersionApprovalQuorums
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := quorums.LoadAllByDocumentVersionID(ctx, conn, scope, documentVersionID, cursor); err != nil {
return fmt.Errorf("cannot list approval quorums: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(quorums, cursor), nil
}
func (s *DocumentApprovalService) CountQuorums(
ctx context.Context, scope coredata.Scoper,
documentVersionID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
quorums := &coredata.DocumentVersionApprovalQuorums{}
count, err = quorums.CountByDocumentVersionID(ctx, conn, scope, documentVersionID)
if err != nil {
return fmt.Errorf("cannot count approval quorums: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *DocumentApprovalService) ListDecisions(
ctx context.Context, scope coredata.Scoper,
quorumID gid.GID,
cursor *page.Cursor[coredata.DocumentVersionApprovalDecisionOrderField],
filter *coredata.DocumentVersionApprovalDecisionFilter,
) (*page.Page[*coredata.DocumentVersionApprovalDecision, coredata.DocumentVersionApprovalDecisionOrderField], error) {
var decisions coredata.DocumentVersionApprovalDecisions
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := decisions.LoadByQuorumID(ctx, conn, scope, quorumID, cursor, filter); err != nil {
return fmt.Errorf("cannot list approval decisions: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(decisions, cursor), nil
}
func (s *DocumentApprovalService) CountDecisions(
ctx context.Context, scope coredata.Scoper,
quorumID gid.GID,
filter *coredata.DocumentVersionApprovalDecisionFilter,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
decisions := &coredata.DocumentVersionApprovalDecisions{}
count, err = decisions.CountByQuorumID(ctx, conn, scope, quorumID, filter)
if err != nil {
return fmt.Errorf("cannot count approval decisions: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *DocumentApprovalService) GetDecision(
ctx context.Context, scope coredata.Scoper,
decisionID gid.GID,
) (*coredata.DocumentVersionApprovalDecision, error) {
decision := &coredata.DocumentVersionApprovalDecision{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := decision.LoadByID(ctx, conn, scope, decisionID); err != nil {
return fmt.Errorf("cannot load approval decision: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return decision, nil
}
func (s *DocumentApprovalService) GetViewerDecision(
ctx context.Context, scope coredata.Scoper,
documentVersionID gid.GID,
identityID gid.GID,
) (*coredata.DocumentVersionApprovalDecision, error) {
var decision *coredata.DocumentVersionApprovalDecision
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
documentVersion := &coredata.DocumentVersion{}
if err := documentVersion.LoadByID(ctx, conn, scope, documentVersionID); err != nil {
return fmt.Errorf("cannot load document version: %w", err)
}
profile := &coredata.MembershipProfile{}
if err := profile.LoadByIdentityIDAndOrganizationID(
ctx,
conn,
scope,
identityID,
documentVersion.OrganizationID,
); err != nil {
return fmt.Errorf("cannot load viewer profile: %w", err)
}
quorum := &coredata.DocumentVersionApprovalQuorum{}
if err := quorum.LoadLastByDocumentVersionID(ctx, conn, scope, documentVersionID); err != nil {
return fmt.Errorf("cannot load last approval quorum: %w", err)
}
d := &coredata.DocumentVersionApprovalDecision{}
if err := d.LoadByQuorumIDAndApproverID(ctx, conn, scope, quorum.ID, profile.ID); err != nil {
return fmt.Errorf("cannot load viewer approval decision: %w", err)
}
decision = d
return nil
},
)
if err != nil {
return nil, err
}
return decision, nil
}
func (s *DocumentApprovalService) loadQuorumAndProfile(
ctx context.Context, scope coredata.Scoper,
conn pg.Querier,
documentVersionID gid.GID,
identityID gid.GID,
organizationID gid.GID,
) (*coredata.DocumentVersionApprovalQuorum, *coredata.MembershipProfile, error) {
quorum := &coredata.DocumentVersionApprovalQuorum{}
if err := quorum.LoadLastByDocumentVersionID(ctx, conn, scope, documentVersionID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, nil, &ErrDocumentVersionNotPendingApproval{}
}
return nil, nil, fmt.Errorf("cannot load last approval quorum: %w", err)
}
profile := &coredata.MembershipProfile{}
if err := profile.LoadByIdentityIDAndOrganizationID(ctx, conn, scope, identityID, organizationID); err != nil {
return nil, nil, fmt.Errorf("cannot find profile for identity: %w", err)
}
return quorum, profile, nil
}
func (s *DocumentApprovalService) createDecisions(
ctx context.Context, scope coredata.Scoper,
tx pg.Tx,
quorum *coredata.DocumentVersionApprovalQuorum,
organizationID gid.GID,
approverIDs []gid.GID,
now time.Time,
) error {
decisions := make(coredata.DocumentVersionApprovalDecisions, 0, len(approverIDs))
for _, approverID := range approverIDs {
decisions = append(decisions, &coredata.DocumentVersionApprovalDecision{
ID: gid.New(scope.GetTenantID(), coredata.DocumentVersionApprovalDecisionEntityType),
OrganizationID: organizationID,
QuorumID: quorum.ID,
ApproverID: approverID,
State: coredata.DocumentVersionApprovalDecisionStatePending,
CreatedAt: now,
UpdatedAt: now,
})
}
if err := decisions.BulkInsert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert approval decisions: %w", err)
}
return nil
}
func (s *DocumentApprovalService) generateApprovalPDF(
ctx context.Context, scope coredata.Scoper,
documentVersionID gid.GID,
) ([]byte, error) {
var pdfData []byte
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var err error
pdfData, err = exportDocumentPDF(
ctx,
s.svc,
s.html2pdfConverter,
conn,
scope,
documentVersionID,
ExportPDFOptions{},
)
return err
},
)
return pdfData, err
}
func (s *DocumentApprovalService) countDecisions(
ctx context.Context, scope coredata.Scoper,
conn pg.Querier,
quorumID gid.GID,
) (int, error) {
decisions := &coredata.DocumentVersionApprovalDecisions{}
count, err := decisions.CountByQuorumID(
ctx,
conn,
scope,
quorumID,
coredata.NewDocumentVersionApprovalDecisionFilter(nil),
)
if err != nil {
return 0, fmt.Errorf("cannot count decisions: %w", err)
}
return count, nil
}
func (s *DocumentApprovalService) maybeApproveQuorum(
ctx context.Context, scope coredata.Scoper,
tx pg.Tx,
quorumID gid.GID,
) error {
totalCount, err := s.countDecisions(ctx, scope, tx, quorumID)
if err != nil {
return fmt.Errorf("cannot count total decisions: %w", err)
}
if totalCount == 0 {
return nil
}
decisions := &coredata.DocumentVersionApprovalDecisions{}
approvedCount, err := decisions.CountApprovedByQuorumID(ctx, tx, scope, quorumID)
if err != nil {
return fmt.Errorf("cannot count approved decisions: %w", err)
}
if approvedCount != totalCount {
return nil
}
quorum := &coredata.DocumentVersionApprovalQuorum{}
if err := quorum.LoadByID(ctx, tx, scope, quorumID); err != nil {
return fmt.Errorf("cannot load quorum: %w", err)
}
now := time.Now()
quorum.Status = coredata.DocumentVersionApprovalQuorumStatusApproved
quorum.UpdatedAt = now
if err := quorum.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update quorum: %w", err)
}
version, err := s.publishVersion(ctx, scope, tx, quorum.VersionID)
if err != nil {
return fmt.Errorf("cannot publish version: %w", err)
}
if err := s.svc.Documents.emitDocumentEvent(
ctx,
scope,
tx,
version.DocumentID,
coredata.WebhookEventTypeDocumentVersionApprovalQuorumApproved,
version,
nil,
&quorum.ID,
nil,
); err != nil {
return fmt.Errorf("cannot emit approval quorum approved webhook: %w", err)
}
if err := s.svc.Documents.emitDocumentEvent(
ctx,
scope,
tx,
version.DocumentID,
coredata.WebhookEventTypeDocumentVersionPublished,
version,
nil,
nil,
nil,
); err != nil {
return fmt.Errorf("cannot emit document version published webhook: %w", err)
}
return nil
}
func (s *DocumentApprovalService) publishVersion(
ctx context.Context, scope coredata.Scoper,
tx pg.Tx,
versionID gid.GID,
) (*coredata.DocumentVersion, error) {
version := &coredata.DocumentVersion{}
if err := version.LoadByID(ctx, tx, scope, versionID); err != nil {
return nil, fmt.Errorf("cannot load document version: %w", err)
}
document := &coredata.Document{}
if err := document.LoadByID(ctx, tx, scope, version.DocumentID); err != nil {
return nil, fmt.Errorf("cannot load document: %w", err)
}
document.CurrentPublishedMajor = &version.Major
document.CurrentPublishedMinor = &version.Minor
if err := s.svc.Documents.finalizePublish(ctx, scope, tx, document, version, nil); err != nil {
return nil, fmt.Errorf("cannot finalize publish: %w", err)
}
if err := s.svc.Documents.cancelPreviousMajorSignatureRequestsInTx(ctx, scope, tx, version.DocumentID, version.Major); err != nil {
return nil, fmt.Errorf("cannot cancel signature requests from previous major versions: %w", err)
}
return version, nil
}