feat(automate-signing): one-click invitation kickoff + auto cascade + completion broadcast
Phase 3 of the comprehensive UAT round. Implements the Automate
Signing feature per the 2026-05-26 locked decisions.
P3.1 — documents.automation_mode schema
Migration 0088 adds the column with a CHECK constraint enforcing
the three-value enum: manual / sequential_auto / concurrent_auto.
Drizzle schema picks it up; default 'manual' preserves existing
behaviour.
P3.2 — Automate Signing orchestrator service
New src/lib/services/signing-automation.service.ts. enableSigningAutomation
resolves the mode from the envelope's signing order (SEQUENTIAL ->
sequential_auto fires first signer only; PARALLEL -> concurrent_auto
fires all signers in one parallel dispatch), updates documents.automationMode,
and dispatches invitations via the same sendSigningInvitation path
the manual route uses (so the email a recipient sees is identical
regardless of trigger). ensureSigningUrls recovers v2 signing URLs
if they're missing on the local signer rows. Hard guards: envelope
must exist, status in {draft, sent, partially_signed}, ≥2 signers.
disableSigningAutomation reverts to manual; idempotent.
P3.3 — Webhook cascade
The existing sendCascadingInviteForNextSigner in documents.service.ts
already fires the next pending signer on every recipient_signed event
(mode-independent). handleDocumentCompleted already sends the signed
PDF to all recipients via sendSigningCompleted on completion. So
"automate" really means "kick off the first invitation"; the rest
is mode-independent existing behaviour. Doc comment in the new
service explains the interaction.
P3.4 — ActiveEoiCard Automate signing button + banner
- DocumentRow type extended with automationMode + documensoId.
- New automateMutation hits POST /api/v1/documents/[id]/automate;
pauseAutomationMutation hits DELETE.
- "Automate signing" button visible when totalCount ≥ 2 AND doc has
documensoId AND envelope is in-flight AND mode === 'manual'.
- "Automating sequentially/concurrently · N of M signed" banner
renders when automation is active, with a Pause button that
reverts to manual.
- Per-row Send invitation / Send reminder buttons in SigningProgress
stay visible per the locked decision (manual override during auto).
P3.5 — Automate Signing API route + tests
- POST /api/v1/documents/[id]/automate (enables) + DELETE (disables).
- Permission: documents.send_for_signing (mirrors the manual
send-invitation route).
- vitest covering: NotFound on missing doc, Conflict on missing
envelope, Conflict on completed status, Conflict on already-
automated, Conflict on <2 signers, disable is idempotent when
already manual. All 7 cases pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,14 @@ interface DocumentRow {
|
||||
* once the fully-signed PDF has been downloaded from Documenso and
|
||||
* stored in MinIO/filesystem. Drives the "Download signed PDF" CTA. */
|
||||
signedFileId?: string | null;
|
||||
/** Automate Signing mode — drives the banner + button visibility on
|
||||
* ActiveEoiCard. `manual` (default) hides the banner and shows the
|
||||
* "Automate signing" button; the auto modes hide the button and
|
||||
* show the "Automating · N of M" banner + "Pause" CTA. */
|
||||
automationMode?: 'manual' | 'sequential_auto' | 'concurrent_auto';
|
||||
/** Documenso envelope id — required for the Automate Signing
|
||||
* button to enable (we don't auto-create envelopes mid-flow). */
|
||||
documensoId?: string | null;
|
||||
}
|
||||
|
||||
interface DocumentSigner {
|
||||
@@ -353,6 +361,46 @@ function ActiveEoiCard({
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
// Automate Signing — single button that fires the first signer's
|
||||
// invitation (sequential envelopes) OR all signers in parallel
|
||||
// (concurrent envelopes). The existing webhook cascade handles
|
||||
// sequential follow-ups; the completion broadcast handles the
|
||||
// "signed PDF to everyone" email regardless of mode. See
|
||||
// src/lib/services/signing-automation.service.ts.
|
||||
const automateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<{ data: { mode: string; invitedCount: number } }>(
|
||||
`/api/v1/documents/${doc.id}/automate`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['documents', doc.id, 'signers'] });
|
||||
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
|
||||
const modeLabel =
|
||||
res.data.mode === 'sequential_auto' ? 'Automating sequentially' : 'Automating concurrently';
|
||||
toast.success(`${modeLabel} - ${res.data.invitedCount} invitation(s) sent.`);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
const pauseAutomationMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/automate`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
|
||||
toast.success('Automation paused. Reverted to manual.');
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
const automationMode = doc.automationMode ?? 'manual';
|
||||
const automationActive =
|
||||
automationMode === 'sequential_auto' || automationMode === 'concurrent_auto';
|
||||
const canAutomate =
|
||||
!automationActive &&
|
||||
!effectivelyCompleted &&
|
||||
!isRejected &&
|
||||
totalCount >= 2 &&
|
||||
Boolean(doc.documensoId);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
@@ -435,6 +483,28 @@ function ActiveEoiCard({
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{/* Automate signing — fires the first signer's invitation
|
||||
(sequential envelopes) or all signers in parallel
|
||||
(concurrent envelopes) in one click. Hidden when
|
||||
automation is already active or when there are fewer
|
||||
than two signers. */}
|
||||
{canAutomate && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={automateMutation.isPending}
|
||||
onClick={() => automateMutation.mutate()}
|
||||
className="gap-1.5 [&_svg]:size-3.5"
|
||||
title="Fire the first signer's invitation; webhook handles the cascade. Sends the completed PDF to everyone when all signers finish."
|
||||
>
|
||||
{automateMutation.isPending ? (
|
||||
<Loader2 className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<FileSignature />
|
||||
)}
|
||||
Automate signing
|
||||
</Button>
|
||||
)}
|
||||
{/* Remind all hides once every signer is signed - no-one to nudge. */}
|
||||
{!effectivelyCompleted && (
|
||||
<Button
|
||||
@@ -455,6 +525,41 @@ function ActiveEoiCard({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Automation banner — replaces the manual "Send invitation"
|
||||
friction with a single status indicator. Manual override
|
||||
buttons in SigningProgress stay visible (per decision
|
||||
locked 2026-05-26) so reps retain individual control. */}
|
||||
{automationActive && !effectivelyCompleted && !isRejected ? (
|
||||
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-xs text-emerald-900">
|
||||
<span className="flex items-center gap-2">
|
||||
<FileSignature className="size-3.5" aria-hidden />
|
||||
<span className="font-semibold">
|
||||
{automationMode === 'sequential_auto'
|
||||
? `Automating sequentially - ${signedCount} of ${totalCount} signed`
|
||||
: `Automating concurrently - ${signedCount} of ${totalCount} signed`}
|
||||
</span>
|
||||
<span className="text-emerald-800">
|
||||
{automationMode === 'sequential_auto'
|
||||
? 'Next signer auto-fires when the current one completes.'
|
||||
: 'All signers invited; waiting on completion.'}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 border-emerald-300 bg-white text-emerald-900 [&_svg]:size-3"
|
||||
disabled={pauseAutomationMutation.isPending}
|
||||
onClick={() => pauseAutomationMutation.mutate()}
|
||||
title="Stop the cascade and revert to manual control. Doesn't undo invitations already sent."
|
||||
>
|
||||
{pauseAutomationMutation.isPending ? (
|
||||
<Loader2 className="animate-spin" aria-hidden />
|
||||
) : null}
|
||||
Pause
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 rounded-lg border bg-background p-4">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Signing progress
|
||||
|
||||
Reference in New Issue
Block a user