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