Files
probo/pkg/coredata/document_version_signature.go
Cursor Agent 366c1f93a1 Fix ambiguous tenant filter in signature load query
LoadRequestedByDocumentID joined document_versions while applying
the unqualified tenant_id scope fragment, which PostgreSQL rejects.
Use the same IN-subquery shape as DeleteRequestedByDocumentID.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
2026-07-20 12:13:41 +02:00

1081 lines
28 KiB
Go

// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam/policy"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
)
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"`
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
DocumentVersionSignatureWithPeople struct {
DocumentVersionSignature
SignedByFullName string `db:"signed_by_full_name"`
}
DocumentVersionSignaturesWithPeople []*DocumentVersionSignatureWithPeople
)
func (pvs DocumentVersionSignature) CursorKey(orderBy DocumentVersionSignatureOrderField) page.CursorKey {
switch orderBy {
case DocumentVersionSignatureOrderFieldCreatedAt:
return page.NewCursorKey(pvs.ID, pvs.CreatedAt)
case DocumentVersionSignatureOrderFieldSignedAt:
return page.NewCursorKey(pvs.ID, pvs.SignedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
func (dvs *DocumentVersionSignature) AuthorizationAttributes(
ctx context.Context,
conn pg.Querier,
resourceIDs []gid.GID,
) (policy.AttributesByID, error) {
q := `SELECT id, organization_id FROM document_version_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 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 authorization attributes: %w", err)
}
attrsByID[id] = policy.Attributes{
"organization_id": organizationID.String(),
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err)
}
return attrsByID, nil
}
func (pvs *DocumentVersionSignature) LoadByDocumentVersionIDAndSignatory(
ctx context.Context,
conn pg.Querier,
scope Scoper,
documentVersionID gid.GID,
signatory gid.GID,
) error {
q := `
SELECT
id,
organization_id,
document_version_id,
state,
signed_by_profile_id,
signed_at,
requested_at,
electronic_signature_id,
created_at,
updated_at
FROM
document_version_signatures
WHERE
%s
AND document_version_id = @document_version_id
AND signed_by_profile_id = @signatory
LIMIT 1
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"document_version_id": documentVersionID, "signatory": signatory}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query document version signature: %w", err)
}
documentVersionSignature, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[DocumentVersionSignature])
if err != nil {
return fmt.Errorf("cannot collect document version signature: %w", err)
}
*pvs = documentVersionSignature
return nil
}
// LoadByDocumentMajorAndSignatory loads the signatory's existing signature for
// the whole major that owns documentVersionID, scanning across every minor
// version of that major. A signed signature is preferred over a still pending
// one, then the most recent. It returns ErrResourceNotFound when the signatory
// has no signature anywhere in the major.
func (pvs *DocumentVersionSignature) LoadByDocumentMajorAndSignatory(
ctx context.Context,
conn pg.Querier,
scope Scoper,
documentVersionID gid.GID,
signatory gid.GID,
) error {
q := `
WITH source_version AS (
SELECT document_id, major FROM document_versions WHERE id = @document_version_id
),
major_versions AS (
SELECT dv.id FROM document_versions dv
INNER JOIN source_version sv ON dv.document_id = sv.document_id AND dv.major = sv.major
),
major_signatures AS (
SELECT
dvs.id,
dvs.organization_id,
dvs.tenant_id,
dvs.document_version_id,
dvs.state,
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
INNER JOIN major_versions mv ON dvs.document_version_id = mv.id
WHERE dvs.signed_by_profile_id = @signatory
)
SELECT
id,
organization_id,
document_version_id,
state,
signed_by_profile_id,
signed_at,
requested_at,
electronic_signature_id,
created_at,
updated_at
FROM
major_signatures
WHERE
%s
ORDER BY
CASE state WHEN 'SIGNED' THEN 0 ELSE 1 END,
created_at DESC
LIMIT 1
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"document_version_id": documentVersionID, "signatory": signatory}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query document version signature by major: %w", err)
}
documentVersionSignature, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[DocumentVersionSignature])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect document version signature by major: %w", err)
}
*pvs = documentVersionSignature
return nil
}
func (pvs *DocumentVersionSignature) LoadByID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
signatureID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
document_version_id,
state,
signed_by_profile_id,
signed_at,
requested_at,
electronic_signature_id,
created_at,
updated_at
FROM
document_version_signatures
WHERE
id = @document_version_signature_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"document_version_signature_id": signatureID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query document version signature: %w", err)
}
documentVersionSignature, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[DocumentVersionSignature])
if err != nil {
return fmt.Errorf("cannot collect document version signature: %w", err)
}
*pvs = documentVersionSignature
return nil
}
func (pvs DocumentVersionSignature) Insert(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
INSERT INTO document_version_signatures (
id,
tenant_id,
organization_id,
document_version_id,
state,
signed_by_profile_id,
signed_at,
requested_at,
electronic_signature_id,
created_at,
updated_at,
notification_count
) VALUES (
@id,
@tenant_id,
@organization_id,
@document_version_id,
@state,
@signed_by_profile_id,
@signed_at,
@requested_at,
@electronic_signature_id,
@created_at,
@updated_at,
@notification_count
)
`
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,
"electronic_signature_id": pvs.ElectronicSignatureID,
"created_at": pvs.CreatedAt,
"updated_at": pvs.UpdatedAt,
"notification_count": 0,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
if pgErr.Code == "23505" && pgErr.ConstraintName == "policy_version_signatures_policy_version_id_signed_by_key" {
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert document version signature: %w", err)
}
return nil
}
func (pvss *DocumentVersionSignatures) LoadByDocumentVersionID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
documentVersionID gid.GID,
cursor *page.Cursor[DocumentVersionSignatureOrderField],
filter *DocumentVersionSignatureFilter,
) error {
q := `
WITH source_version AS (
SELECT document_id, major FROM document_versions WHERE id = @document_version_id
),
major_versions AS (
SELECT dv.id FROM document_versions dv
INNER JOIN source_version sv ON dv.document_id = sv.document_id AND dv.major = sv.major
)
SELECT
document_version_signatures.id,
document_version_signatures.organization_id,
document_version_signatures.document_version_id,
document_version_signatures.state,
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
document_version_signatures
INNER JOIN major_versions mv ON document_version_signatures.document_version_id = mv.id
WHERE
%s
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"document_version_id": documentVersionID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
maps.Copy(args, filter.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query document version signatures: %w", err)
}
documentVersionSignatures, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersionSignature])
if err != nil {
return fmt.Errorf("cannot collect document version signatures: %w", err)
}
*pvss = documentVersionSignatures
return nil
}
// documentNotificationMaxCount caps how many emails a signature/approval
// request gets: the first notice plus three reminders. A request is due for its
// next email once it is past its scheduled offset — the first email after the
// debounce delay, then reminders at 1x, 2x and 3x the reminder interval after
// the previous email — and stops once it reaches this cap.
const documentNotificationMaxCount = 4
func remainingNotificationIDs(all []gid.GID, claimed []gid.GID) []gid.GID {
claimedSet := make(map[gid.GID]struct{}, len(claimed))
for _, id := range claimed {
claimedSet[id] = struct{}{}
}
rest := make([]gid.GID, 0, len(all))
for _, id := range all {
if _, ok := claimedSet[id]; ok {
continue
}
rest = append(rest, id)
}
return rest
}
// LoadNextDueGroupForNotification loads every still-REQUESTED signature for the
// next (organization, signatory) group that has at least one request due for a
// notification, so the whole group can be emailed together. A request is due
// once it is past its scheduled offset (see documentNotificationMaxCount) and
// has not reached the cap. The receiver is left empty when no group is due.
func (pvss *DocumentVersionSignatures) LoadNextDueGroupForNotification(
ctx context.Context,
conn pg.Querier,
now time.Time,
debounceBefore time.Time,
reminderInterval time.Duration,
) error {
q := `
WITH next_group AS (
SELECT
organization_id,
signed_by_profile_id
FROM
document_version_signatures
WHERE
state = @state
AND notification_count < @max_notifications
AND (
(notification_count = 0 AND requested_at < @debounce_before)
OR (notification_count > 0 AND last_notified_at < @now::timestamptz - make_interval(secs => @reminder_interval_seconds * notification_count))
)
AND EXISTS (
SELECT 1
FROM document_versions dv
JOIN documents doc ON doc.id = dv.document_id
WHERE dv.id = document_version_signatures.document_version_id
AND doc.deleted_at IS NULL
AND doc.archived_at IS NULL
)
AND EXISTS (
SELECT 1
FROM iam_membership_profiles p
WHERE p.id = document_version_signatures.signed_by_profile_id
AND p.state = @recipient_state::membership_state
AND (p.contract_end_date IS NULL OR p.contract_end_date >= @now::date)
)
GROUP BY
organization_id,
signed_by_profile_id
ORDER BY
organization_id,
signed_by_profile_id
LIMIT 1
)
SELECT
s.id,
s.organization_id,
s.document_version_id,
s.state,
s.signed_by_profile_id,
s.signed_at,
s.requested_at,
s.electronic_signature_id,
s.created_at,
s.updated_at
FROM
document_version_signatures s
INNER JOIN next_group g
ON g.organization_id = s.organization_id
AND g.signed_by_profile_id = s.signed_by_profile_id
WHERE
s.state = @state
AND s.notification_count < @max_notifications
AND EXISTS (
SELECT 1
FROM document_versions dv
JOIN documents doc ON doc.id = dv.document_id
WHERE dv.id = s.document_version_id
AND doc.deleted_at IS NULL
AND doc.archived_at IS NULL
)
ORDER BY
s.document_version_id
`
args := pgx.StrictNamedArgs{
"state": DocumentVersionSignatureStateRequested,
"recipient_state": ProfileStateActive,
"max_notifications": documentNotificationMaxCount,
"now": now,
"debounce_before": debounceBefore,
"reminder_interval_seconds": reminderInterval.Seconds(),
}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query due signatures for notification: %w", err)
}
signatures, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersionSignature])
if err != nil {
return fmt.Errorf("cannot collect due signatures for notification: %w", err)
}
*pvss = signatures
return nil
}
// ClaimForNotification claims the receiver's signatures that are individually
// due for a notification and returns their ids. The conditional update doubles
// as the claim, so concurrent workers never email the same group twice. Callers
// advance the rest of the group with BumpRemainingForNotification in the same
// transaction.
func (pvss DocumentVersionSignatures) ClaimForNotification(
ctx context.Context,
conn pg.Tx,
now time.Time,
debounceBefore time.Time,
reminderInterval time.Duration,
) ([]gid.GID, error) {
ids := make([]gid.GID, len(pvss))
for i, signature := range pvss {
ids[i] = signature.ID
}
q := `
UPDATE document_version_signatures
SET
notification_count = notification_count + 1,
last_notified_at = @now
WHERE
id = ANY(@ids::text[])
AND state = @state
AND notification_count < @max_notifications
AND (
(notification_count = 0 AND requested_at < @debounce_before)
OR (notification_count > 0 AND last_notified_at < @now::timestamptz - make_interval(secs => @reminder_interval_seconds * notification_count))
)
RETURNING id
`
rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{
"ids": ids,
"state": DocumentVersionSignatureStateRequested,
"max_notifications": documentNotificationMaxCount,
"now": now,
"debounce_before": debounceBefore,
"reminder_interval_seconds": reminderInterval.Seconds(),
})
if err != nil {
return nil, fmt.Errorf("cannot claim due signatures for notification: %w", err)
}
claimed, err := pgx.CollectRows(rows, pgx.RowTo[gid.GID])
if err != nil {
return nil, fmt.Errorf("cannot collect claimed signatures for notification: %w", err)
}
return claimed, nil
}
// BumpRemainingForNotification advances the notification schedule for the
// still-pending signatures in the group that were not individually claimed, so
// the whole emailed list moves forward together. It must run in the same
// transaction as ClaimForNotification.
func (pvss DocumentVersionSignatures) BumpRemainingForNotification(
ctx context.Context,
conn pg.Tx,
claimed []gid.GID,
now time.Time,
) ([]gid.GID, error) {
ids := make([]gid.GID, len(pvss))
for i, signature := range pvss {
ids[i] = signature.ID
}
rest := remainingNotificationIDs(ids, claimed)
if len(rest) == 0 {
return nil, nil
}
q := `
UPDATE document_version_signatures
SET
notification_count = notification_count + 1,
last_notified_at = @now
WHERE
id = ANY(@ids::text[])
AND state = @state
AND notification_count < @max_notifications
RETURNING id
`
rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{
"ids": rest,
"state": DocumentVersionSignatureStateRequested,
"max_notifications": documentNotificationMaxCount,
"now": now,
})
if err != nil {
return nil, fmt.Errorf("cannot bump remaining signatures for notification: %w", err)
}
bumped, err := pgx.CollectRows(rows, pgx.RowTo[gid.GID])
if err != nil {
return nil, fmt.Errorf("cannot collect bumped signatures for notification: %w", err)
}
return bumped, nil
}
func (pvs *DocumentVersionSignature) Update(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
UPDATE document_version_signatures
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
AND id = @id
`
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,
"electronic_signature_id": pvs.ElectronicSignatureID,
"updated_at": pvs.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update document version signature: %w", err)
}
return nil
}
func (pvs *DocumentVersionSignature) Delete(
ctx context.Context,
conn pg.Tx,
scope Scoper,
documentVersionSignatureID gid.GID,
) error {
q := `
DELETE FROM document_version_signatures
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": documentVersionSignatureID}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete document version signature: %w", err)
}
return nil
}
func (pvss *DocumentVersionSignatures) DeleteRequestedBySignatory(
ctx context.Context,
conn pg.Tx,
scope Scoper,
signatoryID gid.GID,
) error {
q := `
DELETE FROM document_version_signatures
WHERE
%s
AND signed_by_profile_id = @signatory_id
AND state = @state
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"signatory_id": signatoryID,
"state": DocumentVersionSignatureStateRequested,
}
maps.Copy(args, scope.SQLArguments())
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot delete requested document version signatures: %w", err)
}
return nil
}
func (pvss *DocumentVersionSignatures) MoveRequestedToVersionWithinMajor(
ctx context.Context,
conn pg.Tx,
scope Scoper,
targetVersionID gid.GID,
) error {
q := `
UPDATE document_version_signatures
SET
document_version_id = @target_version_id,
updated_at = @now
WHERE
%s
AND state = @state
AND document_version_id <> @target_version_id
AND document_version_id IN (
SELECT dv.id
FROM document_versions dv
INNER JOIN document_versions target
ON target.document_id = dv.document_id
AND target.major = dv.major
WHERE target.id = @target_version_id
)
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"target_version_id": targetVersionID,
"state": DocumentVersionSignatureStateRequested,
"now": time.Now(),
}
maps.Copy(args, scope.SQLArguments())
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot move requested document version signatures to the newly published version: %w", err)
}
return nil
}
func (pvss *DocumentVersionSignatures) DeleteRequestedByDocumentIDBelowMajor(
ctx context.Context,
conn pg.Tx,
scope Scoper,
documentID gid.GID,
major int,
) error {
q := `
DELETE FROM document_version_signatures
WHERE
%s
AND state = @state
AND document_version_id IN (
SELECT id
FROM document_versions
WHERE document_id = @document_id
AND major < @major
)
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"document_id": documentID,
"major": major,
"state": DocumentVersionSignatureStateRequested,
}
maps.Copy(args, scope.SQLArguments())
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot delete requested document version signatures from previous major versions: %w", err)
}
return nil
}
func (pvss *DocumentVersionSignatures) LoadRequestedByDocumentID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
documentID gid.GID,
) error {
q := `
SELECT
document_version_signatures.id,
document_version_signatures.organization_id,
document_version_signatures.document_version_id,
document_version_signatures.state,
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
document_version_signatures
WHERE
%s
AND state = @state
AND document_version_id IN (
SELECT id
FROM document_versions
WHERE document_id = @document_id
)
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"document_id": documentID,
"state": DocumentVersionSignatureStateRequested,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query requested document version signatures: %w", err)
}
documentVersionSignatures, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersionSignature])
if err != nil {
return fmt.Errorf("cannot collect requested document version signatures: %w", err)
}
*pvss = documentVersionSignatures
return nil
}
func (pvss *DocumentVersionSignatures) DeleteRequestedByDocumentID(
ctx context.Context,
conn pg.Tx,
scope Scoper,
documentID gid.GID,
) error {
q := `
DELETE FROM document_version_signatures
WHERE
%s
AND state = @state
AND document_version_id IN (
SELECT id
FROM document_versions
WHERE document_id = @document_id
)
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"document_id": documentID,
"state": DocumentVersionSignatureStateRequested,
}
maps.Copy(args, scope.SQLArguments())
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot delete requested document version signatures: %w", err)
}
return nil
}
func (pvss *DocumentVersionSignaturesWithPeople) LoadByDocumentVersionIDWithPeople(
ctx context.Context,
conn pg.Querier,
scope Scoper,
documentVersionID gid.GID,
limit int,
) error {
q := `
WITH source_version AS (
SELECT document_id, major FROM document_versions WHERE id = @document_version_id
),
major_versions AS (
SELECT dv.id FROM document_versions dv
INNER JOIN source_version sv ON dv.document_id = sv.document_id AND dv.major = sv.major
),
signatures_with_people AS (
SELECT
dvs.id,
dvs.organization_id,
dvs.tenant_id,
dvs.document_version_id,
dvs.state,
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
FROM document_version_signatures dvs
INNER JOIN major_versions mv ON dvs.document_version_id = mv.id
INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id
WHERE
dvs.state = 'SIGNED'
OR (
p.state = 'ACTIVE'
AND (p.contract_end_date IS NULL OR p.contract_end_date >= CURRENT_DATE)
AND EXISTS (
SELECT 1
FROM iam_memberships m
WHERE m.identity_id = p.identity_id
AND m.organization_id = p.organization_id
)
)
)
SELECT
id,
organization_id,
document_version_id,
state,
signed_by_profile_id,
signed_at,
requested_at,
electronic_signature_id,
created_at,
updated_at,
signed_by_full_name
FROM
signatures_with_people
WHERE
%s
ORDER BY
signed_by_full_name ASC
LIMIT @limit
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"document_version_id": documentVersionID,
"limit": limit,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query document version signatures with people: %w", err)
}
signatures, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersionSignatureWithPeople])
if err != nil {
return fmt.Errorf("cannot collect document version signatures with people: %w", err)
}
*pvss = signatures
return nil
}
func (pvs *DocumentVersionSignature) IsSignedByUserEmail(
ctx context.Context,
conn pg.Querier,
scope Scoper,
documentVersionID gid.GID,
userEmail mail.Addr,
) (bool, error) {
q := `
WITH source_version AS (
SELECT document_id, major FROM document_versions WHERE id = @document_version_id
),
major_versions AS (
SELECT dv.id FROM document_versions dv
INNER JOIN source_version sv ON dv.document_id = sv.document_id AND dv.major = sv.major
),
signed_emails AS (
SELECT dvs.id, dvs.tenant_id
FROM document_version_signatures dvs
INNER JOIN major_versions mv ON dvs.document_version_id = mv.id
INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id
INNER JOIN identities i ON p.identity_id = i.id
WHERE i.email_address = @user_email::CITEXT
AND dvs.state = 'SIGNED'
)
SELECT EXISTS (
SELECT 1
FROM signed_emails
WHERE %s
) AS signed
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"document_version_id": documentVersionID,
"user_email": userEmail,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return false, fmt.Errorf("cannot query document version signature: %w", err)
}
signed, err := pgx.CollectOneRow(rows, pgx.RowTo[bool])
if err != nil {
return false, fmt.Errorf("cannot collect signed status: %w", err)
}
return signed, nil
}
func (dvs *DocumentVersionSignatures) CountByDocumentVersionID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
documentVersionID gid.GID,
filter *DocumentVersionSignatureFilter,
) (int, error) {
q := `
WITH source_version AS (
SELECT document_id, major FROM document_versions WHERE id = @document_version_id
),
major_versions AS (
SELECT dv.id FROM document_versions dv
INNER JOIN source_version sv ON dv.document_id = sv.document_id AND dv.major = sv.major
)
SELECT
COUNT(document_version_signatures.id)
FROM
document_version_signatures
INNER JOIN major_versions mv ON document_version_signatures.document_version_id = mv.id
WHERE
%s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.NamedArgs{"document_version_id": documentVersionID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot scan count: %w", err)
}
return count, nil
}