From fe5f98db23a2308c380443fb2c7281a04eee5204 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 26 May 2026 21:29:05 +0200 Subject: [PATCH] feat(automate-signing): one-click invitation kickoff + auto cascade + completion broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../api/v1/documents/[id]/automate/route.ts | 55 ++++ src/components/interests/interest-eoi-tab.tsx | 105 ++++++ .../0088_documents_automation_mode.sql | 24 ++ src/lib/db/schema/documents.ts | 8 + .../services/signing-automation.service.ts | 304 ++++++++++++++++++ .../unit/services/signing-automation.test.ts | 170 ++++++++++ 6 files changed, 666 insertions(+) create mode 100644 src/app/api/v1/documents/[id]/automate/route.ts create mode 100644 src/lib/db/migrations/0088_documents_automation_mode.sql create mode 100644 src/lib/services/signing-automation.service.ts create mode 100644 tests/unit/services/signing-automation.test.ts diff --git a/src/app/api/v1/documents/[id]/automate/route.ts b/src/app/api/v1/documents/[id]/automate/route.ts new file mode 100644 index 00000000..4b20de99 --- /dev/null +++ b/src/app/api/v1/documents/[id]/automate/route.ts @@ -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); + } + }), +); diff --git a/src/components/interests/interest-eoi-tab.tsx b/src/components/interests/interest-eoi-tab.tsx index 936ce7a3..1c4e13e0 100644 --- a/src/components/interests/interest-eoi-tab.tsx +++ b/src/components/interests/interest-eoi-tab.tsx @@ -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 (
)} + {/* 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 && ( + + )} {/* Remind all hides once every signer is signed - no-one to nudge. */} {!effectivelyCompleted && ( + + ) : null} +

Signing progress diff --git a/src/lib/db/migrations/0088_documents_automation_mode.sql b/src/lib/db/migrations/0088_documents_automation_mode.sql new file mode 100644 index 00000000..74ce409a --- /dev/null +++ b/src/lib/db/migrations/0088_documents_automation_mode.sql @@ -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')); diff --git a/src/lib/db/schema/documents.ts b/src/lib/db/schema/documents.ts index 8d9c2deb..c2dd5bec 100644 --- a/src/lib/db/schema/documents.ts +++ b/src/lib/db/schema/documents.ts @@ -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 diff --git a/src/lib/services/signing-automation.service.ts b/src/lib/services/signing-automation.service.ts new file mode 100644 index 00000000..2c1e4583 --- /dev/null +++ b/src/lib/services/signing-automation.service.ts @@ -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 { + 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 { + 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> | 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 { + 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, + }); +} diff --git a/tests/unit/services/signing-automation.test.ts b/tests/unit/services/signing-automation.test.ts new file mode 100644 index 00000000..b50a811b --- /dev/null +++ b/tests/unit/services/signing-automation.test.ts @@ -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(); + }); +});