From bf20ca1a9049f8c977d2f1d5ae154b024dc4058d Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Thu, 11 Jun 2026 13:37:33 +0200 Subject: [PATCH] Add esign to document signatures Employee document signatures recorded an acknowledgment with no cryptographic proof, unlike document approvals which already create and accept an electronic signature on every decision. Mirror the approval flow on the sign path: generate the signed document PDF, create-and-accept an esign record, and persist its id on the document_version_signatures row through a new electronic_signature_id column. Capture the signer IP and user agent in the resolver, and re-check the published/archived preconditions inside the transaction so the seal cannot race document state. Make the consent wording a single backend source of truth shared by the text that is sealed and the text shown in the UI. Define DocumentSignatureConsentText and DocumentApprovalConsentText in the probo service package and the NDA copy in the trust service, each owned by the flow that uses it, and stop esign from appending the generic clause to caller-provided consent text so approvals no longer seal a duplicated sentence. Expose the resolved consent text through GraphQL on EmployeeDocumentVersion and DocumentVersionApprovalDecision, and have the signing, approval, and NDA pages render it from the API instead of hard-coded strings, mirroring how the NDA page already worked. Align the wording with the actual interaction: the buttons read "Review and sign" and "Review and approve", the clauses reference those actions, and the inaccurate "typing my full name" phrasing is removed everywhere. Signed-off-by: Sacha Al Himdani --- .../documents/approve/DocumentApprovePage.tsx | 5 +- .../employee/_components/VersionActions.tsx | 9 +- apps/trust/src/pages/NDAPage.tsx | 8 +- e2e/console/document_version_test.go | 142 ++++++++++++++ pkg/coredata/document_version_signature.go | 61 +++--- .../electronic_signature_document_type.go | 69 ------- pkg/coredata/migrations/20260610T992900Z.sql | 16 ++ pkg/esign/service.go | 12 +- pkg/probo/document_approval_service.go | 4 +- pkg/probo/document_service.go | 178 ++++++++++++++---- .../api/console/v1/document_resolvers.go | 41 +++- .../api/console/v1/graphql/document.graphql | 2 + pkg/trust/service.go | 3 + 13 files changed, 391 insertions(+), 159 deletions(-) create mode 100644 pkg/coredata/migrations/20260610T992900Z.sql diff --git a/apps/console/src/pages/organizations/documents/approve/DocumentApprovePage.tsx b/apps/console/src/pages/organizations/documents/approve/DocumentApprovePage.tsx index 112093655..02edfabda 100644 --- a/apps/console/src/pages/organizations/documents/approve/DocumentApprovePage.tsx +++ b/apps/console/src/pages/organizations/documents/approve/DocumentApprovePage.tsx @@ -98,6 +98,7 @@ const decisionFragment = graphql` fragment DocumentApprovePageDecisionFragment on DocumentVersionApprovalDecision { id state + consentText canApprove: permission(action: "core:document-version:approve") canReject: permission(action: "core:document-version:reject") } @@ -350,12 +351,12 @@ function ViewerDecision(props: { }); }} > - {__("Approve")} + {__("Review and approve")} )}

- {__("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.")} + {decision.consentText}

-

- {__( - "By clicking 'I acknowledge and agree', your digital signature will be recorded.", - )} +

+ {versionData.consentText}

); diff --git a/apps/trust/src/pages/NDAPage.tsx b/apps/trust/src/pages/NDAPage.tsx index a6b44d89f..00a30f6c2 100644 --- a/apps/trust/src/pages/NDAPage.tsx +++ b/apps/trust/src/pages/NDAPage.tsx @@ -223,11 +223,7 @@ export function NDAPage(props: { return ; } - const consentText = ndaSignature?.consentText - ? ndaSignature.consentText - : __( - "By clicking Review & Sign, you agree to the terms of this NDA. If you have questions about the NDA, please contact security@probo.com.", - ); + const consentText = ndaSignature?.consentText; return (
@@ -297,7 +293,7 @@ export function NDAPage(props: { > {isFailed ? __("Try again") - : __("Accept")} + : __("Review and sign")} )}
diff --git a/e2e/console/document_version_test.go b/e2e/console/document_version_test.go index 22764a061..44cd1a2d2 100644 --- a/e2e/console/document_version_test.go +++ b/e2e/console/document_version_test.go @@ -2051,3 +2051,145 @@ func TestDocumentVersion_RequestSignatureDeduplicatesAcrossMinors(t *testing.T) assertRequestedSignatureCount(t, owner, v10ID, 1) assertRequestedSignatureCount(t, owner, v11ID, 1) } + +// 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) { + t.Helper() + + var result struct { + SignDocument struct { + DocumentVersionSignature struct { + ID string `json:"id"` + State string `json:"state"` + SignedAt string `json:"signedAt"` + } `json:"documentVersionSignature"` + } `json:"signDocument"` + } + + err := signer.Execute(` + mutation($input: SignDocumentInput!) { + signDocument(input: $input) { + documentVersionSignature { id state signedAt } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": versionID, + }, + }, &result) + require.NoError(t, err) + + return result.SignDocument.DocumentVersionSignature.ID, + result.SignDocument.DocumentVersionSignature.State, + result.SignDocument.DocumentVersionSignature.SignedAt +} + +// signDocumentVersionMutation is the raw mutation used by the negative-path +// signing tests so they can assert the request is rejected. +const signDocumentVersionMutation = ` + mutation($input: SignDocumentInput!) { + signDocument(input: $input) { + documentVersionSignature { id state } + } + } +` + +// TestDocumentVersion_SignDocument verifies that a requested signature can be +// signed by the signatory, transitioning REQUESTED -> SIGNED and recording the +// signing time. Signing exercises the electronic-signature integration end to +// end: PDF export, upload, and esign create-and-accept. +func TestDocumentVersion_SignDocument(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, owner) + approveTestDocument(t, owner, docID) + + publishedVersionID := latestDocumentVersionID(t, owner, docID) + ownerProfileID := owner.GetProfileID().String() + + requestDocumentSignature(t, owner, publishedVersionID, ownerProfileID) + + id, state, signedAt := signDocumentVersion(t, owner, publishedVersionID) + + assert.NotEmpty(t, id) + assert.Equal(t, "SIGNED", state) + assert.NotEmpty(t, signedAt) +} + +// TestDocumentVersion_SignDocumentWithoutRequestFails verifies that a version +// cannot be signed unless a signature was first requested for the signatory. +func TestDocumentVersion_SignDocumentWithoutRequestFails(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, owner) + approveTestDocument(t, owner, docID) + + publishedVersionID := latestDocumentVersionID(t, owner, docID) + + _ = owner.ExecuteShouldFail(signDocumentVersionMutation, map[string]any{ + "input": map[string]any{ + "documentVersionId": publishedVersionID, + }, + }) +} + +// TestDocumentVersion_SignDocumentTwiceFails verifies that an already-signed +// signature cannot be signed again. +func TestDocumentVersion_SignDocumentTwiceFails(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, owner) + approveTestDocument(t, owner, docID) + + publishedVersionID := latestDocumentVersionID(t, owner, docID) + ownerProfileID := owner.GetProfileID().String() + + requestDocumentSignature(t, owner, publishedVersionID, ownerProfileID) + signDocumentVersion(t, owner, publishedVersionID) + + _ = owner.ExecuteShouldFail(signDocumentVersionMutation, map[string]any{ + "input": map[string]any{ + "documentVersionId": publishedVersionID, + }, + }) +} + +// 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 +// signing transaction so a state change between request and sign is honored. +func TestDocumentVersion_SignArchivedDocumentFails(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, owner) + approveTestDocument(t, owner, docID) + + publishedVersionID := latestDocumentVersionID(t, owner, docID) + ownerProfileID := owner.GetProfileID().String() + + requestDocumentSignature(t, owner, publishedVersionID, ownerProfileID) + + _, err := owner.Do(` + mutation($input: ArchiveDocumentInput!) { + archiveDocument(input: $input) { + document { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentId": docID, + }, + }) + require.NoError(t, err) + + _ = owner.ExecuteShouldFail(signDocumentVersionMutation, map[string]any{ + "input": map[string]any{ + "documentVersionId": publishedVersionID, + }, + }) +} diff --git a/pkg/coredata/document_version_signature.go b/pkg/coredata/document_version_signature.go index c6f68772d..85ffe0d97 100644 --- a/pkg/coredata/document_version_signature.go +++ b/pkg/coredata/document_version_signature.go @@ -32,15 +32,16 @@ import ( type ( DocumentVersionSignature struct { - ID gid.GID `json:"id" db:"id"` - OrganizationID gid.GID `json:"-" db:"organization_id"` - DocumentVersionID gid.GID `json:"document_version_id" db:"document_version_id"` - State DocumentVersionSignatureState `json:"state" db:"state"` - SignedBy gid.GID `json:"signed_by" db:"signed_by_profile_id"` - SignedAt *time.Time `json:"signed_at" db:"signed_at"` - RequestedAt time.Time `json:"requested_at" db:"requested_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID gid.GID `json:"id" db:"id"` + OrganizationID gid.GID `json:"-" db:"organization_id"` + DocumentVersionID gid.GID `json:"document_version_id" db:"document_version_id"` + State DocumentVersionSignatureState `json:"state" db:"state"` + SignedBy gid.GID `json:"signed_by" db:"signed_by_profile_id"` + SignedAt *time.Time `json:"signed_at" db:"signed_at"` + RequestedAt time.Time `json:"requested_at" db:"requested_at"` + ElectronicSignatureID *gid.GID `json:"-" db:"electronic_signature_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } DocumentVersionSignatures []*DocumentVersionSignature @@ -120,6 +121,7 @@ SELECT signed_by_profile_id, signed_at, requested_at, + electronic_signature_id, created_at, updated_at FROM @@ -181,6 +183,7 @@ major_signatures AS ( dvs.signed_by_profile_id, dvs.signed_at, dvs.requested_at, + dvs.electronic_signature_id, dvs.created_at, dvs.updated_at FROM document_version_signatures dvs @@ -195,6 +198,7 @@ SELECT signed_by_profile_id, signed_at, requested_at, + electronic_signature_id, created_at, updated_at FROM @@ -246,6 +250,7 @@ SELECT signed_by_profile_id, signed_at, requested_at, + electronic_signature_id, created_at, updated_at FROM @@ -290,6 +295,7 @@ INSERT INTO document_version_signatures ( signed_by_profile_id, signed_at, requested_at, + electronic_signature_id, created_at, updated_at ) VALUES ( @@ -301,22 +307,24 @@ INSERT INTO document_version_signatures ( @signed_by_profile_id, @signed_at, @requested_at, + @electronic_signature_id, @created_at, @updated_at ) ` args := pgx.StrictNamedArgs{ - "id": pvs.ID, - "tenant_id": scope.GetTenantID(), - "organization_id": pvs.OrganizationID, - "document_version_id": pvs.DocumentVersionID, - "state": pvs.State, - "signed_by_profile_id": pvs.SignedBy, - "signed_at": pvs.SignedAt, - "requested_at": pvs.RequestedAt, - "created_at": pvs.CreatedAt, - "updated_at": pvs.UpdatedAt, + "id": pvs.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": pvs.OrganizationID, + "document_version_id": pvs.DocumentVersionID, + "state": pvs.State, + "signed_by_profile_id": pvs.SignedBy, + "signed_at": pvs.SignedAt, + "requested_at": pvs.RequestedAt, + "electronic_signature_id": pvs.ElectronicSignatureID, + "created_at": pvs.CreatedAt, + "updated_at": pvs.UpdatedAt, } _, err := conn.Exec(ctx, q, args) @@ -357,6 +365,7 @@ SELECT 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 @@ -401,6 +410,7 @@ SET state = @state, signed_by_profile_id = @signed_by_profile_id, signed_at = @signed_at, + electronic_signature_id = @electronic_signature_id, updated_at = @updated_at WHERE %s @@ -410,11 +420,12 @@ WHERE q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.StrictNamedArgs{ - "id": pvs.ID, - "state": pvs.State, - "signed_by_profile_id": pvs.SignedBy, - "signed_at": pvs.SignedAt, - "updated_at": pvs.UpdatedAt, + "id": pvs.ID, + "state": pvs.State, + "signed_by_profile_id": pvs.SignedBy, + "signed_at": pvs.SignedAt, + "electronic_signature_id": pvs.ElectronicSignatureID, + "updated_at": pvs.UpdatedAt, } maps.Copy(args, scope.SQLArguments()) @@ -543,6 +554,7 @@ signatures_with_people AS ( dvs.signed_by_profile_id, dvs.signed_at, dvs.requested_at, + dvs.electronic_signature_id, dvs.created_at, dvs.updated_at, p.full_name AS signed_by_full_name @@ -570,6 +582,7 @@ SELECT signed_by_profile_id, signed_at, requested_at, + electronic_signature_id, created_at, updated_at, signed_by_full_name diff --git a/pkg/coredata/electronic_signature_document_type.go b/pkg/coredata/electronic_signature_document_type.go index ff6137ac8..fc97ff11c 100644 --- a/pkg/coredata/electronic_signature_document_type.go +++ b/pkg/coredata/electronic_signature_document_type.go @@ -41,8 +41,6 @@ const ( ElectronicSignatureDocumentTypeTemplate ElectronicSignatureDocumentType = "TEMPLATE" ElectronicSignatureDocumentTypeStatementOfApplicability ElectronicSignatureDocumentType = "STATEMENT_OF_APPLICABILITY" ElectronicSignatureDocumentTypeOther ElectronicSignatureDocumentType = "OTHER" - - ESignProcessConsentText = "By typing my full name and clicking Accept, I consent to sign this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature." ) var ( @@ -51,28 +49,6 @@ var ( _ encoding.TextUnmarshaler = (*ElectronicSignatureDocumentType)(nil) ) -func ElectronicSignatureDocumentTypes() []ElectronicSignatureDocumentType { - return []ElectronicSignatureDocumentType{ - ElectronicSignatureDocumentTypeNDA, - ElectronicSignatureDocumentTypeDPA, - ElectronicSignatureDocumentTypeMSA, - ElectronicSignatureDocumentTypeSOW, - ElectronicSignatureDocumentTypeSLA, - ElectronicSignatureDocumentTypeTOS, - ElectronicSignatureDocumentTypePrivacyPolicy, - ElectronicSignatureDocumentTypeGovernance, - ElectronicSignatureDocumentTypePolicy, - ElectronicSignatureDocumentTypeProcedure, - ElectronicSignatureDocumentTypePlan, - ElectronicSignatureDocumentTypeRegister, - ElectronicSignatureDocumentTypeRecord, - ElectronicSignatureDocumentTypeReport, - ElectronicSignatureDocumentTypeTemplate, - ElectronicSignatureDocumentTypeStatementOfApplicability, - ElectronicSignatureDocumentTypeOther, - } -} - func (v ElectronicSignatureDocumentType) IsValid() bool { switch v { case @@ -157,51 +133,6 @@ func (dt ElectronicSignatureDocumentType) DisplayName() string { } } -func (dt ElectronicSignatureDocumentType) ConsentText() (string, error) { - var docAgreement string - - switch dt { - case ElectronicSignatureDocumentTypeNDA: - docAgreement = "I agree to the terms of this Non-Disclosure Agreement." - case ElectronicSignatureDocumentTypeDPA: - docAgreement = "I agree to the terms of this Data Processing Agreement." - case ElectronicSignatureDocumentTypeMSA: - docAgreement = "I agree to the terms of this Master Service Agreement." - case ElectronicSignatureDocumentTypeSOW: - docAgreement = "I agree to the terms of this Statement of Work." - case ElectronicSignatureDocumentTypeSLA: - docAgreement = "I agree to the terms of this Service Level Agreement." - case ElectronicSignatureDocumentTypeTOS: - docAgreement = "I agree to these Terms of Service." - case ElectronicSignatureDocumentTypePrivacyPolicy: - docAgreement = "I agree to this Privacy Policy." - case ElectronicSignatureDocumentTypeGovernance: - docAgreement = "I acknowledge and agree to this Governance Document." - case ElectronicSignatureDocumentTypePolicy: - docAgreement = "I acknowledge and agree to this Policy." - case ElectronicSignatureDocumentTypeProcedure: - docAgreement = "I acknowledge and agree to this Procedure." - case ElectronicSignatureDocumentTypePlan: - docAgreement = "I acknowledge and agree to this Plan." - case ElectronicSignatureDocumentTypeRegister: - docAgreement = "I acknowledge and agree to this Register." - case ElectronicSignatureDocumentTypeRecord: - docAgreement = "I acknowledge and agree to this Record." - case ElectronicSignatureDocumentTypeReport: - docAgreement = "I acknowledge and agree to this Report." - case ElectronicSignatureDocumentTypeTemplate: - docAgreement = "I acknowledge and agree to this Template." - case ElectronicSignatureDocumentTypeStatementOfApplicability: - docAgreement = "I acknowledge and agree to this Statement of Applicability." - case ElectronicSignatureDocumentTypeOther: - return "", fmt.Errorf("cannot get consent text: document type OTHER requires explicit consent text") - default: - return "", fmt.Errorf("cannot get consent text: unknown document type %q", dt) - } - - return docAgreement + " " + ESignProcessConsentText, nil -} - func ElectronicSignatureDocumentTypeFromDocumentType(dt DocumentType) ElectronicSignatureDocumentType { switch dt { case DocumentTypeGovernance: diff --git a/pkg/coredata/migrations/20260610T992900Z.sql b/pkg/coredata/migrations/20260610T992900Z.sql new file mode 100644 index 000000000..0ea90fb9a --- /dev/null +++ b/pkg/coredata/migrations/20260610T992900Z.sql @@ -0,0 +1,16 @@ +-- 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. + +ALTER TABLE document_version_signatures + ADD COLUMN electronic_signature_id TEXT REFERENCES electronic_signatures(id) ON DELETE RESTRICT; diff --git a/pkg/esign/service.go b/pkg/esign/service.go index 0619a27e5..1df72e974 100644 --- a/pkg/esign/service.go +++ b/pkg/esign/service.go @@ -19,7 +19,6 @@ import ( "context" "errors" "fmt" - "strings" "time" "go.gearno.de/crypto/uuid" @@ -151,16 +150,7 @@ func (s *Service) CreateSignature( ) (*coredata.ElectronicSignature, error) { consentText := req.ConsentText if consentText == "" { - var err error - - consentText, err = req.DocumentType.ConsentText() - if err != nil { - return nil, fmt.Errorf("cannot derive consent text: %w", err) - } - } else { - if !strings.HasSuffix(consentText, coredata.ESignProcessConsentText) { - consentText = consentText + " " + coredata.ESignProcessConsentText - } + return nil, fmt.Errorf("consent text is required") } emailSubject := req.EmailSubject diff --git a/pkg/probo/document_approval_service.go b/pkg/probo/document_approval_service.go index 6d75ba381..ffab27187 100644 --- a/pkg/probo/document_approval_service.go +++ b/pkg/probo/document_approval_service.go @@ -36,6 +36,8 @@ import ( "go.probo.inc/probo/pkg/statelesstoken" ) +const DocumentApprovalConsentText = "By clicking \"Review and 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 @@ -364,7 +366,7 @@ func (s *DocumentApprovalService) Approve( SignerFullName: req.SignerFullName, SignerIPAddr: req.SignerIPAddr, SignerUA: req.SignerUA, - ConsentText: "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.", + ConsentText: DocumentApprovalConsentText, EmailSubject: fmt.Sprintf("Your approved %s - Certificate of Completion", document.Title), }, ) diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index 45047b42c..38c9d6c8a 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -38,6 +38,7 @@ import ( "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/docgen" + "go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/html2pdf" "go.probo.inc/probo/pkg/iam" @@ -50,6 +51,8 @@ import ( "go.probo.inc/probo/pkg/validator" ) +const DocumentSignatureConsentText = "By clicking \"Review and sign\", I consent to sign this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature." + type ( DocumentService struct { svc *Service @@ -119,6 +122,15 @@ type ( Signatory gid.GID } + SignDocumentVersionRequest struct { + DocumentVersionID gid.GID + IdentityID gid.GID + SignerFullName string + SignerEmail mail.Addr + SignerIPAddr string + SignerUA string + } + BulkRequestSignaturesRequest struct { DocumentIDs []gid.GID SignatoryIDs []gid.GID @@ -842,75 +854,161 @@ func (s *DocumentService) SendSigningNotifications( func (s *DocumentService) SignDocumentVersionByIdentity( ctx context.Context, scope coredata.Scoper, - documentVersionID gid.GID, - identityID gid.GID, + req SignDocumentVersionRequest, ) (*coredata.DocumentVersionSignature, error) { - var documentVersionSignature *coredata.DocumentVersionSignature + var ( + documentVersion *coredata.DocumentVersion + document *coredata.Document + documentVersionSignature *coredata.DocumentVersionSignature + ) - err := s.svc.pg.WithTx( + err := s.svc.pg.WithConn( ctx, - func(ctx context.Context, conn pg.Tx) error { - documentVersion := &coredata.DocumentVersion{} - if err := documentVersion.LoadByID(ctx, conn, scope, documentVersionID); err != nil { + 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 get document version: %w", err) } + if documentVersion.Status != coredata.DocumentVersionStatusPublished { + return &ErrDocumentVersionNotPublished{} + } + + 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{} + } + profile := &coredata.MembershipProfile{} // FIXME: will be done differently - if err := profile.LoadByIdentityIDAndOrganizationID(ctx, conn, scope, identityID, documentVersion.OrganizationID); err != nil { + if err := profile.LoadByIdentityIDAndOrganizationID(ctx, conn, scope, req.IdentityID, documentVersion.OrganizationID); err != nil { return fmt.Errorf("cannot find profile record for user email in organization %q: %w", documentVersion.OrganizationID, err) } - var signErr error + documentVersionSignature = &coredata.DocumentVersionSignature{} + if err := documentVersionSignature.LoadByDocumentVersionIDAndSignatory(ctx, conn, scope, req.DocumentVersionID, profile.ID); err != nil { + return fmt.Errorf("cannot load document version signature: %w", err) + } - documentVersionSignature, signErr = s.signDocumentVersionInTx(ctx, scope, conn, documentVersionID, profile.ID) + if documentVersionSignature.State == coredata.DocumentVersionSignatureStateSigned { + return &ErrDocumentVersionSignatureAlreadySigned{} + } - return signErr + return nil }, ) if err != nil { - return nil, fmt.Errorf("cannot sign document version: %w", err) + return nil, err } - return documentVersionSignature, nil -} - -func (s *DocumentService) signDocumentVersionInTx( - ctx context.Context, scope coredata.Scoper, - conn pg.Tx, - documentVersionID gid.GID, - signatory gid.GID, -) (*coredata.DocumentVersionSignature, error) { - documentVersion := &coredata.DocumentVersion{} - documentVersionSignature := &coredata.DocumentVersionSignature{} now := time.Now() - if err := documentVersion.LoadByID(ctx, conn, scope, documentVersionID); err != nil { - return nil, fmt.Errorf("cannot load document version %q: %w", documentVersionID, err) + pdfData, err := s.ExportPDF(ctx, scope, req.DocumentVersionID, ExportPDFOptions{}) + if err != nil { + return nil, fmt.Errorf("cannot export document PDF: %w", err) } - if documentVersion.Status != coredata.DocumentVersionStatusPublished { - return nil, fmt.Errorf("cannot sign unpublished version") + fileRecord := &coredata.File{ + ID: gid.New(scope.GetTenantID(), coredata.FileEntityType), + OrganizationID: documentVersion.OrganizationID, + BucketName: s.svc.bucket, + MimeType: "application/pdf", + FileName: fmt.Sprintf("signature-%s.pdf", documentVersionSignature.ID), + FileKey: uuid.MustNewV4().String(), + Visibility: coredata.FileVisibilityPrivate, + CreatedAt: now, + UpdatedAt: now, } - if err := documentVersionSignature.LoadByDocumentVersionIDAndSignatory(ctx, conn, scope, documentVersionID, signatory); err != nil { - return nil, fmt.Errorf("cannot load document version signature: %w", err) + fileSize, err := s.svc.fileManager.PutFile( + ctx, + fileRecord, + bytes.NewReader(pdfData), + map[string]string{ + "type": "signature-document", + "signature-id": documentVersionSignature.ID.String(), + }, + ) + if err != nil { + return nil, fmt.Errorf("cannot upload signature PDF: %w", err) } - if documentVersionSignature.State == coredata.DocumentVersionSignatureStateSigned { - return nil, &ErrDocumentVersionSignatureAlreadySigned{} - } + fileRecord.FileSize = fileSize - documentVersionSignature.State = coredata.DocumentVersionSignatureStateSigned - documentVersionSignature.SignedAt = &now - documentVersionSignature.UpdatedAt = now + signatureID := documentVersionSignature.ID - if err := documentVersion.Update(ctx, conn, scope); err != nil { - return nil, fmt.Errorf("cannot update document version: %w", err) - } + 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) + } - if err := documentVersionSignature.Update(ctx, conn, scope); err != nil { - return nil, fmt.Errorf("cannot update document version signature: %w", err) + if documentVersion.Status != coredata.DocumentVersionStatusPublished { + return &ErrDocumentVersionNotPublished{} + } + + 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{} + } + + documentVersionSignature = &coredata.DocumentVersionSignature{} + if err := documentVersionSignature.LoadByID(ctx, tx, scope, signatureID); err != nil { + return fmt.Errorf("cannot load document version signature: %w", err) + } + + if documentVersionSignature.State == coredata.DocumentVersionSignatureStateSigned { + return &ErrDocumentVersionSignatureAlreadySigned{} + } + + if err := fileRecord.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert signature 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: DocumentSignatureConsentText, + EmailSubject: fmt.Sprintf("Your signed %s - Certificate of Completion", document.Title), + }, + ) + if err != nil { + return fmt.Errorf("cannot create electronic signature: %w", err) + } + + documentVersionSignature.State = coredata.DocumentVersionSignatureStateSigned + documentVersionSignature.SignedAt = &now + documentVersionSignature.ElectronicSignatureID = &esig.ID + documentVersionSignature.UpdatedAt = now + + if err := documentVersionSignature.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update document version signature: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err } return documentVersionSignature, nil diff --git a/pkg/server/api/console/v1/document_resolvers.go b/pkg/server/api/console/v1/document_resolvers.go index fba0dcabd..95d743ea7 100644 --- a/pkg/server/api/console/v1/document_resolvers.go +++ b/pkg/server/api/console/v1/document_resolvers.go @@ -427,6 +427,11 @@ func (r *documentVersionApprovalDecisionResolver) Approver(ctx context.Context, return types.NewProfile(profile), nil } +// ConsentText is the resolver for the consentText field. +func (r *documentVersionApprovalDecisionResolver) ConsentText(ctx context.Context, obj *types.DocumentVersionApprovalDecision) (string, error) { + return probo.DocumentApprovalConsentText, nil +} + // Permission is the resolver for the permission field. func (r *documentVersionApprovalDecisionResolver) Permission(ctx context.Context, obj *types.DocumentVersionApprovalDecision, action string) (bool, error) { // Approve and reject actions are only allowed for the viewer's own decision. @@ -788,6 +793,11 @@ func (r *employeeDocumentVersionResolver) Signed(ctx context.Context, obj *types return signed, nil } +// ConsentText is the resolver for the consentText field. +func (r *employeeDocumentVersionResolver) ConsentText(ctx context.Context, obj *types.EmployeeDocumentVersion) (string, error) { + return probo.DocumentSignatureConsentText, nil +} + // ApprovalDecision is the resolver for the approvalDecision field. func (r *employeeDocumentVersionResolver) ApprovalDecision(ctx context.Context, obj *types.EmployeeDocumentVersion) (*types.DocumentVersionApprovalDecision, error) { scope, err := r.authorize(ctx, obj.DocumentID, probo.ActionEmployeeDocumentGet) @@ -1423,9 +1433,38 @@ func (r *mutationResolver) SignDocument(ctx context.Context, input types.SignDoc } identity := authn.IdentityFromContext(ctx) + httpReq := gqlutils.HTTPRequestFromContext(ctx) - documentVersionSignature, err := r.probo.Documents.SignDocumentVersionByIdentity(ctx, scope, input.DocumentVersionID, identity.ID) + signerIP, _, _ := net.SplitHostPort(httpReq.RemoteAddr) + if signerIP == "" { + signerIP = httpReq.RemoteAddr + } + + documentVersionSignature, err := r.probo.Documents.SignDocumentVersionByIdentity( + ctx, + scope, + probo.SignDocumentVersionRequest{ + DocumentVersionID: input.DocumentVersionID, + IdentityID: identity.ID, + SignerFullName: identity.FullName, + SignerEmail: identity.EmailAddress, + SignerIPAddr: signerIP, + SignerUA: httpReq.UserAgent(), + }, + ) if err != nil { + if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok { + return nil, gqlutils.Conflict(ctx, errArchived) + } + + if errNotPublished, ok := errors.AsType[*probo.ErrDocumentVersionNotPublished](err); ok { + return nil, gqlutils.Invalid(ctx, errNotPublished) + } + + if errAlreadySigned, ok := errors.AsType[*probo.ErrDocumentVersionSignatureAlreadySigned](err); ok { + return nil, gqlutils.Conflict(ctx, errAlreadySigned) + } + if errors.Is(err, coredata.ErrResourceAlreadyExists) { return nil, gqlutils.Conflict(ctx, err) } diff --git a/pkg/server/api/console/v1/graphql/document.graphql b/pkg/server/api/console/v1/graphql/document.graphql index 1afcd6827..cd31bc7b8 100644 --- a/pkg/server/api/console/v1/graphql/document.graphql +++ b/pkg/server/api/console/v1/graphql/document.graphql @@ -367,6 +367,7 @@ type DocumentVersionApprovalDecision implements Node { approver: Profile! @goField(forceResolver: true) state: DocumentVersionApprovalDecisionState! comment: String + consentText: String! @goField(forceResolver: true) decidedAt: Datetime createdAt: Datetime! updatedAt: Datetime! @@ -422,6 +423,7 @@ type EmployeeDocumentVersion classification: DocumentClassification! documentType: DocumentType! signed: Boolean! @goField(forceResolver: true) + consentText: String! @goField(forceResolver: true) approvalDecision: DocumentVersionApprovalDecision @goField(forceResolver: true) publishedAt: Datetime createdAt: Datetime! diff --git a/pkg/trust/service.go b/pkg/trust/service.go index c141d25c2..b076d36be 100644 --- a/pkg/trust/service.go +++ b/pkg/trust/service.go @@ -34,6 +34,8 @@ import ( "go.probo.inc/probo/pkg/slack" ) +const NDAConsentText = "By clicking \"Review and sign\", I consent to sign this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature. If you have questions about the NDA, please contact security@probo.com." + type ( Service struct { pg *pg.Client @@ -385,6 +387,7 @@ func (s *Service) ProvisionMember( DocumentType: coredata.ElectronicSignatureDocumentTypeNDA, FileID: *compliancePage.NonDisclosureAgreementFileID, SignerEmail: identity.EmailAddress, + ConsentText: NDAConsentText, }, ) if err != nil {