Add electronic signature

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-02-16 23:12:09 +01:00
parent e6f9d7aae2
commit c191d25e9a
58 changed files with 7557 additions and 292 deletions

View File

@@ -0,0 +1,424 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package coredata
import (
"context"
"errors"
"fmt"
"maps"
"strings"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/crypto/hash"
"go.probo.inc/probo/pkg/gid"
)
type ElectronicSignature struct {
ID gid.GID `db:"id"`
TenantID gid.TenantID `db:"tenant_id"`
OrganizationID gid.GID `db:"organization_id"`
Status ElectronicSignatureStatus `db:"status"`
DocumentType ElectronicSignatureDocumentType `db:"document_type"`
FileID gid.GID `db:"file_id"`
SignerEmail string `db:"signer_email"`
ConsentText string `db:"consent_text"`
SignerFullName *string `db:"signer_full_name"`
SignerIPAddress *string `db:"signer_ip_address"`
SignerUserAgent *string `db:"signer_user_agent"`
FileHash *string `db:"file_hash"`
Seal *string `db:"seal"`
SealVersion int `db:"seal_version"`
TSAToken []byte `db:"tsa_token"`
SignedAt *time.Time `db:"signed_at"`
CertificateFileID *gid.GID `db:"certificate_file_id"`
CertificateProcessingStartedAt *time.Time `db:"certificate_processing_started_at"`
AttemptCount int `db:"attempt_count"`
MaxAttempts int `db:"max_attempts"`
LastAttemptedAt *time.Time `db:"last_attempted_at"`
LastError *string `db:"last_error"`
ProcessingStartedAt *time.Time `db:"processing_started_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func (es *ElectronicSignature) NewEvent(
eventType ElectronicSignatureEventType,
eventSource ElectronicSignatureEventSource,
) ElectronicSignatureEvent {
now := time.Now()
return ElectronicSignatureEvent{
ID: gid.New(es.ID.TenantID(), ElectronicSignatureEventEntityType),
ElectronicSignatureID: es.ID,
EventType: eventType,
EventSource: eventSource,
ActorEmail: es.SignerEmail,
ActorIPAddress: ref.UnrefOrZero(es.SignerIPAddress),
ActorUserAgent: ref.UnrefOrZero(es.SignerUserAgent),
OccurredAt: now,
CreatedAt: now,
}
}
func (es *ElectronicSignature) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO electronic_signatures (
id, tenant_id, organization_id, status, document_type, file_id,
signer_email, consent_text, seal_version, attempt_count, max_attempts,
created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @status, @document_type, @file_id,
@signer_email, @consent_text, @seal_version, @attempt_count, @max_attempts,
@created_at, @updated_at
)
`
args := pgx.StrictNamedArgs{
"id": es.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": es.OrganizationID,
"status": es.Status,
"document_type": es.DocumentType,
"file_id": es.FileID,
"signer_email": es.SignerEmail,
"consent_text": es.ConsentText,
"seal_version": es.SealVersion,
"attempt_count": es.AttemptCount,
"max_attempts": es.MaxAttempts,
"created_at": es.CreatedAt,
"updated_at": es.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert electronic signature: %w", err)
}
return nil
}
func (es *ElectronicSignature) Update(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
UPDATE electronic_signatures SET
status = @status,
signer_full_name = @signer_full_name,
signer_ip_address = @signer_ip_address,
signer_user_agent = @signer_user_agent,
file_hash = @file_hash,
seal = @seal,
seal_version = @seal_version,
tsa_token = @tsa_token,
signed_at = @signed_at,
certificate_file_id = @certificate_file_id,
certificate_processing_started_at = @certificate_processing_started_at,
attempt_count = @attempt_count,
max_attempts = @max_attempts,
last_attempted_at = @last_attempted_at,
last_error = @last_error,
processing_started_at = @processing_started_at,
updated_at = @updated_at
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": es.ID,
"status": es.Status,
"signer_full_name": es.SignerFullName,
"signer_ip_address": es.SignerIPAddress,
"signer_user_agent": es.SignerUserAgent,
"file_hash": es.FileHash,
"seal": es.Seal,
"seal_version": es.SealVersion,
"tsa_token": es.TSAToken,
"signed_at": es.SignedAt,
"certificate_file_id": es.CertificateFileID,
"certificate_processing_started_at": es.CertificateProcessingStartedAt,
"attempt_count": es.AttemptCount,
"max_attempts": es.MaxAttempts,
"last_attempted_at": es.LastAttemptedAt,
"last_error": es.LastError,
"processing_started_at": es.ProcessingStartedAt,
"updated_at": es.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update electronic signature: %w", err)
}
return nil
}
func (es *ElectronicSignature) LoadByID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
id gid.GID,
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, file_id,
signer_email, consent_text, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
attempt_count, max_attempts, last_attempted_at, last_error,
processing_started_at, created_at, updated_at
FROM electronic_signatures
WHERE %s AND id = @id
LIMIT 1
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": id}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query electronic signature: %w", err)
}
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect electronic signature: %w", err)
}
*es = sig
return nil
}
func (es *ElectronicSignature) LoadByOrgEmailAndDocType(
ctx context.Context,
conn pg.Conn,
scope Scoper,
orgID gid.GID,
email string,
docType ElectronicSignatureDocumentType,
fileID gid.GID,
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, file_id,
signer_email, consent_text, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
attempt_count, max_attempts, last_attempted_at, last_error,
processing_started_at, created_at, updated_at
FROM electronic_signatures
WHERE %s
AND organization_id = @organization_id
AND signer_email = @signer_email
AND document_type = @document_type
AND file_id = @file_id
LIMIT 1
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": orgID,
"signer_email": email,
"document_type": docType,
"file_id": fileID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query electronic signature: %w", err)
}
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect electronic signature: %w", err)
}
*es = sig
return nil
}
func (es *ElectronicSignature) LoadNextAcceptedForUpdateSkipLocked(
ctx context.Context,
conn pg.Conn,
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, file_id,
signer_email, consent_text, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
attempt_count, max_attempts, last_attempted_at, last_error,
processing_started_at, created_at, updated_at
FROM electronic_signatures
WHERE status = 'ACCEPTED' AND attempt_count < max_attempts
ORDER BY updated_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
`
rows, err := conn.Query(ctx, q)
if err != nil {
return fmt.Errorf("cannot query accepted signatures: %w", err)
}
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect electronic signature: %w", err)
}
*es = sig
return nil
}
func (es *ElectronicSignature) LoadNextCompletedWithoutCertificateForUpdate(
ctx context.Context,
conn pg.Conn,
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, file_id,
signer_email, consent_text, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
attempt_count, max_attempts, last_attempted_at, last_error,
processing_started_at, created_at, updated_at
FROM electronic_signatures
WHERE status = 'COMPLETED'
AND certificate_file_id IS NULL
AND certificate_processing_started_at IS NULL
ORDER BY signed_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
`
rows, err := conn.Query(ctx, q)
if err != nil {
return fmt.Errorf("cannot query completed signatures: %w", err)
}
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect electronic signature: %w", err)
}
*es = sig
return nil
}
func ResetStaleProcessingSignatures(
ctx context.Context,
conn pg.Conn,
staleAfter time.Duration,
) error {
q := `
UPDATE electronic_signatures
SET status = 'ACCEPTED', processing_started_at = NULL, updated_at = NOW()
WHERE status = 'PROCESSING'
AND processing_started_at < NOW() - $1::interval
`
_, err := conn.Exec(ctx, q, staleAfter.String())
if err != nil {
return fmt.Errorf("cannot reset stale processing signatures: %w", err)
}
return nil
}
func (es *ElectronicSignature) ComputeSeal(version int) (string, error) {
switch version {
case 1:
return es.computeSealV1()
default:
return "", fmt.Errorf("unsupported seal version %d", version)
}
}
func (es *ElectronicSignature) computeSealV1() (string, error) {
if es.SignedAt == nil {
return "", fmt.Errorf("signed_at must not be nil")
}
fields := []string{
es.ID.String(),
es.OrganizationID.String(),
es.DocumentType.String(),
es.FileID.String(),
ref.UnrefOrZero(es.FileHash),
ref.UnrefOrZero(es.SignerFullName),
strings.ToLower(es.SignerEmail),
ref.UnrefOrZero(es.SignerIPAddress),
ref.UnrefOrZero(es.SignerUserAgent),
es.ConsentText,
es.SignedAt.UTC().Truncate(time.Microsecond).Format(time.RFC3339Nano),
}
for i, f := range fields {
if f == "" {
return "", fmt.Errorf("seal field %d must not be empty", i)
}
if strings.Contains(f, "\n") {
return "", fmt.Errorf("seal field %d must not contain newline", i)
}
}
input := strings.Join(fields, "\n")
return hash.SHA256Hex([]byte(input)), nil
}
func ResetStaleCertificateProcessing(
ctx context.Context,
conn pg.Conn,
staleAfter time.Duration,
) error {
q := `
UPDATE electronic_signatures
SET certificate_processing_started_at = NULL, updated_at = NOW()
WHERE status = 'COMPLETED'
AND certificate_file_id IS NULL
AND certificate_processing_started_at IS NOT NULL
AND certificate_processing_started_at < NOW() - $1::interval
`
_, err := conn.Exec(ctx, q, staleAfter.String())
if err != nil {
return fmt.Errorf("cannot reset stale certificate processing: %w", err)
}
return nil
}

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package coredata
import (
"database/sql/driver"
"fmt"
)
type (
ElectronicSignatureDocumentType string
)
const (
ElectronicSignatureDocumentTypeNDA ElectronicSignatureDocumentType = "NDA"
ElectronicSignatureDocumentTypeDPA ElectronicSignatureDocumentType = "DPA"
ElectronicSignatureDocumentTypeMSA ElectronicSignatureDocumentType = "MSA"
ElectronicSignatureDocumentTypeSOW ElectronicSignatureDocumentType = "SOW"
ElectronicSignatureDocumentTypeSLA ElectronicSignatureDocumentType = "SLA"
ElectronicSignatureDocumentTypeTOS ElectronicSignatureDocumentType = "TOS"
ElectronicSignatureDocumentTypePrivacyPolicy ElectronicSignatureDocumentType = "PRIVACY_POLICY"
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."
)
func ElectronicSignatureDocumentTypes() []ElectronicSignatureDocumentType {
return []ElectronicSignatureDocumentType{
ElectronicSignatureDocumentTypeNDA,
ElectronicSignatureDocumentTypeDPA,
ElectronicSignatureDocumentTypeMSA,
ElectronicSignatureDocumentTypeSOW,
ElectronicSignatureDocumentTypeSLA,
ElectronicSignatureDocumentTypeTOS,
ElectronicSignatureDocumentTypePrivacyPolicy,
ElectronicSignatureDocumentTypeOther,
}
}
func (dt ElectronicSignatureDocumentType) MarshalText() ([]byte, error) {
return []byte(dt.String()), nil
}
func (dt *ElectronicSignatureDocumentType) UnmarshalText(data []byte) error {
val := string(data)
switch val {
case ElectronicSignatureDocumentTypeNDA.String():
*dt = ElectronicSignatureDocumentTypeNDA
case ElectronicSignatureDocumentTypeDPA.String():
*dt = ElectronicSignatureDocumentTypeDPA
case ElectronicSignatureDocumentTypeMSA.String():
*dt = ElectronicSignatureDocumentTypeMSA
case ElectronicSignatureDocumentTypeSOW.String():
*dt = ElectronicSignatureDocumentTypeSOW
case ElectronicSignatureDocumentTypeSLA.String():
*dt = ElectronicSignatureDocumentTypeSLA
case ElectronicSignatureDocumentTypeTOS.String():
*dt = ElectronicSignatureDocumentTypeTOS
case ElectronicSignatureDocumentTypePrivacyPolicy.String():
*dt = ElectronicSignatureDocumentTypePrivacyPolicy
case ElectronicSignatureDocumentTypeOther.String():
*dt = ElectronicSignatureDocumentTypeOther
default:
return fmt.Errorf("invalid ElectronicSignatureDocumentType value: %q", val)
}
return nil
}
func (dt ElectronicSignatureDocumentType) String() string {
return string(dt)
}
func (dt *ElectronicSignatureDocumentType) Scan(value any) error {
val, ok := value.(string)
if !ok {
return fmt.Errorf("invalid scan source for ElectronicSignatureDocumentType, expected string got %T", value)
}
return dt.UnmarshalText([]byte(val))
}
func (dt ElectronicSignatureDocumentType) Value() (driver.Value, error) {
return dt.String(), nil
}
func (dt ElectronicSignatureDocumentType) DisplayName() string {
switch dt {
case ElectronicSignatureDocumentTypeNDA:
return "Non-Disclosure Agreement"
case ElectronicSignatureDocumentTypeDPA:
return "Data Processing Agreement"
case ElectronicSignatureDocumentTypeMSA:
return "Master Service Agreement"
case ElectronicSignatureDocumentTypeSOW:
return "Statement of Work"
case ElectronicSignatureDocumentTypeSLA:
return "Service Level Agreement"
case ElectronicSignatureDocumentTypeTOS:
return "Terms of Service"
case ElectronicSignatureDocumentTypePrivacyPolicy:
return "Privacy Policy"
default:
return string(dt)
}
}
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 ElectronicSignatureDocumentTypeOther:
return "", fmt.Errorf("document type OTHER requires explicit consent text")
default:
return "", fmt.Errorf("unknown document type %q", dt)
}
return docAgreement + " " + ESignProcessConsentText, nil
}

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
ElectronicSignatureEvent struct {
ID gid.GID `db:"id"`
TenantID gid.TenantID `db:"tenant_id"`
ElectronicSignatureID gid.GID `db:"electronic_signature_id"`
EventType ElectronicSignatureEventType `db:"event_type"`
EventSource ElectronicSignatureEventSource `db:"event_source"`
ActorEmail string `db:"actor_email"`
ActorIPAddress string `db:"actor_ip_address"`
ActorUserAgent string `db:"actor_user_agent"`
OccurredAt time.Time `db:"occurred_at"`
CreatedAt time.Time `db:"created_at"`
}
ElectronicSignatureEvents []*ElectronicSignatureEvent
)
func (e *ElectronicSignatureEvent) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO electronic_signature_events (
id, tenant_id, electronic_signature_id, event_type, event_source,
actor_email, actor_ip_address, actor_user_agent,
occurred_at, created_at
) VALUES (
@id, @tenant_id, @electronic_signature_id, @event_type, @event_source,
@actor_email, @actor_ip_address, @actor_user_agent,
@occurred_at, @created_at
)
`
args := pgx.StrictNamedArgs{
"id": e.ID,
"tenant_id": scope.GetTenantID(),
"electronic_signature_id": e.ElectronicSignatureID,
"event_type": e.EventType,
"event_source": e.EventSource,
"actor_email": e.ActorEmail,
"actor_ip_address": e.ActorIPAddress,
"actor_user_agent": e.ActorUserAgent,
"occurred_at": e.OccurredAt,
"created_at": e.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert electronic signature event: %w", err)
}
return nil
}
func (es *ElectronicSignatureEvents) LoadBySignatureID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
sigID gid.GID,
) error {
q := `
SELECT
id, tenant_id, electronic_signature_id, event_type, event_source,
actor_email, actor_ip_address, actor_user_agent,
occurred_at, created_at
FROM electronic_signature_events
WHERE %s AND electronic_signature_id = @electronic_signature_id
ORDER BY occurred_at ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"electronic_signature_id": sigID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query electronic signature events: %w", err)
}
events, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ElectronicSignatureEvent])
if err != nil {
return fmt.Errorf("cannot collect electronic signature events: %w", err)
}
*es = events
return nil
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package coredata
import (
"database/sql/driver"
"fmt"
)
type (
ElectronicSignatureEventSource string
)
const (
ElectronicSignatureEventSourceClient ElectronicSignatureEventSource = "CLIENT"
ElectronicSignatureEventSourceServer ElectronicSignatureEventSource = "SERVER"
)
func (s ElectronicSignatureEventSource) MarshalText() ([]byte, error) {
return []byte(s.String()), nil
}
func (s *ElectronicSignatureEventSource) UnmarshalText(data []byte) error {
val := string(data)
switch val {
case ElectronicSignatureEventSourceClient.String():
*s = ElectronicSignatureEventSourceClient
case ElectronicSignatureEventSourceServer.String():
*s = ElectronicSignatureEventSourceServer
default:
return fmt.Errorf("invalid ElectronicSignatureEventSource value: %q", val)
}
return nil
}
func (s ElectronicSignatureEventSource) String() string {
return string(s)
}
func (s *ElectronicSignatureEventSource) Scan(value any) error {
val, ok := value.(string)
if !ok {
return fmt.Errorf("invalid scan source for ElectronicSignatureEventSource, expected string got %T", value)
}
return s.UnmarshalText([]byte(val))
}
func (s ElectronicSignatureEventSource) Value() (driver.Value, error) {
return s.String(), nil
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package coredata
import (
"database/sql/driver"
"fmt"
)
type (
ElectronicSignatureEventType string
)
const (
ElectronicSignatureEventTypeDocumentViewed ElectronicSignatureEventType = "DOCUMENT_VIEWED"
ElectronicSignatureEventTypeConsentGiven ElectronicSignatureEventType = "CONSENT_GIVEN"
ElectronicSignatureEventTypeFullNameTyped ElectronicSignatureEventType = "FULL_NAME_TYPED"
ElectronicSignatureEventTypeSignatureAccepted ElectronicSignatureEventType = "SIGNATURE_ACCEPTED"
ElectronicSignatureEventTypeSignatureCompleted ElectronicSignatureEventType = "SIGNATURE_COMPLETED"
ElectronicSignatureEventTypeSealComputed ElectronicSignatureEventType = "SEAL_COMPUTED"
ElectronicSignatureEventTypeTimestampRequested ElectronicSignatureEventType = "TIMESTAMP_REQUESTED"
ElectronicSignatureEventTypeCertificateGenerated ElectronicSignatureEventType = "CERTIFICATE_GENERATED"
ElectronicSignatureEventTypeProcessingError ElectronicSignatureEventType = "PROCESSING_ERROR"
)
func (t ElectronicSignatureEventType) MarshalText() ([]byte, error) {
return []byte(t.String()), nil
}
func (t *ElectronicSignatureEventType) UnmarshalText(data []byte) error {
val := string(data)
switch val {
case ElectronicSignatureEventTypeDocumentViewed.String():
*t = ElectronicSignatureEventTypeDocumentViewed
case ElectronicSignatureEventTypeConsentGiven.String():
*t = ElectronicSignatureEventTypeConsentGiven
case ElectronicSignatureEventTypeFullNameTyped.String():
*t = ElectronicSignatureEventTypeFullNameTyped
case ElectronicSignatureEventTypeSignatureAccepted.String():
*t = ElectronicSignatureEventTypeSignatureAccepted
case ElectronicSignatureEventTypeSignatureCompleted.String():
*t = ElectronicSignatureEventTypeSignatureCompleted
case ElectronicSignatureEventTypeSealComputed.String():
*t = ElectronicSignatureEventTypeSealComputed
case ElectronicSignatureEventTypeTimestampRequested.String():
*t = ElectronicSignatureEventTypeTimestampRequested
case ElectronicSignatureEventTypeCertificateGenerated.String():
*t = ElectronicSignatureEventTypeCertificateGenerated
case ElectronicSignatureEventTypeProcessingError.String():
*t = ElectronicSignatureEventTypeProcessingError
default:
return fmt.Errorf("invalid ElectronicSignatureEventType value: %q", val)
}
return nil
}
func (t ElectronicSignatureEventType) String() string {
return string(t)
}
func (t *ElectronicSignatureEventType) Scan(value any) error {
val, ok := value.(string)
if !ok {
return fmt.Errorf("invalid scan source for ElectronicSignatureEventType, expected string got %T", value)
}
return t.UnmarshalText([]byte(val))
}
func (t ElectronicSignatureEventType) Value() (driver.Value, error) {
return t.String(), nil
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package coredata
import (
"database/sql/driver"
"fmt"
)
type (
ElectronicSignatureStatus string
)
const (
ElectronicSignatureStatusPending ElectronicSignatureStatus = "PENDING"
ElectronicSignatureStatusAccepted ElectronicSignatureStatus = "ACCEPTED"
ElectronicSignatureStatusProcessing ElectronicSignatureStatus = "PROCESSING"
ElectronicSignatureStatusCompleted ElectronicSignatureStatus = "COMPLETED"
ElectronicSignatureStatusFailed ElectronicSignatureStatus = "FAILED"
)
func (s ElectronicSignatureStatus) MarshalText() ([]byte, error) {
return []byte(s.String()), nil
}
func (s *ElectronicSignatureStatus) UnmarshalText(data []byte) error {
val := string(data)
switch val {
case ElectronicSignatureStatusPending.String():
*s = ElectronicSignatureStatusPending
case ElectronicSignatureStatusAccepted.String():
*s = ElectronicSignatureStatusAccepted
case ElectronicSignatureStatusProcessing.String():
*s = ElectronicSignatureStatusProcessing
case ElectronicSignatureStatusCompleted.String():
*s = ElectronicSignatureStatusCompleted
case ElectronicSignatureStatusFailed.String():
*s = ElectronicSignatureStatusFailed
default:
return fmt.Errorf("invalid ElectronicSignatureStatus value: %q", val)
}
return nil
}
func (s ElectronicSignatureStatus) String() string {
return string(s)
}
func (s *ElectronicSignatureStatus) Scan(value any) error {
val, ok := value.(string)
if !ok {
return fmt.Errorf("invalid scan source for ElectronicSignatureStatus, expected string got %T", value)
}
return s.UnmarshalText([]byte(val))
}
func (s ElectronicSignatureStatus) Value() (driver.Value, error) {
return s.String(), nil
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.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.
package coredata
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
EmailAttachment struct {
ID gid.GID `db:"id"`
EmailID gid.GID `db:"email_id"`
FileID gid.GID `db:"file_id"`
Filename string `db:"filename"`
// TODO (to drop)
ContentType string `db:"content_type"`
CreatedAt time.Time `db:"created_at"`
}
EmailAttachments []*EmailAttachment
)
func NewEmailAttachment(emailID, fileID gid.GID, filename, contentType string) *EmailAttachment {
return &EmailAttachment{
ID: gid.New(gid.NilTenant, EmailAttachmentEntityType),
EmailID: emailID,
FileID: fileID,
Filename: filename,
ContentType: contentType,
CreatedAt: time.Now(),
}
}
func (a *EmailAttachment) Insert(
ctx context.Context,
conn pg.Conn,
) error {
q := `
INSERT INTO email_attachments (id, email_id, file_id, filename, content_type, created_at)
VALUES (@id, @email_id, @file_id, @filename, @content_type, @created_at)
`
args := pgx.StrictNamedArgs{
"id": a.ID,
"email_id": a.EmailID,
"file_id": a.FileID,
"filename": a.Filename,
"content_type": a.ContentType,
"created_at": a.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert email attachment: %w", err)
}
return nil
}
func (a *EmailAttachments) LoadByEmailID(
ctx context.Context,
conn pg.Conn,
emailID gid.GID,
) error {
q := `
SELECT id, email_id, file_id, filename, content_type, created_at
FROM email_attachments
WHERE email_id = @email_id
ORDER BY created_at ASC
`
args := pgx.StrictNamedArgs{
"email_id": emailID,
}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query email attachments: %w", err)
}
attachments, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[EmailAttachment])
if err != nil {
return fmt.Errorf("cannot collect email attachments: %w", err)
}
*a = attachments
return nil
}

View File

@@ -82,6 +82,9 @@ const (
WebhookSubscriptionEntityType uint16 = 56
WebhookDataEntityType uint16 = 57
WebhookEventEntityType uint16 = 58
ElectronicSignatureEntityType uint16 = 59
ElectronicSignatureEventEntityType uint16 = 60
EmailAttachmentEntityType uint16 = 61
)
func NewEntityFromID(id gid.GID) (any, bool) {
@@ -200,6 +203,12 @@ func NewEntityFromID(id gid.GID) (any, bool) {
return &WebhookData{ID: id}, true
case WebhookEventEntityType:
return &WebhookEvent{ID: id}, true
case ElectronicSignatureEntityType:
return &ElectronicSignature{ID: id}, true
case ElectronicSignatureEventEntityType:
return &ElectronicSignatureEvent{ID: id}, true
case EmailAttachmentEntityType:
return &EmailAttachment{ID: id}, true
default:
return nil, false
}

View File

@@ -0,0 +1,94 @@
-- Electronic signature enums
CREATE TYPE electronic_signature_document_type AS ENUM (
'NDA', 'DPA', 'MSA', 'SOW', 'SLA', 'TOS', 'PRIVACY_POLICY', 'OTHER'
);
CREATE TYPE electronic_signature_status AS ENUM (
'PENDING',
'ACCEPTED',
'PROCESSING',
'COMPLETED',
'FAILED'
);
CREATE TYPE electronic_signature_event_type AS ENUM (
'DOCUMENT_VIEWED', 'CONSENT_GIVEN', 'FULL_NAME_TYPED',
'SIGNATURE_ACCEPTED',
'SIGNATURE_COMPLETED', 'SEAL_COMPUTED', 'TIMESTAMP_REQUESTED',
'CERTIFICATE_GENERATED',
'PROCESSING_ERROR'
);
CREATE TYPE electronic_signature_event_source AS ENUM ('CLIENT', 'SERVER');
-- Electronic signatures table
CREATE TABLE electronic_signatures (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
status electronic_signature_status NOT NULL DEFAULT 'PENDING',
document_type electronic_signature_document_type NOT NULL,
file_id TEXT NOT NULL REFERENCES files(id),
signer_email CITEXT NOT NULL,
-- Set at creation time (PENDING):
consent_text TEXT NOT NULL,
-- Set when status transitions to ACCEPTED (signer submits):
signer_full_name TEXT,
signer_ip_address TEXT,
signer_user_agent TEXT,
-- Set when status transitions to COMPLETED (worker finishes):
file_hash TEXT,
seal TEXT,
seal_version INT NOT NULL DEFAULT 1,
tsa_token BYTEA,
signed_at TIMESTAMPTZ,
-- Set by certificate worker after COMPLETED:
certificate_file_id TEXT REFERENCES files(id),
certificate_processing_started_at TIMESTAMPTZ,
-- Async processing state:
attempt_count INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 10,
last_attempted_at TIMESTAMPTZ,
last_error TEXT,
processing_started_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
-- file_id in the constraint allows re-signing when the org replaces the NDA file.
UNIQUE(organization_id, signer_email, document_type, file_id)
);
-- Electronic signature events table
CREATE TABLE electronic_signature_events (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
electronic_signature_id TEXT NOT NULL REFERENCES electronic_signatures(id) ON DELETE CASCADE,
event_type electronic_signature_event_type NOT NULL,
event_source electronic_signature_event_source NOT NULL,
actor_email CITEXT NOT NULL,
actor_ip_address TEXT NOT NULL,
actor_user_agent TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_esig_events_signature
ON electronic_signature_events (electronic_signature_id, occurred_at ASC);
-- Email attachments table (generic, not esign-specific)
CREATE TABLE email_attachments (
id TEXT PRIMARY KEY,
email_id TEXT NOT NULL REFERENCES emails(id) ON DELETE CASCADE,
file_id TEXT NOT NULL REFERENCES files(id),
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_email_attachments_email_id ON email_attachments (email_id);