Batch signature and approval notifications via debounced worker

Replace the immediate per-document approval email and the manual
"send signing notifications" action with a single debounced worker that
batches pending requests per recipient and organization.

The worker (go.gearno.de/kit/worker) polls on an interval (default 5m)
and claims one (organization, recipient) group at a time, sending one
consolidated signing email and/or one approval email per recipient/org
that lists every document awaiting their signature or approval. The
claim is a conditional UPDATE that doubles as concurrency-safe dedup, so
several workers never email the same group twice.

Each request is notified once it has been pending past the debounce
delay (default 15m), then reminded at 1x, 2x and 3x the reminder
interval (default 1 day) after the previous email, after which it stops.
New last_notified_at and notification_count columns on signatures and
approval decisions drive the debounce, the widening reminder cadence and
the four-email cap.

Email copy lists each document with its title, type and a deep link to
the employee page. Removed the inline approval-on-publish email, the
SendSigningNotifications service method/mutation/MCP tool, its IAM action,
and the related console UI and n8n operation.

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
Sacha Al Himdani
2026-06-15 09:13:31 +02:00
committed by Sacha Al Himdani
parent c9b74bac4a
commit f462b124e6
33 changed files with 1281 additions and 524 deletions

View File

@@ -183,16 +183,15 @@ const (
ActionEvidenceDelete = "core:evidence:delete"
// Document actions
ActionDocumentGet = "core:document:get"
ActionDocumentList = "core:document:list"
ActionDocumentCreate = "core:document:create"
ActionDocumentUpdate = "core:document:update"
ActionDocumentDelete = "core:document:delete"
ActionDocumentChangelogGenerate = "core:document:generate-changelog"
ActionDocumentArchive = "core:document:archive"
ActionDocumentUnarchive = "core:document:unarchive"
ActionDocumentDeleteDraft = "core:document:delete-draft"
ActionDocumentSendSigningNotifications = "core:document:send-signing-notifications"
ActionDocumentGet = "core:document:get"
ActionDocumentList = "core:document:list"
ActionDocumentCreate = "core:document:create"
ActionDocumentUpdate = "core:document:update"
ActionDocumentDelete = "core:document:delete"
ActionDocumentChangelogGenerate = "core:document:generate-changelog"
ActionDocumentArchive = "core:document:archive"
ActionDocumentUnarchive = "core:document:unarchive"
ActionDocumentDeleteDraft = "core:document:delete-draft"
// DocumentVersion actions
ActionDocumentVersionGet = "core:document-version:get"

View File

@@ -19,21 +19,16 @@ import (
"context"
"errors"
"fmt"
"net/url"
"time"
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/html2pdf"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
"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."
@@ -82,16 +77,6 @@ func (s *DocumentApprovalService) RequestApprovalInTx(
approverIDs []gid.GID,
changelog *string,
) (*coredata.DocumentVersionApprovalQuorum, error) {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, scope, document.OrganizationID); err != nil {
return nil, fmt.Errorf("cannot load organization: %w", err)
}
approverProfiles := &coredata.MembershipProfiles{}
if err := approverProfiles.LoadByIDs(ctx, tx, scope, approverIDs); err != nil {
return nil, fmt.Errorf("cannot load approver profiles: %w", err)
}
now := time.Now()
documentVersion.Status = coredata.DocumentVersionStatusPendingApproval
@@ -129,9 +114,9 @@ func (s *DocumentApprovalService) RequestApprovalInTx(
return nil, fmt.Errorf("cannot create approval decisions: %w", err)
}
if err := s.sendApprovalEmails(ctx, scope, tx, *approverProfiles, document, organization, documentVersion.ID); err != nil {
return nil, fmt.Errorf("cannot send approval emails: %w", err)
}
// Approval notifications are sent asynchronously and debounced by the
// document notification worker, which batches all pending approvals per
// recipient into a single email.
return quorum, nil
}
@@ -809,88 +794,6 @@ func (s *DocumentApprovalService) createDecisions(
return nil
}
func (s *DocumentApprovalService) sendApprovalEmails(
ctx context.Context, scope coredata.Scoper,
tx pg.Tx,
profiles coredata.MembershipProfiles,
document *coredata.Document,
organization *coredata.Organization,
documentVersionID gid.GID,
) error {
now := time.Now()
approvalURLPath := "/organizations/" + document.OrganizationID.String() + "/employee/approvals/" + document.ID.String()
approvalEmails := make(coredata.Emails, 0, len(profiles))
for _, profile := range profiles {
emailPresenter := emails.NewPresenter(s.svc.baseURL, profile.FullName)
var (
emailLinkURLPath = approvalURLPath
query = make(url.Values)
)
if profile.State != coredata.ProfileStateActive {
if profile.Source != coredata.ProfileSourceSCIM {
invitation := &coredata.Invitation{
ID: gid.New(document.OrganizationID.TenantID(), coredata.InvitationEntityType),
OrganizationID: document.OrganizationID,
UserID: profile.ID,
Status: coredata.InvitationStatusPending,
ExpiresAt: now.Add(s.invitationTokenValidity),
CreatedAt: now,
}
if err := invitation.Insert(ctx, tx, coredata.NewScopeFromObjectID(document.OrganizationID)); err != nil {
return fmt.Errorf("cannot insert invitation: %w", err)
}
invitationToken, err := statelesstoken.NewToken(
s.tokenSecret,
iam.TokenTypeOrganizationInvitation,
s.invitationTokenValidity,
iam.InvitationTokenData{InvitationID: invitation.ID},
)
if err != nil {
return fmt.Errorf("cannot generate invitation token: %w", err)
}
emailLinkURLPath = "/auth/activate-account"
continueURL := baseurl.MustParse(s.svc.baseURL).AppendPath(approvalURLPath).MustString()
query.Add("token", invitationToken)
query.Add("continue", continueURL)
}
}
subject, textBody, htmlBody, err := emailPresenter.RenderDocumentApproval(
ctx,
emailLinkURLPath,
query,
organization.Name,
document.Title,
)
if err != nil {
return fmt.Errorf("cannot render approval request email: %w", err)
}
approvalEmails = append(approvalEmails, coredata.NewEmail(
profile.FullName,
profile.EmailAddress,
subject,
textBody,
htmlBody,
&coredata.EmailOptions{
SenderName: new(organization.Name),
},
))
}
if err := approvalEmails.BulkInsert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert approval emails: %w", err)
}
return nil
}
func (s *DocumentApprovalService) generateApprovalPDF(
ctx context.Context, scope coredata.Scoper,
documentVersionID gid.GID,

View File

@@ -0,0 +1,549 @@
// 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.
package probo
import (
"context"
"fmt"
"net/url"
"sort"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.gearno.de/kit/worker"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/statelesstoken"
)
type (
notificationKind string
claimStatus string
// documentNotificationTask is one consolidated email to send: every document
// awaiting a given recipient's signature or approval in one organization. The
// schedule has already been advanced (the requests are claimed) by the time a
// task is handed to Process.
documentNotificationTask struct {
kind notificationKind
organizationID gid.GID
recipientID gid.GID
versionIDs []gid.GID
}
DocumentNotificationWorkerConfig struct {
DebounceDelay time.Duration
ReminderInterval time.Duration
}
documentNotificationHandler struct {
service *Service
logger *log.Logger
debounceDelay time.Duration
reminderInterval time.Duration
}
)
const (
notificationKindSigning notificationKind = "signing"
notificationKindApproval notificationKind = "approval"
claimStatusNone claimStatus = "none"
claimStatusClaimed claimStatus = "claimed"
claimStatusRaced claimStatus = "raced"
)
// NewDocumentNotificationWorker builds the worker that emails recipients, one
// consolidated message per organization, about the documents awaiting their
// signature or approval. Each claim advances the request's reminder schedule, so
// the conditional update doubles as the claim and concurrent workers never email
// the same group twice.
func NewDocumentNotificationWorker(
service *Service,
logger *log.Logger,
cfg DocumentNotificationWorkerConfig,
opts ...worker.Option,
) *worker.Worker[documentNotificationTask] {
h := &documentNotificationHandler{
service: service,
logger: logger,
debounceDelay: cfg.DebounceDelay,
reminderInterval: cfg.ReminderInterval,
}
return worker.New(
"document-notification-worker",
h,
logger,
opts...,
)
}
func (h *documentNotificationHandler) Claim(ctx context.Context) (documentNotificationTask, error) {
now := time.Now()
debounceBefore := now.Add(-h.debounceDelay)
task, status, err := h.claimNextSigningGroup(ctx, now, debounceBefore)
if err != nil {
return documentNotificationTask{}, err
}
for status == claimStatusRaced {
task, status, err = h.claimNextSigningGroup(ctx, now, debounceBefore)
if err != nil {
return documentNotificationTask{}, err
}
}
if status == claimStatusClaimed {
return task, nil
}
task, status, err = h.claimNextApprovalGroup(ctx, now, debounceBefore)
if err != nil {
return documentNotificationTask{}, err
}
for status == claimStatusRaced {
task, status, err = h.claimNextApprovalGroup(ctx, now, debounceBefore)
if err != nil {
return documentNotificationTask{}, err
}
}
if status == claimStatusClaimed {
return task, nil
}
return documentNotificationTask{}, worker.ErrNoTask
}
func (h *documentNotificationHandler) Process(ctx context.Context, task documentNotificationTask) error {
if len(task.versionIDs) == 0 {
return nil
}
scope := coredata.NewScopeFromObjectID(task.organizationID)
if err := h.service.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
return h.service.Documents.sendNotification(ctx, tx, scope, task.kind, task.organizationID, task.recipientID, task.versionIDs)
},
); err != nil {
h.logger.ErrorCtx(
ctx,
"document notification worker failure",
log.Error(err),
log.String("organization_id", task.organizationID.String()),
)
return err
}
return nil
}
// claimNextSigningGroup claims the next (organization, signatory) group whose
// signatures are due and returns the documents to list. claimStatusRaced means
// another worker claimed the candidate group first and the caller should retry.
func (h *documentNotificationHandler) claimNextSigningGroup(
ctx context.Context,
now time.Time,
debounceBefore time.Time,
) (documentNotificationTask, claimStatus, error) {
var (
signatures coredata.DocumentVersionSignatures
claimed []gid.GID
)
if err := h.service.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := signatures.LoadNextDueGroupForNotification(ctx, tx, now, debounceBefore, h.reminderInterval); err != nil {
return fmt.Errorf("cannot load next due signature group: %w", err)
}
if len(signatures) == 0 {
return nil
}
dueClaimed, err := signatures.ClaimForNotification(ctx, tx, now, debounceBefore, h.reminderInterval)
if err != nil {
return fmt.Errorf("cannot claim signatures for notification: %w", err)
}
if len(dueClaimed) == 0 {
return nil
}
bumped, err := signatures.BumpRemainingForNotification(ctx, tx, dueClaimed, now)
if err != nil {
return fmt.Errorf("cannot bump remaining signatures for notification: %w", err)
}
claimed = append(dueClaimed, bumped...)
return nil
},
); err != nil {
return documentNotificationTask{}, claimStatusNone, err
}
if len(signatures) == 0 {
return documentNotificationTask{}, claimStatusNone, nil
}
if len(claimed) == 0 {
return documentNotificationTask{}, claimStatusRaced, nil
}
claimedSet := make(map[gid.GID]struct{}, len(claimed))
for _, id := range claimed {
claimedSet[id] = struct{}{}
}
versionIDs := make([]gid.GID, 0, len(signatures))
for _, signature := range signatures {
if _, ok := claimedSet[signature.ID]; ok {
versionIDs = append(versionIDs, signature.DocumentVersionID)
}
}
group := signatures[0]
return documentNotificationTask{
kind: notificationKindSigning,
organizationID: group.OrganizationID,
recipientID: group.SignedBy,
versionIDs: versionIDs,
}, claimStatusClaimed, nil
}
// claimNextApprovalGroup claims the next (organization, approver) group whose
// decisions are due and resolves the documents to list via their quorums.
// claimStatusRaced means another worker won the candidate group first.
func (h *documentNotificationHandler) claimNextApprovalGroup(
ctx context.Context,
now time.Time,
debounceBefore time.Time,
) (documentNotificationTask, claimStatus, error) {
var (
decisions coredata.DocumentVersionApprovalDecisions
claimed []gid.GID
versionIDs []gid.GID
organizationID gid.GID
recipientID gid.GID
)
if err := h.service.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := decisions.LoadNextDueGroupForNotification(ctx, tx, now, debounceBefore, h.reminderInterval); err != nil {
return fmt.Errorf("cannot load next due approval group: %w", err)
}
if len(decisions) == 0 {
return nil
}
dueClaimed, err := decisions.ClaimForNotification(ctx, tx, now, debounceBefore, h.reminderInterval)
if err != nil {
return fmt.Errorf("cannot claim approval decisions for notification: %w", err)
}
if len(dueClaimed) == 0 {
return nil
}
bumped, err := decisions.BumpRemainingForNotification(ctx, tx, dueClaimed, now)
if err != nil {
return fmt.Errorf("cannot bump remaining approval decisions for notification: %w", err)
}
claimed = append(dueClaimed, bumped...)
claimedSet := make(map[gid.GID]struct{}, len(claimed))
for _, id := range claimed {
claimedSet[id] = struct{}{}
}
quorumIDs := make([]gid.GID, 0, len(claimed))
for _, decision := range decisions {
if _, ok := claimedSet[decision.ID]; ok {
quorumIDs = append(quorumIDs, decision.QuorumID)
}
}
scope := coredata.NewScopeFromObjectID(decisions[0].OrganizationID)
var quorums coredata.DocumentVersionApprovalQuorums
if err := quorums.LoadByIDs(ctx, tx, scope, quorumIDs); err != nil {
return fmt.Errorf("cannot load approval quorums: %w", err)
}
for _, quorum := range quorums {
versionIDs = append(versionIDs, quorum.VersionID)
}
organizationID = decisions[0].OrganizationID
recipientID = decisions[0].ApproverID
return nil
},
); err != nil {
return documentNotificationTask{}, claimStatusNone, err
}
if len(decisions) == 0 {
return documentNotificationTask{}, claimStatusNone, nil
}
if len(claimed) == 0 {
return documentNotificationTask{}, claimStatusRaced, nil
}
return documentNotificationTask{
kind: notificationKindApproval,
organizationID: organizationID,
recipientID: recipientID,
versionIDs: versionIDs,
}, claimStatusClaimed, nil
}
func (s *DocumentService) sendNotification(
ctx context.Context,
tx pg.Tx,
scope coredata.Scoper,
kind notificationKind,
organizationID gid.GID,
recipientID gid.GID,
versionIDs []gid.GID,
) error {
if len(versionIDs) == 0 {
return nil
}
var versions coredata.DocumentVersions
if err := versions.LoadByIDs(ctx, tx, scope, versionIDs); err != nil {
return fmt.Errorf("cannot load document versions for notification: %w", err)
}
if len(versions) == 0 {
return nil
}
var profiles coredata.MembershipProfiles
if err := profiles.LoadByIDs(ctx, tx, scope, []gid.GID{recipientID}); err != nil {
return fmt.Errorf("cannot load notification recipient: %w", err)
}
if len(profiles) == 0 {
return nil
}
recipient := profiles[0]
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, scope, organizationID); err != nil {
return fmt.Errorf("cannot load notification organization: %w", err)
}
token, err := s.buildInvitationToken(ctx, tx, recipient)
if err != nil {
return err
}
documents := make([]emails.DocumentSummary, 0, len(versions))
for _, version := range versions {
documentPath, err := documentDestinationPath(kind, organizationID, version.DocumentID)
if err != nil {
return fmt.Errorf("cannot build document destination path: %w", err)
}
documentURL, err := s.recipientURL(documentPath, token)
if err != nil {
return fmt.Errorf("cannot build document notification URL: %w", err)
}
documents = append(documents, emails.DocumentSummary{
Title: version.Title,
Type: version.DocumentType.Label(),
URL: documentURL,
})
}
sort.Slice(documents, func(i, j int) bool {
return documents[i].Title < documents[j].Title
})
mainPath, err := mainDestinationPath(kind, organizationID)
if err != nil {
return fmt.Errorf("cannot build main destination path: %w", err)
}
mainURL, err := s.recipientURL(mainPath, token)
if err != nil {
return fmt.Errorf("cannot build main notification URL: %w", err)
}
email, err := s.renderNotificationEmail(ctx, kind, recipient, organization.Name, mainURL, documents)
if err != nil {
return fmt.Errorf("cannot render notification email: %w", err)
}
if err := email.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert notification email: %w", err)
}
return nil
}
func (s *DocumentService) renderNotificationEmail(
ctx context.Context,
kind notificationKind,
recipient *coredata.MembershipProfile,
organizationName string,
mainURL string,
documents []emails.DocumentSummary,
) (*coredata.Email, error) {
emailPresenter := emails.NewPresenter(s.svc.baseURL, recipient.FullName)
var (
subject string
textBody string
htmlBody *string
err error
)
switch kind {
case notificationKindSigning:
subject, textBody, htmlBody, err = emailPresenter.RenderDocumentSigning(ctx, mainURL, organizationName, documents)
case notificationKindApproval:
subject, textBody, htmlBody, err = emailPresenter.RenderDocumentApproval(ctx, mainURL, organizationName, documents)
default:
return nil, fmt.Errorf("unknown notification kind %q", kind)
}
if err != nil {
return nil, fmt.Errorf("cannot render notification email body: %w", err)
}
return coredata.NewEmail(
recipient.FullName,
recipient.EmailAddress,
subject,
textBody,
htmlBody,
&coredata.EmailOptions{
SenderName: new(organizationName),
},
), nil
}
// buildInvitationToken returns an activation token for invited recipients that
// are not yet active and not managed by SCIM; others get an empty token and a
// direct link.
func (s *DocumentService) buildInvitationToken(
ctx context.Context,
tx pg.Tx,
recipient *coredata.MembershipProfile,
) (string, error) {
if recipient.State == coredata.ProfileStateActive || recipient.Source == coredata.ProfileSourceSCIM {
return "", nil
}
now := time.Now()
invitation := &coredata.Invitation{
ID: gid.New(recipient.OrganizationID.TenantID(), coredata.InvitationEntityType),
OrganizationID: recipient.OrganizationID,
UserID: recipient.ID,
Status: coredata.InvitationStatusPending,
ExpiresAt: now.Add(s.invitationTokenValidity),
CreatedAt: now,
}
if err := invitation.Insert(ctx, tx, coredata.NewScopeFromObjectID(recipient.OrganizationID)); err != nil {
return "", fmt.Errorf("cannot insert invitation: %w", err)
}
invitationToken, err := statelesstoken.NewToken(
s.tokenSecret,
iam.TokenTypeOrganizationInvitation,
s.invitationTokenValidity,
iam.InvitationTokenData{InvitationID: invitation.ID},
)
if err != nil {
return "", fmt.Errorf("cannot generate invitation token: %w", err)
}
return invitationToken, nil
}
// recipientURL builds the absolute link for destinationPath, routing through the
// account activation flow when token is set.
func (s *DocumentService) recipientURL(destinationPath string, token string) (string, error) {
target, err := baseurl.MustParse(s.svc.baseURL).AppendPath(destinationPath).String()
if err != nil {
return "", fmt.Errorf("cannot build destination URL: %w", err)
}
if token == "" {
return target, nil
}
activationURL, err := baseurl.MustParse(s.svc.baseURL).
AppendPath("/auth/activate-account").
WithQuery("token", token).
WithQuery("continue", target).
String()
if err != nil {
return "", fmt.Errorf("cannot build activation URL: %w", err)
}
return activationURL, nil
}
func mainDestinationPath(kind notificationKind, organizationID gid.GID) (string, error) {
path, err := url.JoinPath("/organizations", organizationID.String(), "employee", notificationSection(kind))
if err != nil {
return "", fmt.Errorf("cannot build notification path: %w", err)
}
return path, nil
}
func documentDestinationPath(kind notificationKind, organizationID gid.GID, documentID gid.GID) (string, error) {
path, err := url.JoinPath("/organizations", organizationID.String(), "employee", notificationSection(kind), documentID.String())
if err != nil {
return "", fmt.Errorf("cannot build document notification path: %w", err)
}
return path, nil
}
func notificationSection(kind notificationKind) string {
if kind == notificationKindApproval {
return "approvals"
}
return "signatures"
}

View File

@@ -23,7 +23,6 @@ import (
"errors"
"fmt"
"io"
"net/url"
"os"
"regexp"
"strings"
@@ -35,19 +34,16 @@ import (
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/agent"
"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"
"go.probo.inc/probo/pkg/llm"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/pdfutils"
"go.probo.inc/probo/pkg/prosemirror"
"go.probo.inc/probo/pkg/statelesstoken"
"go.probo.inc/probo/pkg/validator"
)
@@ -756,102 +752,6 @@ func (s *DocumentService) Create(
return document, documentVersion, nil
}
func (s *DocumentService) SendSigningNotifications(
ctx context.Context, scope coredata.Scoper,
organizationID gid.GID,
) error {
now := time.Now()
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var signatories coredata.MembershipProfiles
if err := signatories.LoadAwaitingSigning(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot load signatories: %w", err)
}
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
for _, signatory := range signatories {
emailPresenter := emails.NewPresenter(s.svc.baseURL, signatory.FullName)
var (
employeeDocumentsURLPath = "/organizations/" + organizationID.String() + "/employee"
emailLinkURLPath = employeeDocumentsURLPath
query = make(url.Values)
)
if signatory.State != coredata.ProfileStateActive {
if signatory.Source != coredata.ProfileSourceSCIM {
invitation := &coredata.Invitation{
ID: gid.New(organizationID.TenantID(), coredata.InvitationEntityType),
OrganizationID: organizationID,
UserID: signatory.ID,
Status: coredata.InvitationStatusPending,
ExpiresAt: now.Add(s.invitationTokenValidity),
CreatedAt: now,
}
if err := invitation.Insert(ctx, tx, coredata.NewScopeFromObjectID(organizationID)); err != nil {
return fmt.Errorf("cannot insert invitation: %w", err)
}
invitationToken, err := statelesstoken.NewToken(
s.tokenSecret,
iam.TokenTypeOrganizationInvitation,
s.invitationTokenValidity,
iam.InvitationTokenData{InvitationID: invitation.ID},
)
if err != nil {
return fmt.Errorf("cannot generate invitation token: %w", err)
}
emailLinkURLPath = "/auth/activate-account"
continueURL := baseurl.MustParse(s.svc.baseURL).AppendPath(employeeDocumentsURLPath).MustString()
query.Add("token", invitationToken)
query.Add("continue", continueURL)
}
}
subject, textBody, htmlBody, err := emailPresenter.RenderDocumentSigning(
ctx,
emailLinkURLPath,
query,
organization.Name,
)
if err != nil {
return fmt.Errorf("cannot render signing request email: %w", err)
}
email := coredata.NewEmail(
signatory.FullName,
signatory.EmailAddress,
subject,
textBody,
htmlBody,
&coredata.EmailOptions{
SenderName: new(organization.Name),
},
)
if err := email.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
}
return nil
},
)
if err != nil {
return fmt.Errorf("cannot send signing notifications: %w", err)
}
return nil
}
func (s *DocumentService) SignDocumentVersionByIdentity(
ctx context.Context, scope coredata.Scoper,
req SignDocumentVersionRequest,