From 612e9cbc22be496c4c50aa177076bf216551d2c6 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Wed, 24 Jun 2026 10:51:32 +0200 Subject: [PATCH] Move pending signature requests on minor publish When a new minor version is published, still-REQUESTED signature requests on the previous version now move onto the newly published version, keeping the same signature row so the notification schedule (count and last-notified time) is preserved. SIGNED signatures are left untouched. Signed-off-by: Sacha Al Himdani --- e2e/console/document_version_test.go | 88 ++++++++++++++++++++++ pkg/coredata/document_version_signature.go | 41 ++++++++++ pkg/probo/document_service.go | 22 ++++++ 3 files changed, 151 insertions(+) diff --git a/e2e/console/document_version_test.go b/e2e/console/document_version_test.go index 44cd1a2d2..8f816b194 100644 --- a/e2e/console/document_version_test.go +++ b/e2e/console/document_version_test.go @@ -2052,6 +2052,94 @@ func TestDocumentVersion_RequestSignatureDeduplicatesAcrossMinors(t *testing.T) assertRequestedSignatureCount(t, owner, v11ID, 1) } +// requestedSignatureVersions returns a map of signature node ID -> the document +// version ID it is currently attached to, for the REQUESTED signatures visible +// from versionID (the signatures connection aggregates across every minor of +// the major). +func requestedSignatureVersions( + t *testing.T, + owner *testutil.Client, + versionID string, +) map[string]string { + t.Helper() + + var result struct { + Node struct { + Signatures struct { + Edges []struct { + Node struct { + ID string `json:"id"` + State string `json:"state"` + DocumentVersion struct { + ID string `json:"id"` + } `json:"documentVersion"` + } `json:"node"` + } `json:"edges"` + } `json:"signatures"` + } `json:"node"` + } + + err := owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on DocumentVersion { + signatures(first: 50, filter: { states: [REQUESTED] }) { + edges { node { id state documentVersion { id } } } + } + } + } + } + `, map[string]any{"id": versionID}, &result) + require.NoError(t, err) + + out := make(map[string]string, len(result.Node.Signatures.Edges)) + for _, edge := range result.Node.Signatures.Edges { + out[edge.Node.ID] = edge.Node.DocumentVersion.ID + } + + return out +} + +// TestDocumentVersion_MinorPublishMovesSignatureRequestsToNewVersion verifies +// that publishing a new minor version carries the still-pending signature +// requests from the previous version onto the newly published version. The same +// signature row is reused (its ID is preserved), so its notification schedule +// (count and last-notified time) is kept intact rather than reset. +func TestDocumentVersion_MinorPublishMovesSignatureRequestsToNewVersion(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + signer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + docID, _ := createTestDocument(t, owner) + + v10ID := publishMajorDocumentVersion(t, owner, docID) + requestDocumentSignature(t, owner, v10ID, signer.GetProfileID().String()) + + // The pending request starts attached to v1.0. + before := requestedSignatureVersions(t, owner, v10ID) + require.Len(t, before, 1) + + var signatureID string + for id, versionID := range before { + signatureID = id + + assert.Equal(t, v10ID, versionID, "the request must start on v1.0") + } + + // A minor bump publishes v1.1 within the same major. + updateDocumentContent(t, owner, docID, "Updated content for 1.1") + v11ID := publishMinorDocumentVersion(t, owner, docID) + require.NotEqual(t, v10ID, v11ID) + + // The same request row is now attached to the newly published v1.1. + after := requestedSignatureVersions(t, owner, v11ID) + require.Len(t, after, 1) + + movedVersionID, ok := after[signatureID] + require.True(t, ok, "the signature request must keep its identity across the minor publish") + assert.Equal(t, v11ID, movedVersionID, "the pending request must move to the newly published minor version") +} + // signDocumentVersion signs the version as the authenticated client and returns // the resulting signature node's id, state and signing time. func signDocumentVersion(t *testing.T, signer *testutil.Client, versionID string) (id, state, signedAt string) { diff --git a/pkg/coredata/document_version_signature.go b/pkg/coredata/document_version_signature.go index 54205c9c5..8eee35641 100644 --- a/pkg/coredata/document_version_signature.go +++ b/pkg/coredata/document_version_signature.go @@ -728,6 +728,47 @@ WHERE return nil } +func (pvss *DocumentVersionSignatures) MoveRequestedToVersionWithinMajor( + ctx context.Context, + conn pg.Tx, + scope Scoper, + targetVersionID gid.GID, +) error { + q := ` +UPDATE document_version_signatures +SET + document_version_id = @target_version_id, + updated_at = @now +WHERE + %s + AND state = @state + AND document_version_id <> @target_version_id + AND document_version_id IN ( + SELECT dv.id + FROM document_versions dv + INNER JOIN document_versions target + ON target.document_id = dv.document_id + AND target.major = dv.major + WHERE target.id = @target_version_id + ) +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "target_version_id": targetVersionID, + "state": DocumentVersionSignatureStateRequested, + "now": time.Now(), + } + maps.Copy(args, scope.SQLArguments()) + + if _, err := conn.Exec(ctx, q, args); err != nil { + return fmt.Errorf("cannot move requested document version signatures to the newly published version: %w", err) + } + + return nil +} + func (pvss *DocumentVersionSignatures) DeleteRequestedByDocumentIDBelowMajor( ctx context.Context, conn pg.Tx, diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index 0989226b2..d55745cec 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -2889,9 +2889,31 @@ func (s *DocumentService) publishMinorVersionInTx( return nil, nil, err } + if err := s.moveRequestedSignaturesToVersionInTx(ctx, scope, tx, documentVersion.ID); err != nil { + return nil, nil, err + } + return document, documentVersion, nil } +// moveRequestedSignaturesToVersionInTx carries every still-pending signature +// request from a prior minor of the same major onto the newly published minor +// version. The new minor supersedes the previous one while keeping the same +// signing obligations, so REQUESTED signatures follow along with their +// notification schedule (time and count) intact. SIGNED signatures stay put. +func (s *DocumentService) moveRequestedSignaturesToVersionInTx( + ctx context.Context, scope coredata.Scoper, + tx pg.Tx, + documentVersionID gid.GID, +) error { + signatures := &coredata.DocumentVersionSignatures{} + if err := signatures.MoveRequestedToVersionWithinMajor(ctx, tx, scope, documentVersionID); err != nil { + return fmt.Errorf("cannot move signature requests to the newly published minor version: %w", err) + } + + return nil +} + func (s *DocumentService) generateAndUploadPublicationPDF( ctx context.Context, scope coredata.Scoper, documentVersion *coredata.DocumentVersion,