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:
committed by
Sacha Al Himdani
parent
c9b74bac4a
commit
f462b124e6
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
549
pkg/probo/document_notification_worker.go
Normal file
549
pkg/probo/document_notification_worker.go
Normal 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"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user