diff --git a/e2e/console/document_version_test.go b/e2e/console/document_version_test.go index 0663e92cf..dd6aeaec7 100644 --- a/e2e/console/document_version_test.go +++ b/e2e/console/document_version_test.go @@ -2218,6 +2218,40 @@ func signDocumentVersion(t *testing.T, signer *testutil.Client, versionID string result.SignDocument.DocumentVersionSignature.SignedAt } +func archiveDocument(t *testing.T, owner *testutil.Client, docID string) { + t.Helper() + + _, err := owner.Do(` + mutation($input: ArchiveDocumentInput!) { + archiveDocument(input: $input) { + document { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentId": docID, + }, + }) + require.NoError(t, err) +} + +func unarchiveDocument(t *testing.T, owner *testutil.Client, docID string) { + t.Helper() + + _, err := owner.Do(` + mutation($input: UnarchiveDocumentInput!) { + unarchiveDocument(input: $input) { + document { id status } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentId": docID, + }, + }) + require.NoError(t, err) +} + // signDocumentVersionMutation is the raw mutation used by the negative-path // signing tests so they can assert the request is rejected. const signDocumentVersionMutation = ` @@ -2291,6 +2325,171 @@ func TestDocumentVersion_SignDocumentTwiceFails(t *testing.T) { }) } +func TestDocumentVersion_ArchiveVoidsPendingQuorum(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, owner) + requestDocumentApproval(t, owner, docID, []string{getOwnerProfileID(t, owner)}) + + archiveDocument(t, owner, docID) + + var result struct { + Node struct { + Status string `json:"status"` + Versions struct { + Edges []struct { + Node struct { + Status string `json:"status"` + Major int `json:"major"` + Minor int `json:"minor"` + ApprovalQuorums struct { + Edges []struct { + Node struct { + Status string `json:"status"` + Decisions struct { + Edges []struct { + Node struct { + State string `json:"state"` + } `json:"node"` + } `json:"edges"` + } `json:"decisions"` + } `json:"node"` + } `json:"edges"` + } `json:"approvalQuorums"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"node"` + } + + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Document { + status + versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { + node { + status + major + minor + approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { + node { + status + decisions(first: 10) { + edges { node { state } } + } + } + } + } + } + } + } + } + } + } + `, map[string]any{"id": docID}, &result) + require.NoError(t, err) + require.NotEmpty(t, result.Node.Versions.Edges) + + version := result.Node.Versions.Edges[0].Node + assert.Equal(t, "ARCHIVED", result.Node.Status) + assert.Equal(t, "DRAFT", version.Status) + assert.Equal(t, 0, version.Major) + assert.Equal(t, 1, version.Minor) + require.NotEmpty(t, version.ApprovalQuorums.Edges) + assert.Equal(t, "VOIDED", version.ApprovalQuorums.Edges[0].Node.Status) + + for _, edge := range version.ApprovalQuorums.Edges[0].Node.Decisions.Edges { + assert.Equal(t, "VOIDED", edge.Node.State) + } +} + +func TestDocumentVersion_ArchiveCancelsPendingSignatures(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, owner) + approveTestDocument(t, owner, docID) + + publishedVersionID := latestDocumentVersionID(t, owner, docID) + requestDocumentSignature(t, owner, publishedVersionID, getOwnerProfileID(t, owner)) + + archiveDocument(t, owner, docID) + + var result struct { + Node struct { + Signatures struct { + Edges []struct { + Node struct { + State string `json:"state"` + } `json:"node"` + } `json:"edges"` + } `json:"signatures"` + } `json:"node"` + } + + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on DocumentVersion { + signatures(first: 10) { + edges { node { state } } + } + } + } + } + `, map[string]any{"id": publishedVersionID}, &result) + require.NoError(t, err) + assert.Empty(t, result.Node.Signatures.Edges) +} + +func TestDocumentVersion_UnarchiveRestoresEditableDraft(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, owner) + requestDocumentApproval(t, owner, docID, []string{getOwnerProfileID(t, owner)}) + + archiveDocument(t, owner, docID) + unarchiveDocument(t, owner, docID) + + var result struct { + Node struct { + Status string `json:"status"` + Versions struct { + Edges []struct { + Node struct { + Status string `json:"status"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"node"` + } + + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Document { + status + versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { node { status } } + } + } + } + } + `, map[string]any{"id": docID}, &result) + require.NoError(t, err) + require.NotEmpty(t, result.Node.Versions.Edges) + + assert.Equal(t, "ACTIVE", result.Node.Status) + assert.Equal(t, "DRAFT", result.Node.Versions.Edges[0].Node.Status) + + updateDocumentContent(t, owner, docID, "Updated after unarchive") +} + // TestDocumentVersion_SignArchivedDocumentFails verifies that a document // archived after its signature was requested can no longer be signed. This // guards the archived/published preconditions that are re-validated inside the @@ -2307,18 +2506,33 @@ func TestDocumentVersion_SignArchivedDocumentFails(t *testing.T) { requestDocumentSignature(t, owner, publishedVersionID, ownerProfileID) - _, err := owner.Do(` - mutation($input: ArchiveDocumentInput!) { - archiveDocument(input: $input) { - document { id } + archiveDocument(t, owner, docID) + + var signatureResult struct { + Node struct { + Signatures struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"signatures"` + } `json:"node"` + } + + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on DocumentVersion { + signatures(first: 10) { + edges { node { id } } + } + } } } - `, map[string]any{ - "input": map[string]any{ - "documentId": docID, - }, - }) + `, map[string]any{"id": publishedVersionID}, &signatureResult) require.NoError(t, err) + assert.Empty(t, signatureResult.Node.Signatures.Edges) _ = owner.ExecuteShouldFail(signDocumentVersionMutation, map[string]any{ "input": map[string]any{ diff --git a/pkg/coredata/document_version_signature.go b/pkg/coredata/document_version_signature.go index 7aca39bb4..8da20140c 100644 --- a/pkg/coredata/document_version_signature.go +++ b/pkg/coredata/document_version_signature.go @@ -811,6 +811,89 @@ WHERE return nil } +func (pvss *DocumentVersionSignatures) LoadRequestedByDocumentID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + documentID gid.GID, +) error { + q := ` +SELECT + document_version_signatures.id, + document_version_signatures.organization_id, + document_version_signatures.document_version_id, + document_version_signatures.state, + document_version_signatures.signed_by_profile_id, + document_version_signatures.signed_at, + document_version_signatures.requested_at, + document_version_signatures.electronic_signature_id, + document_version_signatures.created_at, + document_version_signatures.updated_at +FROM + document_version_signatures +INNER JOIN document_versions ON document_versions.id = document_version_signatures.document_version_id +WHERE + %s + AND document_versions.document_id = @document_id + AND document_version_signatures.state = @state +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "document_id": documentID, + "state": DocumentVersionSignatureStateRequested, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query requested document version signatures: %w", err) + } + + documentVersionSignatures, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersionSignature]) + if err != nil { + return fmt.Errorf("cannot collect requested document version signatures: %w", err) + } + + *pvss = documentVersionSignatures + + return nil +} + +func (pvss *DocumentVersionSignatures) DeleteRequestedByDocumentID( + ctx context.Context, + conn pg.Tx, + scope Scoper, + documentID gid.GID, +) error { + q := ` +DELETE FROM document_version_signatures +WHERE + %s + AND state = @state + AND document_version_id IN ( + SELECT id + FROM document_versions + WHERE document_id = @document_id + ) +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "document_id": documentID, + "state": DocumentVersionSignatureStateRequested, + } + maps.Copy(args, scope.SQLArguments()) + + if _, err := conn.Exec(ctx, q, args); err != nil { + return fmt.Errorf("cannot delete requested document version signatures: %w", err) + } + + return nil +} + func (pvss *DocumentVersionSignaturesWithPeople) LoadByDocumentVersionIDWithPeople( ctx context.Context, conn pg.Querier, diff --git a/pkg/coredata/migrations/20260711T095134Z.sql b/pkg/coredata/migrations/20260711T095134Z.sql new file mode 100644 index 000000000..77f756275 --- /dev/null +++ b/pkg/coredata/migrations/20260711T095134Z.sql @@ -0,0 +1,66 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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. + +-- Archived documents must not retain in-flight approval or signature workflows. +-- Backfill rows that were archived before archive teardown was enforced in the +-- application layer. + +UPDATE document_version_approval_decisions d +SET + state = 'VOIDED', + updated_at = NOW() +FROM document_version_approval_quorums q +INNER JOIN document_versions dv ON dv.id = q.version_id +INNER JOIN documents doc ON doc.id = dv.document_id +WHERE + d.quorum_id = q.id + AND d.state = 'PENDING' + AND doc.archived_at IS NOT NULL; + +UPDATE document_version_approval_quorums q +SET + status = 'VOIDED', + updated_at = NOW() +FROM document_versions dv +INNER JOIN documents doc ON doc.id = dv.document_id +WHERE + q.version_id = dv.id + AND q.status = 'PENDING' + AND doc.archived_at IS NOT NULL; + +UPDATE document_versions dv +SET + status = 'DRAFT', + major = CASE + WHEN doc.current_published_major IS NOT NULL THEN doc.current_published_major + ELSE 0 + END, + minor = CASE + WHEN doc.current_published_major IS NOT NULL THEN doc.current_published_minor + 1 + ELSE 1 + END, + updated_at = NOW() +FROM documents doc +WHERE + dv.document_id = doc.id + AND doc.archived_at IS NOT NULL + AND dv.status = 'PENDING_APPROVAL'; + +DELETE FROM document_version_signatures dvs +USING document_versions dv +INNER JOIN documents doc ON doc.id = dv.document_id +WHERE + dvs.document_version_id = dv.id + AND dvs.state = 'REQUESTED' + AND doc.archived_at IS NOT NULL; diff --git a/pkg/probo/document_approval_service.go b/pkg/probo/document_approval_service.go index 1ff0d1b09..f3786ff7a 100644 --- a/pkg/probo/document_approval_service.go +++ b/pkg/probo/document_approval_service.go @@ -642,50 +642,7 @@ func (s *DocumentApprovalService) VoidApproval( return &ErrDocumentVersionNotPendingApproval{} } - 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 + return s.voidApprovalInTx(ctx, scope, tx, document, documentVersion, quorum) }, ) if err != nil { @@ -695,6 +652,59 @@ func (s *DocumentApprovalService) VoidApproval( 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) GetQuorum( ctx context.Context, scope coredata.Scoper, quorumID gid.GID, diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index 0027998ea..273062ade 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -1646,6 +1646,12 @@ func (s *DocumentService) BulkArchive( return s.svc.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { + for _, documentID := range documentIDs { + if err := s.teardownDocumentWorkflowsInTx(ctx, scope, tx, documentID); err != nil { + return err + } + } + controlDocument := coredata.ControlDocument{} if err := controlDocument.DeleteByDocumentIDs(ctx, tx, scope, documentIDs); err != nil { return fmt.Errorf("cannot delete control mappings: %w", err) @@ -1691,6 +1697,12 @@ func (s *DocumentService) BulkUnarchive( return s.svc.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { + for _, documentID := range documentIDs { + if err := s.ensureDraftReadyOnUnarchiveInTx(ctx, scope, tx, documentID); err != nil { + return err + } + } + if err := documents.BulkUnarchive(ctx, tx, scope); err != nil { return err } @@ -1741,6 +1753,102 @@ func (s *DocumentService) clearDocumentReferences( return nil } +func (s *DocumentService) teardownDocumentWorkflowsInTx( + ctx context.Context, scope coredata.Scoper, + tx pg.Tx, + documentID gid.GID, +) error { + document := &coredata.Document{} + if err := document.LoadByID(ctx, tx, scope, documentID); err != nil { + return fmt.Errorf("cannot load document %q: %w", documentID, err) + } + + documentVersion := &coredata.DocumentVersion{} + if err := documentVersion.LoadLatestVersion(ctx, tx, scope, documentID); err != nil { + return fmt.Errorf("cannot load latest document version: %w", err) + } + + if documentVersion.Status == coredata.DocumentVersionStatusPendingApproval { + quorum := &coredata.DocumentVersionApprovalQuorum{} + if err := quorum.LoadLastByDocumentVersionID(ctx, tx, scope, documentVersion.ID); err != nil { + if !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load approval quorum: %w", err) + } + } else if quorum.Status == coredata.DocumentVersionApprovalQuorumStatusPending { + if err := s.svc.DocumentApprovals.voidApprovalInTx(ctx, scope, tx, document, documentVersion, quorum); err != nil { + return err + } + } + } + + signatures := &coredata.DocumentVersionSignatures{} + if err := signatures.LoadRequestedByDocumentID(ctx, tx, scope, documentID); err != nil { + return fmt.Errorf("cannot load requested document version signatures: %w", err) + } + + for _, signature := range *signatures { + version := &coredata.DocumentVersion{} + if err := version.LoadByID(ctx, tx, scope, signature.DocumentVersionID); err != nil { + return fmt.Errorf("cannot load document version: %w", err) + } + + if err := signature.Delete(ctx, tx, scope, signature.ID); err != nil { + return fmt.Errorf("cannot delete document version signature: %w", err) + } + + if err := s.emitDocumentEvent( + ctx, + scope, + tx, + documentID, + coredata.WebhookEventTypeDocumentVersionSignatureCancelled, + version, + signature, + nil, + nil, + ); err != nil { + return fmt.Errorf("cannot emit document version signature cancelled webhook: %w", err) + } + } + + return nil +} + +func (s *DocumentService) ensureDraftReadyOnUnarchiveInTx( + ctx context.Context, scope coredata.Scoper, + tx pg.Tx, + documentID gid.GID, +) error { + document := &coredata.Document{} + if err := document.LoadByID(ctx, tx, scope, documentID); err != nil { + return fmt.Errorf("cannot load document %q: %w", documentID, err) + } + + documentVersion := &coredata.DocumentVersion{} + if err := documentVersion.LoadLatestVersion(ctx, tx, scope, documentID); err != nil { + return fmt.Errorf("cannot load latest document version: %w", err) + } + + 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 + } + + return s.svc.DocumentApprovals.voidApprovalInTx(ctx, scope, tx, document, documentVersion, quorum) +} + func (s *DocumentService) RequestExport( ctx context.Context, scope coredata.Scoper, documentIDs []gid.GID, @@ -2408,6 +2516,10 @@ func (s *DocumentService) Archive( return &ErrDocumentArchived{} } + if err := s.teardownDocumentWorkflowsInTx(ctx, scope, tx, documentID); err != nil { + return err + } + controlDocument := coredata.ControlDocument{} if err := controlDocument.DeleteByDocumentIDs(ctx, tx, scope, []gid.GID{documentID}); err != nil { return fmt.Errorf("cannot delete control mappings: %w", err) @@ -2468,6 +2580,10 @@ func (s *DocumentService) Unarchive( return &ErrDocumentNotArchived{} } + if err := s.ensureDraftReadyOnUnarchiveInTx(ctx, scope, tx, documentID); err != nil { + return err + } + document.Status = coredata.DocumentStatusActive document.ArchivedAt = nil document.UpdatedAt = now