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}
{__("Back to Documents")}
diff --git a/apps/console/src/pages/organizations/employee/_components/VersionActions.tsx b/apps/console/src/pages/organizations/employee/_components/VersionActions.tsx
index 680c12be3..02e930938 100644
--- a/apps/console/src/pages/organizations/employee/_components/VersionActions.tsx
+++ b/apps/console/src/pages/organizations/employee/_components/VersionActions.tsx
@@ -22,6 +22,7 @@ const fragment = graphql`
fragment VersionActionsFragment on EmployeeDocumentVersion {
id
signed
+ consentText
}
`;
@@ -59,12 +60,10 @@ export function VersionActions({
disabled={isSigning}
icon={isSigning ? Spinner : undefined}
>
- {__("I acknowledge and agree")}
+ {__("Review and sign")}
-
- {__(
- "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 {