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

@@ -79,6 +79,11 @@
# THIRD_PARTY_VETTING_STALE_AFTER=1500
# THIRD_PARTY_VETTING_MAX_CONCURRENCY=1
# ── Document signature/approval notifications (seconds) ───────────────
# DOCUMENT_NOTIFICATION_INTERVAL=300
# DOCUMENT_NOTIFICATION_DEBOUNCE_DELAY=900
# DOCUMENT_NOTIFICATION_REMINDER_INTERVAL=86400
# ── OIDC sign-in providers ────────────────────────────────────────────
# AUTH_GOOGLE_CLIENT_ID=
# AUTH_GOOGLE_CLIENT_SECRET=

View File

@@ -17,7 +17,6 @@ import { graphql } from "relay-runtime";
import type { DocumentGraphBulkExportDocumentsMutation } from "#/__generated__/core/DocumentGraphBulkExportDocumentsMutation.graphql";
import type { DocumentGraphDeleteMutation } from "#/__generated__/core/DocumentGraphDeleteMutation.graphql";
import type { DocumentGraphSendSigningNotificationsMutation } from "#/__generated__/core/DocumentGraphSendSigningNotificationsMutation.graphql";
import { useMutationWithToasts } from "../useMutationWithToasts";
@@ -65,28 +64,6 @@ export function useBulkDeleteDocumentsMutation() {
});
}
const sendSigningNotificationsMutation = graphql`
mutation DocumentGraphSendSigningNotificationsMutation(
$input: SendSigningNotificationsInput!
) {
sendSigningNotifications(input: $input) {
success
}
}
`;
export function useSendSigningNotificationsMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<DocumentGraphSendSigningNotificationsMutation>(
sendSigningNotificationsMutation,
{
successMessage: __("Signing notifications sent successfully."),
errorMessage: __("Failed to send signing notifications"),
},
);
}
const bulkExportDocumentsMutation = graphql`
mutation DocumentGraphBulkExportDocumentsMutation(
$input: BulkExportDocumentsInput!

View File

@@ -16,7 +16,6 @@ import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
Button,
IconBell2,
IconPlusLarge,
PageHeader,
TabItem,
@@ -30,9 +29,6 @@ import {
import { ConnectionHandler, graphql } from "relay-runtime";
import type { DocumentsPageQuery } from "#/__generated__/core/DocumentsPageQuery.graphql";
import {
useSendSigningNotificationsMutation,
} from "#/hooks/graph/DocumentGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { CreateDocumentDialog } from "./_components/CreateDocumentDialog";
@@ -66,11 +62,8 @@ export default function DocumentsPage(props: {
throw new Error("invalid type for node");
}
const [sendSigningNotifications] = useSendSigningNotificationsMutation();
usePageTitle(__("Documents"));
const [canSendAnySignatureNotifications, setCanSendAnySignatureNotifications] = useState(false);
const [tab, setTab] = useState<"ACTIVE" | "ARCHIVED">("ACTIVE");
const [documentListConnectionId, setDocumentListConnectionId] = useState(
ConnectionHandler.getConnectionID(
@@ -80,14 +73,6 @@ export default function DocumentsPage(props: {
),
);
const handleSendSigningNotifications = async () => {
await sendSigningNotifications({
variables: {
input: { organizationId },
},
});
};
return (
<div className="space-y-6">
<PageHeader
@@ -95,15 +80,6 @@ export default function DocumentsPage(props: {
description={__("Manage your organization's documents")}
>
<div className="flex gap-2">
{canSendAnySignatureNotifications && (
<Button
icon={IconBell2}
variant="secondary"
onClick={() => void handleSendSigningNotifications()}
>
{__("Send signing notifications")}
</Button>
)}
{organization.canCreateDocument && tab === "ACTIVE" && (
<CreateDocumentDialog
connection={documentListConnectionId}
@@ -125,7 +101,6 @@ export default function DocumentsPage(props: {
<DocumentList
fKey={organization}
onConnectionIdChange={setDocumentListConnectionId}
onCanSendNotificationsChange={setCanSendAnySignatureNotifications}
tab={tab}
/>
</div>

View File

@@ -59,7 +59,6 @@ const createDocumentMutation = graphql`
canRequestSignatures: permission(action: "core:document-version:request-signature")
canArchive: permission(action: "core:document:archive")
canUnarchive: permission(action: "core:document:unarchive")
canSendSigningNotifications: permission(action: "core:document:send-signing-notifications")
...DocumentListItemFragment
}
}

View File

@@ -72,9 +72,6 @@ const fragment = graphql`
)
canArchive: permission(action: "core:document:archive")
canUnarchive: permission(action: "core:document:unarchive")
canSendSigningNotifications: permission(
action: "core:document:send-signing-notifications"
)
...DocumentListItemFragment
}
}
@@ -115,10 +112,9 @@ const bulkUnarchiveMutation = graphql`
export function DocumentList(props: {
fKey: DocumentListFragment$key;
onConnectionIdChange: (connectionId: string) => void;
onCanSendNotificationsChange?: (can: boolean) => void;
tab: "ACTIVE" | "ARCHIVED";
}) {
const { fKey, onConnectionIdChange, onCanSendNotificationsChange, tab } = props;
const { fKey, onConnectionIdChange, tab } = props;
const organizationId = useOrganizationId();
const { email: defaultEmail } = use(CurrentUser);
@@ -170,19 +166,12 @@ export function DocumentList(props: {
const canRequestAnySignatures = documents.some(({ canRequestSignatures }) => canRequestSignatures);
const canArchiveAny = documents.some(({ canArchive }) => canArchive);
const canUnarchiveAny = documents.some(({ canUnarchive }) => canUnarchive);
const canSendAnySignatureNotifications = documents.some(
({ canSendSigningNotifications }) => canSendSigningNotifications,
);
const hasAnyAction = tab === "ARCHIVED" ? canUnarchiveAny || canDeleteAny : canArchiveAny || canDeleteAny || canUpdateAny;
useEffect(() => {
onConnectionIdChange(connectionId);
}, [connectionId, onConnectionIdChange]);
useEffect(() => {
onCanSendNotificationsChange?.(canSendAnySignatureNotifications);
}, [canSendAnySignatureNotifications, onCanSendNotificationsChange]);
const handleDocumentTypeFilterChange = (value: string) => {
const newType = value === "ALL" ? null : (value as DocumentType);
clear();

View File

@@ -679,6 +679,7 @@ func seedCommonTrackerPattern(t *testing.T) gid.GID {
defer cancel()
cleanupConn := dialTestPg(t, cleanupCtx)
defer func() { _ = cleanupConn.Close(cleanupCtx) }()
_, err := cleanupConn.Exec(cleanupCtx, `DELETE FROM common_tracker_patterns WHERE id = $1`, id)

View File

@@ -20,7 +20,6 @@ import (
"embed"
"fmt"
htmltemplate "html/template"
"net/url"
texttemplate "text/template"
"time"
@@ -60,6 +59,12 @@ type (
config PresenterConfig
RecipientFullName string
}
DocumentSummary struct {
Title string
Type string
URL string
}
)
func DefaultPresenterConfig(baseURL string) PresenterConfig {
@@ -92,7 +97,7 @@ const (
subjectConfirmEmail = "Confirm your email address"
subjectPasswordReset = "Reset your password"
subjectInvitation = "Invitation to join %s on Probo"
subjectDocumentApproval = "Action Required Please review and approve %s"
subjectDocumentApproval = "Action Required Please review and approve %s compliance documents"
subjectDocumentSigning = "Action Required Please review and sign %s compliance documents"
subjectDocumentExport = "Your document export is ready"
subjectFrameworkExport = "Your framework export is ready"
@@ -231,62 +236,53 @@ func (p *Presenter) RenderInvitation(ctx context.Context, invitationURLPath stri
func (p *Presenter) RenderDocumentApproval(
ctx context.Context,
approvalURLPath string,
approvalURLQuery url.Values,
approvalURL string,
organizationName string,
documentName string,
documents []DocumentSummary,
) (subject string, textBody string, htmlBody *string, err error) {
vars, err := p.getCommonVariables()
if err != nil {
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
}
approvalURL := baseurl.MustParse(vars.BaseURL).
AppendPath(approvalURLPath).
WithQueryValues(approvalURLQuery).
MustString()
data := struct {
*CommonVariables
ApprovalUrl string
OrganizationName string
DocumentName string
Documents []DocumentSummary
}{
CommonVariables: vars,
ApprovalUrl: approvalURL,
OrganizationName: organizationName,
DocumentName: documentName,
Documents: documents,
}
textBody, htmlBody, err = renderEmail(documentApprovalTextTemplate, documentApprovalHTMLTemplate, data)
return fmt.Sprintf(subjectDocumentApproval, documentName), textBody, htmlBody, err
return fmt.Sprintf(subjectDocumentApproval, organizationName), textBody, htmlBody, err
}
func (p *Presenter) RenderDocumentSigning(
ctx context.Context,
signingURLPath string,
signingURLQuery url.Values,
signingURL string,
organizationName string,
documents []DocumentSummary,
) (subject string, textBody string, htmlBody *string, err error) {
vars, err := p.getCommonVariables()
if err != nil {
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
}
signingURL := baseurl.MustParse(vars.BaseURL).
AppendPath(signingURLPath).
WithQueryValues(signingURLQuery).
MustString()
data := struct {
*CommonVariables
SigningUrl string
OrganizationName string
Documents []DocumentSummary
}{
CommonVariables: vars,
SigningUrl: signingURL,
OrganizationName: organizationName,
Documents: documents,
}
textBody, htmlBody, err = renderEmail(documentSigningTextTemplate, documentSigningHTMLTemplate, data)

View File

@@ -16,14 +16,22 @@ import { Button, Section, Text } from 'react-email';
import * as React from 'react';
import EmailLayout, { bodyText, button, buttonContainer, footerText } from './components/EmailLayout';
const documentList = {
...bodyText,
paddingLeft: '20px',
};
export const DocumentApproval = () => {
return (
<EmailLayout subject={'Action Required Please review and approve {{.DocumentName}}'}>
<EmailLayout subject={'Action Required Please review and approve {{.OrganizationName}} compliance documents'}>
<Text style={bodyText}>
You're receiving this message because <strong>{'{{.OrganizationName}}'}</strong> has requested your approval on the document <strong>{'{{.DocumentName}}'}</strong>.
You're receiving this message because <strong>{'{{.OrganizationName}}'}</strong> has requested your approval on the following documents:
</Text>
<ul style={documentList} dangerouslySetInnerHTML={{ __html: '{{range .Documents}}<li><a href="{{.URL}}">{{.Title}}</a> ({{.Type}})</li>{{end}}' }} />
<Text style={bodyText}>
Please take a moment to review the document and approve or reject it by clicking the button below:
Please take a moment to review them and approve or reject each one by clicking the button below:
</Text>
<Section style={buttonContainer}>

View File

@@ -16,14 +16,22 @@ import { Button, Section, Text } from 'react-email';
import * as React from 'react';
import EmailLayout, { bodyText, button, buttonContainer, footerText } from './components/EmailLayout';
const documentList = {
...bodyText,
paddingLeft: '20px',
};
export const DocumentSigning = () => {
return (
<EmailLayout subject={'Action Required Please review and sign {{.OrganizationName}} compliance documents'}>
<Text style={bodyText}>
You're receiving this message because your company, <strong>{'{{.OrganizationName}}'}</strong>, has shared a new compliance document that requires your review and signature.
You're receiving this message because your company, <strong>{'{{.OrganizationName}}'}</strong>, has shared compliance documents that require your review and signature:
</Text>
<ul style={documentList} dangerouslySetInnerHTML={{ __html: '{{range .Documents}}<li><a href="{{.URL}}">{{.Title}}</a> ({{.Type}})</li>{{end}}' }} />
<Text style={bodyText}>
To stay compliant with company policies, please take a moment to review and sign the document by clicking the button below:
To stay compliant with company policies, please take a moment to review and sign them by clicking the button below:
</Text>
<Section style={buttonContainer}>

View File

@@ -2,9 +2,11 @@ Probo
Hi {{.RecipientFullName}},
You're receiving this message because {{.OrganizationName}} has requested your approval on the document {{.DocumentName}}.
You're receiving this message because {{.OrganizationName}} has requested your approval on the following documents:
Please take a moment to review the document and approve or reject it by clicking the link below:
{{range .Documents}}- {{.Title}} ({{.Type}}): {{.URL}}
{{end}}
Please take a moment to review them and approve or reject each one by clicking the link below:
{{.ApprovalUrl}}

View File

@@ -2,9 +2,11 @@ Probo
Hi {{.RecipientFullName}},
You're receiving this message because your company, {{.OrganizationName}}, has shared a new compliance document that requires your review and signature.
You're receiving this message because your company, {{.OrganizationName}}, has shared compliance documents that require your review and signature:
To stay compliant with company policies, please take a moment to review and sign the document by clicking the link below:
{{range .Documents}}- {{.Title}} ({{.Type}}): {{.URL}}
{{end}}
To stay compliant with company policies, please take a moment to review and sign them by clicking the link below:
{{.SigningUrl}}

View File

@@ -31,7 +31,6 @@ import * as getSignatureOp from './getSignature.operation';
import * as getAllSignaturesOp from './getAllSignatures.operation';
import * as requestSignatureOp from './requestSignature.operation';
import * as cancelSignatureOp from './cancelSignature.operation';
import * as sendSigningNotificationsOp from './sendSigningNotifications.operation';
import * as getApprovalQuorumOp from './getApprovalQuorum.operation';
import * as getAllApprovalQuorumsOp from './getAllApprovalQuorums.operation';
import * as getApprovalDecisionOp from './getApprovalDecision.operation';
@@ -157,12 +156,6 @@ export const description: INodeProperties[] = [
description: 'Request a signature for a document version',
action: 'Request a document version signature',
},
{
name: 'Send Signing Notifications',
value: 'sendSigningNotifications',
description: 'Send signing notifications to all pending signatories',
action: 'Send signing notifications',
},
{
name: 'Unarchive',
value: 'unarchive',
@@ -208,7 +201,6 @@ export const description: INodeProperties[] = [
...getAllSignaturesOp.description,
...requestSignatureOp.description,
...cancelSignatureOp.description,
...sendSigningNotificationsOp.description,
...getApprovalQuorumOp.description,
...getAllApprovalQuorumsOp.description,
...getApprovalDecisionOp.description,
@@ -234,7 +226,6 @@ export {
getAllSignaturesOp as getAllSignatures,
requestSignatureOp as requestSignature,
cancelSignatureOp as cancelSignature,
sendSigningNotificationsOp as sendSigningNotifications,
getApprovalQuorumOp as getApprovalQuorum,
getAllApprovalQuorumsOp as getAllApprovalQuorums,
getApprovalDecisionOp as getApprovalDecision,

View File

@@ -1,55 +0,0 @@
// Copyright (c) 2025-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.
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Organization ID',
name: 'organizationId',
type: 'string',
displayOptions: {
show: {
resource: ['document'],
operation: ['sendSigningNotifications'],
},
},
default: '',
description: 'The ID of the organization',
required: true,
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const organizationId = this.getNodeParameter('organizationId', itemIndex) as string;
const query = `
mutation SendSigningNotifications($input: SendSigningNotificationsInput!) {
sendSigningNotifications(input: $input) {
success
}
}
`;
const responseData = await proboApiRequest.call(this, query, { input: { organizationId } });
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}

View File

@@ -177,6 +177,11 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) {
SenderInterval: b.getEnvIntOrDefault("WEBHOOK_SENDER_INTERVAL", 5),
CacheTTL: b.getEnvIntOrDefault("WEBHOOK_CACHE_TTL", 86400),
},
Document: probodconfig.DocumentNotificationConfig{
Interval: b.getEnvIntOrDefault("DOCUMENT_NOTIFICATION_INTERVAL", 300),
DebounceDelay: b.getEnvIntOrDefault("DOCUMENT_NOTIFICATION_DEBOUNCE_DELAY", 900),
ReminderInterval: b.getEnvIntOrDefault("DOCUMENT_NOTIFICATION_REMINDER_INTERVAL", 86400),
},
},
Agents: probodconfig.AgentsConfig{
Providers: map[string]probodconfig.LLMProviderConfig{

View File

@@ -198,6 +198,9 @@ func TestBuilder_Build_Defaults(t *testing.T) {
assert.Empty(t, cfg.Probod.Notifications.Slack.SigningSecret)
assert.Equal(t, 5, cfg.Probod.Notifications.Webhook.SenderInterval)
assert.Equal(t, 86400, cfg.Probod.Notifications.Webhook.CacheTTL)
assert.Equal(t, 300, cfg.Probod.Notifications.Document.Interval)
assert.Equal(t, 900, cfg.Probod.Notifications.Document.DebounceDelay)
assert.Equal(t, 86400, cfg.Probod.Notifications.Document.ReminderInterval)
// Agents tools — Firecrawl empty by default
assert.Empty(t, cfg.Probod.Agents.Tools.FirecrawlAPIKey)
@@ -329,6 +332,9 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
env["WEBHOOK_SENDER_INTERVAL"] = "10"
env["WEBHOOK_CACHE_TTL"] = "3600"
env["CONNECTOR_SLACK_SIGNING_SECRET"] = "slack-signing-secret"
env["DOCUMENT_NOTIFICATION_INTERVAL"] = "120"
env["DOCUMENT_NOTIFICATION_DEBOUNCE_DELAY"] = "60"
env["DOCUMENT_NOTIFICATION_REMINDER_INTERVAL"] = "43200"
// Firecrawl
env["FIRECRAWL_API_KEY"] = "fc-test-key"
// Agents — providers
@@ -456,6 +462,9 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
assert.Equal(t, "slack-signing-secret", cfg.Probod.Notifications.Slack.SigningSecret)
assert.Equal(t, 10, cfg.Probod.Notifications.Webhook.SenderInterval)
assert.Equal(t, 3600, cfg.Probod.Notifications.Webhook.CacheTTL)
assert.Equal(t, 120, cfg.Probod.Notifications.Document.Interval)
assert.Equal(t, 60, cfg.Probod.Notifications.Document.DebounceDelay)
assert.Equal(t, 43200, cfg.Probod.Notifications.Document.ReminderInterval)
// Agents tools — Firecrawl
assert.Equal(t, "fc-test-key", cfg.Probod.Agents.Tools.FirecrawlAPIKey)
// Agents — providers

View File

@@ -80,6 +80,31 @@ func (v DocumentType) String() string {
return string(v)
}
func (v DocumentType) Label() string {
switch v {
case DocumentTypeGovernance:
return "Governance"
case DocumentTypePolicy:
return "Policy"
case DocumentTypeProcedure:
return "Procedure"
case DocumentTypePlan:
return "Plan"
case DocumentTypeRegister:
return "Register"
case DocumentTypeRecord:
return "Record"
case DocumentTypeReport:
return "Report"
case DocumentTypeTemplate:
return "Template"
case DocumentTypeStatementOfApplicability:
return "Statement of Applicability"
default:
return "Document"
}
}
func (v DocumentType) MarshalText() ([]byte, error) {
return []byte(v.String()), nil
}

View File

@@ -152,6 +152,58 @@ WHERE
return nil
}
func (dv *DocumentVersions) LoadByIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
documentVersionIDs []gid.GID,
) error {
q := `
SELECT
id,
organization_id,
document_id,
title,
major,
minor,
classification,
document_type,
content,
changelog,
status,
orientation,
file_id,
pdf_attempt_count,
published_at,
created_at,
updated_at
FROM
document_versions
WHERE
%s
AND id = ANY(@document_version_ids)
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"document_version_ids": documentVersionIDs}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query document versions: %w", err)
}
documentVersions, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersion])
if err != nil {
return fmt.Errorf("cannot collect document versions: %w", err)
}
*dv = documentVersions
return nil
}
func (dv DocumentVersion) CursorKey(orderBy DocumentVersionOrderField) page.CursorKey {
switch orderBy {
case DocumentVersionOrderFieldCreatedAt:

View File

@@ -298,7 +298,8 @@ INSERT INTO document_version_approval_decisions (
electronic_signature_id,
decided_at,
created_at,
updated_at
updated_at,
notification_count
) VALUES (
@id,
@tenant_id,
@@ -310,7 +311,8 @@ INSERT INTO document_version_approval_decisions (
@electronic_signature_id,
@decided_at,
@created_at,
@updated_at
@updated_at,
@notification_count
)
`
@@ -326,6 +328,7 @@ INSERT INTO document_version_approval_decisions (
"decided_at": d.DecidedAt,
"created_at": d.CreatedAt,
"updated_at": d.UpdatedAt,
"notification_count": 0,
}
_, err := conn.Exec(ctx, q, args)
@@ -367,6 +370,7 @@ func (ds DocumentVersionApprovalDecisions) BulkInsert(
d.DecidedAt,
d.CreatedAt,
d.UpdatedAt,
0,
},
)
}
@@ -386,6 +390,7 @@ func (ds DocumentVersionApprovalDecisions) BulkInsert(
"decided_at",
"created_at",
"updated_at",
"notification_count",
},
pgx.CopyFromRows(rows),
)
@@ -393,6 +398,211 @@ func (ds DocumentVersionApprovalDecisions) BulkInsert(
return err
}
// LoadNextDueGroupForNotification loads every still-PENDING approval decision
// for the next (organization, approver) group that has at least one decision due
// for a notification, so the whole group can be emailed together. A decision 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.
//
// A decision is only ever PENDING while its quorum is pending — resolving a
// quorum either approves all decisions or voids the remaining ones — so there is
// no need to join the quorum table here.
func (d *DocumentVersionApprovalDecisions) 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,
approver_id
FROM
document_version_approval_decisions
WHERE
state = @state
AND notification_count < @max_notifications
AND (
(notification_count = 0 AND created_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_version_approval_quorums q
JOIN document_versions dv ON dv.id = q.version_id
JOIN documents doc ON doc.id = dv.document_id
WHERE q.id = document_version_approval_decisions.quorum_id
AND doc.deleted_at IS NULL
AND doc.archived_at IS NULL
)
GROUP BY
organization_id,
approver_id
ORDER BY
organization_id,
approver_id
LIMIT 1
)
SELECT
d.id,
d.organization_id,
d.quorum_id,
d.approver_id,
d.state,
d.comment,
d.electronic_signature_id,
d.decided_at,
d.created_at,
d.updated_at
FROM
document_version_approval_decisions d
INNER JOIN next_group g
ON g.organization_id = d.organization_id
AND g.approver_id = d.approver_id
WHERE
d.state = @state
AND d.notification_count < @max_notifications
AND EXISTS (
SELECT 1
FROM document_version_approval_quorums q
JOIN document_versions dv ON dv.id = q.version_id
JOIN documents doc ON doc.id = dv.document_id
WHERE q.id = d.quorum_id
AND doc.deleted_at IS NULL
AND doc.archived_at IS NULL
)
ORDER BY
d.quorum_id
`
args := pgx.StrictNamedArgs{
"state": DocumentVersionApprovalDecisionStatePending,
"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 approval decisions for notification: %w", err)
}
decisions, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersionApprovalDecision])
if err != nil {
return fmt.Errorf("cannot collect due approval decisions for notification: %w", err)
}
*d = decisions
return nil
}
// ClaimForNotification claims the receiver's decisions 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 (d DocumentVersionApprovalDecisions) ClaimForNotification(
ctx context.Context,
conn pg.Tx,
now time.Time,
debounceBefore time.Time,
reminderInterval time.Duration,
) ([]gid.GID, error) {
ids := make([]gid.GID, len(d))
for i, decision := range d {
ids[i] = decision.ID
}
q := `
UPDATE document_version_approval_decisions
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 created_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": DocumentVersionApprovalDecisionStatePending,
"max_notifications": documentNotificationMaxCount,
"now": now,
"debounce_before": debounceBefore,
"reminder_interval_seconds": reminderInterval.Seconds(),
})
if err != nil {
return nil, fmt.Errorf("cannot claim due approval decisions for notification: %w", err)
}
claimed, err := pgx.CollectRows(rows, pgx.RowTo[gid.GID])
if err != nil {
return nil, fmt.Errorf("cannot collect claimed approval decisions for notification: %w", err)
}
return claimed, nil
}
// BumpRemainingForNotification advances the notification schedule for the
// still-pending decisions 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 (d DocumentVersionApprovalDecisions) BumpRemainingForNotification(
ctx context.Context,
conn pg.Tx,
claimed []gid.GID,
now time.Time,
) ([]gid.GID, error) {
ids := make([]gid.GID, len(d))
for i, decision := range d {
ids[i] = decision.ID
}
rest := remainingNotificationIDs(ids, claimed)
if len(rest) == 0 {
return nil, nil
}
q := `
UPDATE document_version_approval_decisions
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": DocumentVersionApprovalDecisionStatePending,
"max_notifications": documentNotificationMaxCount,
"now": now,
})
if err != nil {
return nil, fmt.Errorf("cannot bump remaining approval decisions for notification: %w", err)
}
bumped, err := pgx.CollectRows(rows, pgx.RowTo[gid.GID])
if err != nil {
return nil, fmt.Errorf("cannot collect bumped approval decisions for notification: %w", err)
}
return bumped, nil
}
func (d *DocumentVersionApprovalDecision) Update(
ctx context.Context,
conn pg.Tx,

View File

@@ -135,6 +135,47 @@ WHERE
return nil
}
func (q *DocumentVersionApprovalQuorums) LoadByIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
quorumIDs []gid.GID,
) error {
query := `
SELECT
id,
organization_id,
version_id,
status,
created_at,
updated_at
FROM
document_version_approval_quorums
WHERE
%s
AND id = ANY(@quorum_ids)
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{"quorum_ids": quorumIDs}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot query approval quorums: %w", err)
}
quorums, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersionApprovalQuorum])
if err != nil {
return fmt.Errorf("cannot collect approval quorums: %w", err)
}
*q = quorums
return nil
}
func (q *DocumentVersionApprovalQuorum) LoadLastByDocumentVersionID(
ctx context.Context,
conn pg.Querier,

View File

@@ -297,7 +297,8 @@ INSERT INTO document_version_signatures (
requested_at,
electronic_signature_id,
created_at,
updated_at
updated_at,
notification_count
) VALUES (
@id,
@tenant_id,
@@ -309,7 +310,8 @@ INSERT INTO document_version_signatures (
@requested_at,
@electronic_signature_id,
@created_at,
@updated_at
@updated_at,
@notification_count
)
`
@@ -325,6 +327,7 @@ INSERT INTO document_version_signatures (
"electronic_signature_id": pvs.ElectronicSignatureID,
"created_at": pvs.CreatedAt,
"updated_at": pvs.UpdatedAt,
"notification_count": 0,
}
_, err := conn.Exec(ctx, q, args)
@@ -399,6 +402,230 @@ WHERE
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
)
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,
"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,

View File

@@ -838,82 +838,6 @@ WHERE
return count, nil
}
func (p *MembershipProfiles) LoadAwaitingSigning(
ctx context.Context,
conn pg.Querier,
scope Scoper,
) error {
q := `
WITH signatories AS (
SELECT
signed_by_profile_id
FROM
document_version_signatures
WHERE
%s
AND state = 'REQUESTED'
GROUP BY
signed_by_profile_id
)
SELECT
p.id,
p.identity_id,
p.organization_id,
p.kind,
p.full_name,
i.email_address,
p.source,
p.state,
p.additional_email_addresses,
p.position,
p.contract_start_date,
p.contract_end_date,
'' AS organization_name,
p.user_name,
p.external_id,
p.nickname,
p.locale,
p.timezone,
p.profile_url,
p.preferred_language,
p.given_name,
p.family_name,
p.formatted_name,
p.middle_name,
p.honorific_prefix,
p.honorific_suffix,
p.employee_number,
p.department,
p.cost_center,
p.enterprise_organization,
p.division,
p.manager_value,
p.created_at,
p.updated_at
FROM
iam_membership_profiles p
INNER JOIN identities i
ON i.id = p.identity_id
INNER JOIN signatories ON p.id = signatories.signed_by_profile_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
rows, err := conn.Query(ctx, q, scope.SQLArguments())
if err != nil {
return fmt.Errorf("cannot query profiles: %w", err)
}
profiles, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MembershipProfile])
if err != nil {
return fmt.Errorf("cannot collect profiles: %w", err)
}
*p = profiles
return nil
}
func (p *MembershipProfiles) CountByIdentityID(
ctx context.Context,
conn pg.Querier,

View File

@@ -0,0 +1,31 @@
-- 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.
ALTER TABLE document_version_signatures
ADD COLUMN last_notified_at TIMESTAMPTZ;
ALTER TABLE document_version_signatures
ADD COLUMN notification_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE document_version_signatures
ALTER COLUMN notification_count DROP DEFAULT;
ALTER TABLE document_version_approval_decisions
ADD COLUMN last_notified_at TIMESTAMPTZ;
ALTER TABLE document_version_approval_decisions
ADD COLUMN notification_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE document_version_approval_decisions
ALTER COLUMN notification_count DROP DEFAULT;

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,

View File

@@ -51,9 +51,11 @@ type (
SMTPConfig = probodconfig.SMTPConfig
NotificationsConfig = probodconfig.NotificationsConfig
WebhookConfig = probodconfig.WebhookConfig
OIDCProviderConfig = probodconfig.OIDCProviderConfig
PgConfig = probodconfig.PgConfig
SAMLConfig = probodconfig.SAMLConfig
SCIMBridgeConfig = probodconfig.SCIMBridgeConfig
SlackConfig = probodconfig.SlackConfig
DocumentNotificationConfig = probodconfig.DocumentNotificationConfig
OIDCProviderConfig = probodconfig.OIDCProviderConfig
PgConfig = probodconfig.PgConfig
SAMLConfig = probodconfig.SAMLConfig
SCIMBridgeConfig = probodconfig.SCIMBridgeConfig
SlackConfig = probodconfig.SlackConfig
)

View File

@@ -152,6 +152,11 @@ func New() *Implm {
SenderInterval: 5,
CacheTTL: 86400,
},
Document: DocumentNotificationConfig{
Interval: 300, // 5 minutes
DebounceDelay: 900, // 15 minutes
ReminderInterval: 86400, // 1 day base cadence (1x, 2x, 3x)
},
},
CustomDomains: CustomDomainsConfig{
RenewalInterval: 3600,
@@ -734,6 +739,40 @@ func (impl *Implm) Run(
},
)
documentNotificationInterval := time.Duration(impl.cfg.Notifications.Document.Interval) * time.Second
if documentNotificationInterval <= 0 {
documentNotificationInterval = 5 * time.Minute
}
documentNotificationDebounce := time.Duration(impl.cfg.Notifications.Document.DebounceDelay) * time.Second
if documentNotificationDebounce <= 0 {
documentNotificationDebounce = 15 * time.Minute
}
documentNotificationReminder := time.Duration(impl.cfg.Notifications.Document.ReminderInterval) * time.Second
if documentNotificationReminder <= 0 {
documentNotificationReminder = 24 * time.Hour
}
documentNotificationWorker := probo.NewDocumentNotificationWorker(
proboService,
l.Named("document-notification-worker"),
probo.DocumentNotificationWorkerConfig{
DebounceDelay: documentNotificationDebounce,
ReminderInterval: documentNotificationReminder,
},
worker.WithInterval(documentNotificationInterval),
)
documentNotificationCtx, stopDocumentNotification := context.WithCancel(context.Background())
wg.Go(
func() {
if err := documentNotificationWorker.Run(documentNotificationCtx); err != nil {
cancel(fmt.Errorf("document notification worker crashed: %w", err))
}
},
)
accessReviewWorkerCtx, stopAccessReviewWorker := context.WithCancel(context.Background())
wg.Go(
@@ -959,6 +998,7 @@ func (impl *Implm) Run(
stopVettingWorker()
stopEvidenceDescriptionWorker()
stopDocumentPDFWorker()
stopDocumentNotification()
stopExportJobExporter()
stopAccessReviewWorker()
stopIAMService()

View File

@@ -15,12 +15,26 @@
package probodconfig
type NotificationsConfig struct {
Mailer MailerConfig `json:"mailer"`
Slack SlackConfig `json:"slack"`
Webhook WebhookConfig `json:"webhook"`
Mailer MailerConfig `json:"mailer"`
Slack SlackConfig `json:"slack"`
Webhook WebhookConfig `json:"webhook"`
Document DocumentNotificationConfig `json:"document"`
}
type WebhookConfig struct {
SenderInterval int `json:"sender-interval"`
CacheTTL int `json:"cache-ttl"`
}
// DocumentNotificationConfig configures the debounced worker that batches
// signature and approval request notifications. All durations are in seconds.
type DocumentNotificationConfig struct {
// Interval is how often the worker scans for pending requests.
Interval int `json:"interval"`
// DebounceDelay is how long a request must have been pending before its
// first notification is sent.
DebounceDelay int `json:"debounce-delay"`
// ReminderInterval is the base reminder cadence. Reminders are sent at
// 1x, 2x and 3x this interval after the previous email, then stop.
ReminderInterval int `json:"reminder-interval"`
}

View File

@@ -1386,23 +1386,6 @@ func (r *mutationResolver) BulkRequestSignatures(ctx context.Context, input type
}, nil
}
// SendSigningNotifications is the resolver for the sendSigningNotifications field.
func (r *mutationResolver) SendSigningNotifications(ctx context.Context, input types.SendSigningNotificationsInput) (*types.SendSigningNotificationsPayload, error) {
scope, err := r.authorize(ctx, input.OrganizationID, probo.ActionDocumentSendSigningNotifications)
if err != nil {
return nil, err
}
if err := r.probo.Documents.SendSigningNotifications(ctx, scope, input.OrganizationID); err != nil {
r.logger.ErrorCtx(ctx, "cannot send signing notifications", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.SendSigningNotificationsPayload{
Success: true,
}, nil
}
// CancelSignatureRequest is the resolver for the cancelSignatureRequest field.
func (r *mutationResolver) CancelSignatureRequest(ctx context.Context, input types.CancelSignatureRequestInput) (*types.CancelSignatureRequestPayload, error) {
scope, err := r.authorize(ctx, input.DocumentVersionSignatureID, probo.ActionDocumentVersionCancelSignature)

View File

@@ -567,9 +567,6 @@ extend type Mutation {
bulkRequestSignatures(
input: BulkRequestSignaturesInput!
): BulkRequestSignaturesPayload!
sendSigningNotifications(
input: SendSigningNotificationsInput!
): SendSigningNotificationsPayload!
cancelSignatureRequest(
input: CancelSignatureRequestInput!
): CancelSignatureRequestPayload!
@@ -685,10 +682,6 @@ input BulkRequestSignaturesInput {
signatoryIds: [ID!]!
}
input SendSigningNotificationsInput {
organizationId: ID!
}
input CancelSignatureRequestInput {
documentVersionSignatureId: ID!
}
@@ -782,10 +775,6 @@ type BulkRequestSignaturesPayload {
documentVersionSignatureEdges: [DocumentVersionSignatureEdge!]!
}
type SendSigningNotificationsPayload {
success: Boolean!
}
type CancelSignatureRequestPayload {
deletedDocumentVersionSignatureId: ID!
}

View File

@@ -4125,24 +4125,6 @@ func (r *Resolver) VoidDocumentVersionApprovalTool(ctx context.Context, req *mcp
}, nil
}
func (r *Resolver) SendSigningNotificationsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.SendSigningNotificationsInput) (*mcp.CallToolResult, types.SendSigningNotificationsOutput, error) {
scope, err := r.Authorize(ctx, input.OrganizationID, probo.ActionDocumentSendSigningNotifications)
if err != nil {
return nil, types.SendSigningNotificationsOutput{}, err
}
svc := r.proboSvc
err = svc.Documents.SendSigningNotifications(ctx, scope, input.OrganizationID)
if err != nil {
panic(fmt.Errorf("cannot send signing notifications: %w", err))
}
return nil, types.SendSigningNotificationsOutput{
Success: true,
}, nil
}
func (r *Resolver) DeleteDocumentDraftTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteDocumentDraftInput) (*mcp.CallToolResult, types.DeleteDocumentDraftOutput, error) {
scope, err := r.Authorize(ctx, input.ID, probo.ActionDocumentDeleteDraft)
if err != nil {

View File

@@ -6570,24 +6570,6 @@ components:
approval_decision:
$ref: "#/components/schemas/DocumentVersionApprovalDecision"
SendSigningNotificationsInput:
type: object
required:
- organization_id
properties:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
SendSigningNotificationsOutput:
type: object
required:
- success
properties:
success:
type: boolean
description: Whether the notifications were sent successfully
WebhookEventType:
type: string
enum:
@@ -13109,14 +13091,6 @@ tools:
$ref: "#/components/schemas/VoidDocumentVersionApprovalInput"
outputSchema:
$ref: "#/components/schemas/VoidDocumentVersionApprovalOutput"
- name: sendSigningNotifications
description: Send signing notifications to all signatories with pending signature requests in the organization
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/SendSigningNotificationsInput"
outputSchema:
$ref: "#/components/schemas/SendSigningNotificationsOutput"
- name: listStatementsOfApplicability
description: List all statements of applicability for the organization
hints: