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,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);
}
}),
);

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

View 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'));

View File

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

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,
});
}

View 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();
});
});