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:
2026-05-26 21:29:05 +02:00
parent 210748076f
commit fe5f98db23
6 changed files with 666 additions and 0 deletions

View File

@@ -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