// Copyright (c) 2026 Probo Inc . // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // 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" "go.probo.inc/probo/pkg/iam/policy" ) 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"` DocumentName *string `db:"document_name"` FileID gid.GID `db:"file_id"` SignerEmail string `db:"signer_email"` ConsentText string `db:"consent_text"` EmailSubject string `db:"email_subject"` 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) AuthorizationAttributes( ctx context.Context, conn pg.Querier, resourceIDs []gid.GID, ) (policy.AttributesByID, error) { q := `SELECT id, organization_id FROM electronic_signatures WHERE id = ANY(@resource_ids::text[])` args := pgx.StrictNamedArgs{ "resource_ids": resourceIDs, } rows, err := conn.Query(ctx, q, args) if err != nil { return nil, fmt.Errorf("cannot query electronic signature authorization attributes: %w", err) } defer rows.Close() attrsByID := make(policy.AttributesByID) for rows.Next() { var id, organizationID gid.GID if err := rows.Scan(&id, &organizationID); err != nil { return nil, fmt.Errorf("cannot scan electronic signature authorization attributes: %w", err) } attrsByID[id] = policy.Attributes{ "organization_id": organizationID.String(), } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("cannot iterate electronic signature authorization attributes: %w", err) } return attrsByID, nil } 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.Tx, scope Scoper, ) error { q := ` INSERT INTO electronic_signatures ( id, tenant_id, organization_id, status, document_type, document_name, file_id, signer_email, consent_text, email_subject, seal_version, attempt_count, max_attempts, created_at, updated_at ) VALUES ( @id, @tenant_id, @organization_id, @status, @document_type, @document_name, @file_id, @signer_email, @consent_text, @email_subject, @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, "document_name": es.DocumentName, "file_id": es.FileID, "signer_email": es.SignerEmail, "consent_text": es.ConsentText, "email_subject": es.EmailSubject, "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.Tx, 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.Querier, scope Scoper, id gid.GID, ) error { q := ` SELECT id, tenant_id, organization_id, status, document_type, document_name, file_id, signer_email, consent_text, email_subject, 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) LoadNextAcceptedForUpdateSkipLocked( ctx context.Context, conn pg.Tx, ) error { q := ` SELECT id, tenant_id, organization_id, status, document_type, document_name, file_id, signer_email, consent_text, email_subject, 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.Tx, ) error { q := ` SELECT id, tenant_id, organization_id, status, document_type, document_name, file_id, signer_email, consent_text, email_subject, 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 AND attempt_count < max_attempts 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.Querier, 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) 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.SHA256HexString(input), nil } func ResetStaleCertificateProcessing( ctx context.Context, conn pg.Querier, 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 AND attempt_count < max_attempts ` _, err := conn.Exec(ctx, q, staleAfter) if err != nil { return fmt.Errorf("cannot reset stale certificate processing: %w", err) } return nil }