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