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

@@ -0,0 +1,304 @@
/**
* Automate Signing orchestration. Wraps the per-signer invitation
* fire so the rep can flip a document from manual → auto with one
* click. Once enabled, the existing
* `sendCascadingInviteForNextSigner` path in documents.service.ts
* handles the rest of the cascade (it fires regardless of
* automation_mode), and `handleDocumentCompleted` handles the
* completion broadcast (signed PDF to every recipient).
*
* Modes:
* - manual Default. Rep fires each signer's invitation
* individually. Existing webhook cascade still
* auto-fires subsequent signers when each
* completes.
* - sequential_auto First signer invited on enable; webhook cascade
* handles the rest. Used when the envelope is
* SEQUENTIAL.
* - concurrent_auto All signers invited in parallel on enable.
* Used when the envelope is PARALLEL.
*
* Decisions locked 2026-05-26:
* - Mid-flow enable picks up from next-in-order signer (find
* invitedAt=null, fire from there).
* - Completion broadcast goes to ALL recipients (signers + approvers
* + CCs). Already wired in handleDocumentCompleted.
* - Single combined mode (no partial-automate option).
* - Manual override buttons stay visible during automation with
* "Auto-firing soon" tooltip.
*/
import { and, asc, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documents, documentSigners } from '@/lib/db/schema/documents';
import { ports } from '@/lib/db/schema/ports';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import {
getDocument as getDocumensoDoc,
distributeEnvelopeV2,
} from '@/lib/services/documenso-client';
import {
sendSigningInvitation,
type SignerRole,
} from '@/lib/services/document-signing-emails.service';
import { DOC_TYPE_LABEL } from '@/lib/services/documenso-signers';
import { logger } from '@/lib/logger';
export type AutomationMode = 'manual' | 'sequential_auto' | 'concurrent_auto';
interface SignerRow {
id: string;
signerName: string;
signerEmail: string;
signerRole: string;
signingOrder: number;
signingUrl: string | null;
invitedAt: Date | null;
status: string;
}
/**
* Fire a Documenso signing invitation to one signer + stamp invitedAt.
* Mirrors what the manual POST /api/v1/documents/:id/send-invitation
* route does so the email a rep receives is identical regardless of
* whether the rep clicked Send or the orchestrator auto-fired.
*/
async function dispatchInvitationToSigner(args: {
portId: string;
portName: string;
signer: SignerRow;
documentLabel: string;
customMessage: string | null;
senderName: string | null;
}): Promise<void> {
if (!args.signer.signingUrl) {
throw new ValidationError(
`Signer ${args.signer.signerEmail} has no signing URL — cannot dispatch invitation`,
);
}
await sendSigningInvitation({
portId: args.portId,
portName: args.portName,
recipient: { name: args.signer.signerName, email: args.signer.signerEmail },
documensoSigningUrl: args.signer.signingUrl,
documentLabel: args.documentLabel as never,
signerRole: (args.signer.signerRole as SignerRole) ?? 'client',
senderName: args.senderName,
customMessage: args.customMessage,
});
await db
.update(documentSigners)
.set({ invitedAt: new Date() })
.where(eq(documentSigners.id, args.signer.id));
}
/**
* Recover signing URLs for the document's signers when any are
* missing. v2 envelopes don't populate signingUrls until distribution
* lands; fetch the envelope, persist URLs onto local signer rows, and
* return the refreshed signer list. Idempotent.
*/
async function ensureSigningUrls(
documentId: string,
documensoId: string,
portId: string,
): Promise<SignerRow[]> {
const rows = await db
.select()
.from(documentSigners)
.where(eq(documentSigners.documentId, documentId))
.orderBy(asc(documentSigners.signingOrder));
if (rows.every((r) => r.signingUrl)) return rows as SignerRow[];
let fetched: Awaited<ReturnType<typeof getDocumensoDoc>> | null = null;
try {
fetched = await getDocumensoDoc(documensoId, portId);
} catch {
/* fall through to distribute */
}
const haveUrlsFromGet = fetched?.recipients.some((r) => r.signingUrl);
if (!haveUrlsFromGet) {
try {
fetched = await distributeEnvelopeV2(documensoId, portId);
} catch {
try {
fetched = await getDocumensoDoc(documensoId, portId);
} catch {
/* give up */
}
}
}
if (!fetched) return rows as SignerRow[];
for (const r of fetched.recipients) {
if (!r.email || !r.signingUrl) continue;
await db
.update(documentSigners)
.set({ signingUrl: r.signingUrl })
.where(
and(eq(documentSigners.documentId, documentId), eq(documentSigners.signerEmail, r.email)),
);
}
const refreshed = await db
.select()
.from(documentSigners)
.where(eq(documentSigners.documentId, documentId))
.orderBy(asc(documentSigners.signingOrder));
return refreshed as SignerRow[];
}
/**
* Enable Automate Signing on a document. Resolves the mode from the
* envelope's signing order and fires the appropriate invitation set:
* first uninvited signer for sequential, all uninvited signers for
* concurrent.
*/
export async function enableSigningAutomation(
documentId: string,
portId: string,
meta: AuditMeta,
): Promise<{ mode: AutomationMode; invitedCount: number }> {
const doc = await db.query.documents.findFirst({
where: and(eq(documents.id, documentId), eq(documents.portId, portId)),
});
if (!doc) throw new NotFoundError('document');
if (!doc.documensoId) {
throw new ConflictError(
'Document has no Documenso envelope — cannot automate. Generate / upload + send first.',
);
}
if (!['draft', 'sent', 'partially_signed'].includes(doc.status)) {
throw new ConflictError(
`Document is ${doc.status} — automation only applies to in-flight envelopes.`,
);
}
if (doc.automationMode === 'sequential_auto' || doc.automationMode === 'concurrent_auto') {
throw new ConflictError(`Automation already enabled (mode=${doc.automationMode}).`);
}
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
if (!port) throw new NotFoundError('port');
const docCfg = await getPortDocumensoConfig(portId);
// Determine mode from the envelope's actual signing order. Falls
// back to the per-port `documenso_signing_order` setting when the
// remote response doesn't echo signingOrder; falls back to PARALLEL
// when neither is set (Documenso's own default).
const remote = await getDocumensoDoc(doc.documensoId, portId);
const remoteOrder = (
remote as { signingOrder?: string } & typeof remote
).signingOrder?.toString();
const isSequential = remoteOrder === 'SEQUENTIAL' || docCfg.signingOrder === 'SEQUENTIAL';
const mode: AutomationMode = isSequential ? 'sequential_auto' : 'concurrent_auto';
const signers = await ensureSigningUrls(documentId, doc.documensoId, portId);
if (signers.length < 2) {
throw new ConflictError('Automation requires at least two signers on the envelope.');
}
// Persist the mode FIRST so the audit trail captures the rep's
// intent even if the invitation dispatch fails part-way through.
await db
.update(documents)
.set({ automationMode: mode, updatedAt: new Date() })
.where(eq(documents.id, documentId));
const docLabel = DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest';
let invitedCount = 0;
if (mode === 'sequential_auto') {
// Find the next-in-order signer with invitedAt=NULL and fire just
// that one. The webhook cascade in documents.service.ts handles
// the rest as each completes (mode-independent).
const next = signers.find(
(s) => s.invitedAt === null && s.status === 'pending' && s.signingUrl,
);
if (next) {
await dispatchInvitationToSigner({
portId,
portName: port.name,
signer: next,
documentLabel: docLabel,
customMessage: doc.invitationMessage,
senderName: docCfg.developerName ?? null,
});
invitedCount = 1;
}
} else {
// concurrent_auto: fire all uninvited signers in parallel.
const candidates = signers.filter(
(s) => s.invitedAt === null && s.status === 'pending' && s.signingUrl,
);
const results = await Promise.allSettled(
candidates.map((s) =>
dispatchInvitationToSigner({
portId,
portName: port.name,
signer: s,
documentLabel: docLabel,
customMessage: doc.invitationMessage,
senderName: docCfg.developerName ?? null,
}),
),
);
invitedCount = results.filter((r) => r.status === 'fulfilled').length;
const failures = results.filter((r) => r.status === 'rejected');
if (failures.length > 0) {
logger.warn(
{ documentId, failureCount: failures.length, total: candidates.length },
'Some invitations failed during concurrent_auto enable — partial state',
);
}
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'document',
entityId: documentId,
newValue: { automationMode: mode, invitedCount },
metadata: { type: 'signing_automation_enabled' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return { mode, invitedCount };
}
/**
* Disable Automate Signing — reverts to manual. Doesn't undo any
* invitations already sent.
*/
export async function disableSigningAutomation(
documentId: string,
portId: string,
meta: AuditMeta,
): Promise<void> {
const doc = await db.query.documents.findFirst({
where: and(eq(documents.id, documentId), eq(documents.portId, portId)),
columns: { id: true, automationMode: true },
});
if (!doc) throw new NotFoundError('document');
if (doc.automationMode === 'manual') return;
await db
.update(documents)
.set({ automationMode: 'manual', updatedAt: new Date() })
.where(eq(documents.id, documentId));
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'document',
entityId: documentId,
oldValue: { automationMode: doc.automationMode },
newValue: { automationMode: 'manual' },
metadata: { type: 'signing_automation_disabled' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}