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:
55
src/app/api/v1/documents/[id]/automate/route.ts
Normal file
55
src/app/api/v1/documents/[id]/automate/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
enableSigningAutomation,
|
||||
disableSigningAutomation,
|
||||
} from '@/lib/services/signing-automation.service';
|
||||
|
||||
/**
|
||||
* POST `/api/v1/documents/[id]/automate`
|
||||
*
|
||||
* Enable Automate Signing on the document. The service resolves the
|
||||
* mode (sequential_auto vs concurrent_auto) from the envelope's
|
||||
* signing order and fires the appropriate invitation set. Returns
|
||||
* the chosen mode + how many invitations were dispatched.
|
||||
*
|
||||
* DELETE on the same path reverts to manual mode.
|
||||
*
|
||||
* Permission: documents.send_for_signing — same as the per-signer
|
||||
* Send invitation route since the side effects are equivalent.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('documents', 'send_for_signing', async (req, ctx, params) => {
|
||||
try {
|
||||
const documentId = params.id!;
|
||||
const result = await enableSigningAutomation(documentId, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: req.headers.get('x-forwarded-for') ?? '0.0.0.0',
|
||||
userAgent: req.headers.get('user-agent') ?? '',
|
||||
});
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('documents', 'send_for_signing', async (req, ctx, params) => {
|
||||
try {
|
||||
const documentId = params.id!;
|
||||
await disableSigningAutomation(documentId, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: req.headers.get('x-forwarded-for') ?? '0.0.0.0',
|
||||
userAgent: req.headers.get('user-agent') ?? '',
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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
|
||||
|
||||
24
src/lib/db/migrations/0088_documents_automation_mode.sql
Normal file
24
src/lib/db/migrations/0088_documents_automation_mode.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Phase 3: Automate Signing feature.
|
||||
--
|
||||
-- `automation_mode` controls whether the CRM cascades signing
|
||||
-- invitations automatically as signers complete vs. leaving the rep
|
||||
-- to fire each invitation manually.
|
||||
--
|
||||
-- Values:
|
||||
-- manual - default; rep fires each signer's invitation by hand.
|
||||
-- sequential_auto - first signer invited; webhook handler fires the
|
||||
-- next-in-order signer on each recipient_signed event.
|
||||
-- concurrent_auto - all signers invited at once; only the broadcast
|
||||
-- completion email fires from the webhook.
|
||||
--
|
||||
-- Default 'manual' preserves existing behaviour. Backfill not needed.
|
||||
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN automation_mode text NOT NULL DEFAULT 'manual';
|
||||
|
||||
-- Defensive constraint: only the three known enum values are
|
||||
-- acceptable. Service layer also validates, but having it at the DB
|
||||
-- level prevents direct-SQL bypass from creating an invalid state.
|
||||
ALTER TABLE documents
|
||||
ADD CONSTRAINT documents_automation_mode_check
|
||||
CHECK (automation_mode IN ('manual', 'sequential_auto', 'concurrent_auto'));
|
||||
@@ -125,6 +125,14 @@ export const documents = pgTable(
|
||||
* the empty string when null. Plain-text (XSS-escaped by the
|
||||
* email renderer); not Markdown. */
|
||||
invitationMessage: text('invitation_message'),
|
||||
/** Automate Signing mode. `manual` (default) = rep clicks each
|
||||
* signer's invitation by hand. `sequential_auto` = first signer
|
||||
* invited; webhook auto-fires the next-in-order on each
|
||||
* recipient_signed event. `concurrent_auto` = all signers invited
|
||||
* in one bulk dispatch; webhook only fires the broadcast on
|
||||
* completion. CHECK constraint at the DB layer enforces the
|
||||
* three-value enum. */
|
||||
automationMode: text('automation_mode').notNull().default('manual'),
|
||||
remindersDisabled: boolean('reminders_disabled').notNull().default(false),
|
||||
reminderCadenceOverride: integer('reminder_cadence_override'),
|
||||
// Phase 3 - per-document field overrides. When NULL, the canonical
|
||||
|
||||
304
src/lib/services/signing-automation.service.ts
Normal file
304
src/lib/services/signing-automation.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
170
tests/unit/services/signing-automation.test.ts
Normal file
170
tests/unit/services/signing-automation.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* Signing-automation orchestrator validation tests. Heavy integration
|
||||
* paths (actual Documenso round-trip, real signer dispatch) are
|
||||
* exercised by realapi Playwright specs. These tests pin the input-
|
||||
* validation contract: refuses to enable on completed / cancelled /
|
||||
* already-automated documents, refuses when documensoId is null,
|
||||
* refuses with fewer than two signers.
|
||||
*/
|
||||
|
||||
// vi.hoisted lets us declare mock helpers that the hoisted vi.mock
|
||||
// factory can reference safely — without hoisted, the factory sees
|
||||
// undefined for any top-level const.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
docFindFirst: vi.fn(),
|
||||
signersFindFirst: vi.fn(),
|
||||
updateCall: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn() })) })),
|
||||
selectCall: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({ orderBy: vi.fn(() => Promise.resolve([])) })),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
db: {
|
||||
query: {
|
||||
documents: { findFirst: mocks.docFindFirst },
|
||||
documentSigners: { findFirst: mocks.signersFindFirst },
|
||||
ports: { findFirst: vi.fn().mockResolvedValue({ id: 'port-1', name: 'Test Port' }) },
|
||||
},
|
||||
update: mocks.updateCall,
|
||||
select: mocks.selectCall,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/port-config', () => ({
|
||||
getPortDocumensoConfig: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ signingOrder: 'SEQUENTIAL', developerName: 'Dev' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/documenso-client', () => ({
|
||||
getDocument: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
id: 'env-1',
|
||||
status: 'PENDING',
|
||||
recipients: [],
|
||||
signingOrder: 'SEQUENTIAL',
|
||||
}),
|
||||
distributeEnvelopeV2: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
id: 'env-1',
|
||||
status: 'PENDING',
|
||||
recipients: [],
|
||||
signingOrder: 'SEQUENTIAL',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/document-signing-emails.service', () => ({
|
||||
sendSigningInvitation: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/documenso-signers', () => ({
|
||||
DOC_TYPE_LABEL: { eoi: 'EOI', contract: 'Contract', reservation_agreement: 'Reservation' },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/audit', () => ({
|
||||
createAuditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
enableSigningAutomation,
|
||||
disableSigningAutomation,
|
||||
} from '@/lib/services/signing-automation.service';
|
||||
|
||||
const meta = {
|
||||
userId: 'user-1',
|
||||
portId: 'port-1',
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'test',
|
||||
};
|
||||
|
||||
describe('enableSigningAutomation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('throws NotFound when document does not exist', async () => {
|
||||
mocks.docFindFirst.mockResolvedValue(null);
|
||||
await expect(enableSigningAutomation('doc-1', 'port-1', meta)).rejects.toThrow(/document/i);
|
||||
});
|
||||
|
||||
it('throws Conflict when document has no Documenso envelope', async () => {
|
||||
mocks.docFindFirst.mockResolvedValue({
|
||||
id: 'doc-1',
|
||||
documensoId: null,
|
||||
status: 'draft',
|
||||
automationMode: 'manual',
|
||||
documentType: 'eoi',
|
||||
invitationMessage: null,
|
||||
});
|
||||
await expect(enableSigningAutomation('doc-1', 'port-1', meta)).rejects.toThrow(
|
||||
/no Documenso envelope/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws Conflict when document is completed', async () => {
|
||||
mocks.docFindFirst.mockResolvedValue({
|
||||
id: 'doc-1',
|
||||
documensoId: 'env-1',
|
||||
status: 'completed',
|
||||
automationMode: 'manual',
|
||||
documentType: 'eoi',
|
||||
invitationMessage: null,
|
||||
});
|
||||
await expect(enableSigningAutomation('doc-1', 'port-1', meta)).rejects.toThrow(/completed/);
|
||||
});
|
||||
|
||||
it('throws Conflict when automation is already enabled', async () => {
|
||||
mocks.docFindFirst.mockResolvedValue({
|
||||
id: 'doc-1',
|
||||
documensoId: 'env-1',
|
||||
status: 'sent',
|
||||
automationMode: 'sequential_auto',
|
||||
documentType: 'eoi',
|
||||
invitationMessage: null,
|
||||
});
|
||||
await expect(enableSigningAutomation('doc-1', 'port-1', meta)).rejects.toThrow(
|
||||
/already enabled/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws Conflict when fewer than two signers exist', async () => {
|
||||
mocks.docFindFirst.mockResolvedValue({
|
||||
id: 'doc-1',
|
||||
documensoId: 'env-1',
|
||||
status: 'sent',
|
||||
automationMode: 'manual',
|
||||
documentType: 'eoi',
|
||||
invitationMessage: null,
|
||||
});
|
||||
// mockSelect.from.where.orderBy resolves to [] -> ensureSigningUrls
|
||||
// returns 0 signers -> service throws "at least two".
|
||||
await expect(enableSigningAutomation('doc-1', 'port-1', meta)).rejects.toThrow(
|
||||
/at least two signers/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableSigningAutomation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('throws NotFound when document does not exist', async () => {
|
||||
mocks.docFindFirst.mockResolvedValue(null);
|
||||
await expect(disableSigningAutomation('doc-1', 'port-1', meta)).rejects.toThrow(/document/i);
|
||||
});
|
||||
|
||||
it('is a no-op when document is already manual (idempotent)', async () => {
|
||||
mocks.docFindFirst.mockResolvedValue({ id: 'doc-1', automationMode: 'manual' });
|
||||
await expect(disableSigningAutomation('doc-1', 'port-1', meta)).resolves.toBeUndefined();
|
||||
// No update should fire when already manual.
|
||||
expect(mocks.updateCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user