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 <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-06-11 13:37:33 +02:00
parent 71e8d662bc
commit bf20ca1a90
13 changed files with 391 additions and 159 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -0,0 +1,16 @@
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
--
-- 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;