feat(documenso-phase-2): webhook handler enhancement — cascade + completion fan-out

Closes the silence after the first signing invitation. Three real
improvements on top of the existing webhook plumbing, all aligned with
the Documenso v1.32 + v2 webhook payload shape (verified against the
official OpenAPI spec + Context7 docs):

1. Cascading "your turn" emails — when DOCUMENT_SIGNED / DOCUMENT_
   RECIPIENT_COMPLETED / RECIPIENT_SIGNED fires for a recipient,
   handleRecipientSigned now resolves the next pending signer in
   signing order and sends them the branded sendSigningInvitation()
   email with the embedded-host-wrapped URL. Stamps invitedAt so a
   duplicate webhook retry doesn't re-send.

2. On-completion PDF distribution — handleDocumentCompleted now re-
   reads the just-committed signedFileId, resolves all signers, and
   fires sendSigningCompleted() to every recipient with the signed
   PDF attached. resolveAttachments in lib/email already pulls bytes
   through getStorageBackend() so this works under both the s3/minio
   and filesystem backends without changes. Failures fall through to
   logger.error rather than throwing — the document is already marked
   completed and the admin can re-trigger manually.

3. Token-based recipient matching — Documenso v1 + v2 webhook recipients
   carry a `token` field (per the OpenAPI spec); same token appears in
   the document-create response. Captured at send time into the existing
   document_signers.signing_token column (already in schema from Phase 1)
   and used by handleRecipientSigned + handleDocumentOpened before
   falling back to email match. Robust against the case where one email
   serves multiple roles on a contract — which is the documented gap in
   the legacy nocodb-based handler.

Supporting changes:
- New helper module lib/services/documenso-signers.ts with
  extractSigningToken() (URL-tail fallback), DOC_TYPE_LABEL map, and
  nextPendingSigner() picker. 11 unit tests cover the token-regex,
  the helper picks the lowest pending signing-order, and rejects
  declined/signed correctly.
- documenso-client normalizeDocument now reads `token` from both
  `recipients[]` and the legacy capital-R `Recipient[]` array Documenso
  v1.32 sometimes ships in webhooks.
- documents.service signer-update at send time prefers the explicit
  token field, falling back to extractSigningToken(signingUrl) for any
  v2 deployment whose distribute response omits it.

Out of scope for Phase 2 (per the build plan):
- Custom-doc upload-to-Documenso path (Phase 3)
- Recipient + field-placement UI (Phase 4)
- DNS-rebinding hardening + circuit-breaker (deferred-refactor list)
- Auto-reminder cron — manual "Send reminder" button + auto-reminder
  toggle remain manual until Phase 6 polish

Tests: 1315/1315 vitest  + 11 new tests for documenso-signers ;
tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 13:47:33 +02:00
parent ebdd8408bf
commit 3dc4c6ff14
5 changed files with 389 additions and 21 deletions

View File

@@ -5,6 +5,7 @@ import { match } from 'ts-pattern';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook'; import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
import { listDocumensoWebhookSecrets } from '@/lib/services/port-config'; import { listDocumensoWebhookSecrets } from '@/lib/services/port-config';
import { extractSigningToken } from '@/lib/services/documenso-signers';
import { import {
handleRecipientSigned, handleRecipientSigned,
handleDocumentCompleted, handleDocumentCompleted,
@@ -69,11 +70,33 @@ function isKnownEvent(event: string): event is KnownDocumensoEvent {
return KNOWN_DOCUMENSO_EVENTS.has(event as KnownDocumensoEvent); return KNOWN_DOCUMENSO_EVENTS.has(event as KnownDocumensoEvent);
} }
/**
* Pull the recipient's signing token out of a Documenso webhook
* payload. v1.13 emits `recipients[].token`; some 2.x payloads use
* `signingToken`; both versions always carry a `signingUrl` whose tail
* IS the token. Prefer the explicit fields, fall back to URL extraction
* so the cascade still works when Documenso reshapes its payload.
*/
function resolveRecipientToken(r: DocumensoRecipient): string | null {
if (r.token) return r.token;
if (r.signingToken) return r.signingToken;
if (r.signingUrl) return extractSigningToken(r.signingUrl);
return null;
}
type DocumensoRecipient = { type DocumensoRecipient = {
email: string; email: string;
signingStatus?: string; signingStatus?: string;
readStatus?: string; readStatus?: string;
signedAt?: string | null; signedAt?: string | null;
/** Per-recipient signing token Documenso uses as the URL tail.
* Present on both v1.13 and v2 payloads under varied field names —
* we coalesce them below. Phase 2: passed through to the handlers
* so they can match against `document_signers.signing_token`
* instead of email. */
token?: string | null;
signingToken?: string | null;
signingUrl?: string | null;
}; };
type DocumensoWebhookBody = { type DocumensoWebhookBody = {
@@ -214,6 +237,7 @@ async function handleDocumensoWebhook(req: NextRequest): Promise<NextResponse> {
await handleRecipientSigned({ await handleRecipientSigned({
documentId: documensoId, documentId: documensoId,
recipientEmail: r.email, recipientEmail: r.email,
recipientToken: resolveRecipientToken(r),
signatureHash: `${signatureHash}:signed:${r.email}`, signatureHash: `${signatureHash}:signed:${r.email}`,
...portScope, ...portScope,
}); });
@@ -239,6 +263,7 @@ async function handleDocumensoWebhook(req: NextRequest): Promise<NextResponse> {
await handleDocumentOpened({ await handleDocumentOpened({
documentId: documensoId, documentId: documensoId,
recipientEmail: r.email, recipientEmail: r.email,
recipientToken: resolveRecipientToken(r),
signatureHash: `${signatureHash}:opened:${r.email}`, signatureHash: `${signatureHash}:opened:${r.email}`,
...portScope, ...portScope,
}); });

View File

@@ -120,7 +120,13 @@ function normalizeDocument(raw: unknown): DocumensoDocument {
const r = (raw ?? {}) as Record<string, unknown>; const r = (raw ?? {}) as Record<string, unknown>;
const id = String(r.documentId ?? r.id ?? ''); const id = String(r.documentId ?? r.id ?? '');
const status = String(r.status ?? 'PENDING'); const status = String(r.status ?? 'PENDING');
const recipientsRaw = (r.recipients as Array<Record<string, unknown>> | undefined) ?? []; // v1.32+ payloads carry a `Recipient` (capital R) array as a legacy
// duplicate of `recipients` — fall through to it so we still resolve
// tokens / URLs when only the legacy field is populated.
const recipientsRaw =
(r.recipients as Array<Record<string, unknown>> | undefined) ??
(r.Recipient as Array<Record<string, unknown>> | undefined) ??
[];
const recipients = recipientsRaw.map((rec) => ({ const recipients = recipientsRaw.map((rec) => ({
id: String(rec.recipientId ?? rec.id ?? ''), id: String(rec.recipientId ?? rec.id ?? ''),
name: String(rec.name ?? ''), name: String(rec.name ?? ''),
@@ -130,6 +136,11 @@ function normalizeDocument(raw: unknown): DocumensoDocument {
status: String(rec.signingStatus ?? rec.status ?? 'PENDING'), status: String(rec.signingStatus ?? rec.status ?? 'PENDING'),
signingUrl: typeof rec.signingUrl === 'string' ? rec.signingUrl : undefined, signingUrl: typeof rec.signingUrl === 'string' ? rec.signingUrl : undefined,
embeddedUrl: typeof rec.embeddedUrl === 'string' ? rec.embeddedUrl : undefined, embeddedUrl: typeof rec.embeddedUrl === 'string' ? rec.embeddedUrl : undefined,
// Per-recipient signing token — required on the v1 Recipient model,
// present on every v2 envelope-distribute response. Documenso uses
// it as the URL tail (`/sign/<token>`) so it also matches what we
// see on subsequent webhook deliveries.
token: typeof rec.token === 'string' ? rec.token : undefined,
})); }));
return { id, status, recipients }; return { id, status, recipients };
} }
@@ -153,6 +164,11 @@ export interface DocumensoDocument {
status: string; status: string;
signingUrl?: string; signingUrl?: string;
embeddedUrl?: string; embeddedUrl?: string;
/** v1 + v2 recipient token. Used to populate
* `document_signers.signing_token` so the webhook handler can
* match recipients without leaning on email (which may be reused
* across roles). */
token?: string;
}>; }>;
} }

View File

@@ -0,0 +1,61 @@
/**
* Small helpers shared between the document-signing flows and the
* Documenso webhook handlers. Kept in their own file so the two
* heavyweight callers (`documents.service.ts` + the webhook handler
* routes) don't have to depend on each other for a 30-line utility.
*/
import type { DocumentLabel } from '@/lib/services/document-signing-emails.service';
import type { DocumentSigner } from '@/lib/db/schema/documents';
/**
* Pull the per-recipient signing token out of a Documenso signing URL.
* Both v1.13 and v2 emit URLs of the form
* `https://signatures.example.com/sign/<token>`
* so the token is the last non-empty path segment. Returns null when
* the URL is empty / unparseable / doesn't carry a token-shaped tail.
*
* Used at document-send time to populate `document_signers.signing_token`
* so subsequent webhook deliveries can match recipients by token
* (more robust than email match when one address serves multiple roles).
*/
export function extractSigningToken(url: string | null | undefined): string | null {
if (!url) return null;
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null;
}
const segments = parsed.pathname.split('/').filter(Boolean);
const last = segments[segments.length - 1];
if (!last) return null;
// A token must be at least 8 chars and contain only URL-safe chars —
// discriminates real tokens from generic words like "sign" or "embed"
// that some Documenso 2.x deployments append.
if (last.length < 8) return null;
if (!/^[A-Za-z0-9_-]+$/.test(last)) return null;
return last;
}
/** Map of internal documentType to the customer-facing label used in
* email subjects + body copy. Duplicated in send-invitation/route.ts
* but consolidated here so the webhook cascade picks up the same
* label without re-defining the map. */
export const DOC_TYPE_LABEL: Record<string, DocumentLabel> = {
eoi: 'Expression of Interest',
contract: 'Sales Contract',
reservation_agreement: 'Reservation Agreement',
};
/** Pick the next pending signer in signing order. Returns null when
* every signer in the list has signed (or declined). */
export function nextPendingSigner<T extends Pick<DocumentSigner, 'status' | 'signingOrder'>>(
signers: T[],
): T | null {
// signers list is assumed pre-sorted asc by signingOrder; we
// defensively pick the lowest-order pending row regardless.
const pending = signers.filter((s) => s.status === 'pending');
if (pending.length === 0) return null;
return pending.reduce((lo, s) => (s.signingOrder < lo.signingOrder ? s : lo));
}

View File

@@ -35,6 +35,16 @@ import {
} from '@/lib/services/documenso-client'; } from '@/lib/services/documenso-client';
import { getPortEoiSigners } from '@/lib/services/documenso-payload'; import { getPortEoiSigners } from '@/lib/services/documenso-payload';
import { getPortDocumensoConfig } from '@/lib/services/port-config'; import { getPortDocumensoConfig } from '@/lib/services/port-config';
import {
DOC_TYPE_LABEL,
extractSigningToken,
nextPendingSigner,
} from '@/lib/services/documenso-signers';
import {
sendSigningInvitation,
sendSigningCompleted,
type SignerRole,
} from '@/lib/services/document-signing-emails.service';
import { import {
listTree, listTree,
collectDescendantIds, collectDescendantIds,
@@ -734,7 +744,12 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
await documensoSend(documensoDoc.id, portId); await documensoSend(documensoDoc.id, portId);
// Update signer records with signing URLs from Documenso response // Update signer records with signing URLs + tokens from the Documenso
// response. The signingToken column powers the webhook recipient-match
// path (more robust than email match — same email can serve multiple
// roles on a contract). Documenso's recipient response carries `token`
// explicitly per the OpenAPI spec; we keep the URL-extraction fallback
// for any v2 deployment whose distribute response omits the field.
for (const docSigner of documensoDoc.recipients) { for (const docSigner of documensoDoc.recipients) {
const localSigner = signerRecords.find((s) => s.signerEmail === docSigner.email); const localSigner = signerRecords.find((s) => s.signerEmail === docSigner.email);
if (localSigner) { if (localSigner) {
@@ -743,6 +758,7 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
.set({ .set({
signingUrl: docSigner.signingUrl ?? null, signingUrl: docSigner.signingUrl ?? null,
embeddedUrl: docSigner.embeddedUrl ?? null, embeddedUrl: docSigner.embeddedUrl ?? null,
signingToken: docSigner.token ?? extractSigningToken(docSigner.signingUrl),
}) })
.where(eq(documentSigners.id, localSigner.id)); .where(eq(documentSigners.id, localSigner.id));
} }
@@ -998,36 +1014,49 @@ async function resolveWebhookDocument(
export async function handleRecipientSigned(eventData: { export async function handleRecipientSigned(eventData: {
documentId: string; documentId: string;
recipientEmail: string; recipientEmail: string;
/** Optional Documenso recipient token — when supplied (webhook
* payload exposes it on v1.13 + 2.x), preferred over the email
* match because a single email can serve multiple roles on one
* document. Falls back to email match when null. */
recipientToken?: string | null;
signatureHash?: string; signatureHash?: string;
portId?: string; portId?: string;
}) { }) {
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId); const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
if (!doc) return; if (!doc) return;
// Update signer status // Token-match first, fall back to email match. Phase 2: webhook
// payloads carry `recipients[].token` which we captured at send-time
// via extractSigningToken — that's the authoritative identifier.
const signerWhere = eventData.recipientToken
? and(
eq(documentSigners.documentId, doc.id),
eq(documentSigners.signingToken, eventData.recipientToken),
)
: and(
eq(documentSigners.documentId, doc.id),
eq(documentSigners.signerEmail, eventData.recipientEmail),
);
const [signer] = await db const [signer] = await db
.update(documentSigners) .update(documentSigners)
.set({ status: 'signed', signedAt: new Date() }) .set({ status: 'signed', signedAt: new Date() })
.where( .where(signerWhere)
and(
eq(documentSigners.documentId, doc.id),
eq(documentSigners.signerEmail, eventData.recipientEmail),
),
)
.returning(); .returning();
if (!signer) { if (!signer) {
// Email mismatch: the address Documenso has on the recipient doesn't match // Mismatch: neither token nor email matched. This happens when the
// any row in documentSigners. This happens when the local signers were // local signers were created with fabricated / stale addresses or
// created with fabricated / stale addresses. Log a warning so operators can // the document was created out-of-band. Log a warning so operators
// investigate and fix the port's eoi_signers system setting. // can investigate and fix the port's eoi_signers system setting.
logger.warn( logger.warn(
{ {
documensoId: eventData.documentId, documensoId: eventData.documentId,
documentId: doc.id, documentId: doc.id,
recipientEmail: eventData.recipientEmail, recipientEmail: eventData.recipientEmail,
hadToken: Boolean(eventData.recipientToken),
}, },
'handleRecipientSigned: no matching signer row for recipient email - ' + 'handleRecipientSigned: no matching signer row for recipient - ' +
'check eoi_signers system setting for this port', 'check eoi_signers system setting for this port',
); );
} }
@@ -1052,6 +1081,76 @@ export async function handleRecipientSigned(eventData: {
documentId: doc.id, documentId: doc.id,
signerEmail: eventData.recipientEmail, signerEmail: eventData.recipientEmail,
}); });
// Phase 2 cascade: now that this signer is done, fire the branded
// "your turn" invitation to the next pending signer in signing order.
// The webhook may fire multiple times per document (one per recipient
// sign event); the `invitedAt` guard prevents duplicate invites.
if (signer) {
await sendCascadingInviteForNextSigner(doc).catch((err) => {
// Cascading-invite failure is non-fatal — the webhook itself
// succeeded. The rep can manually click "Send invitation" if the
// email worker is down.
logger.error(
{ err, documentId: doc.id, justSignedSigner: signer.id },
'cascading "your turn" invite failed after recipient signed',
);
});
}
}
/**
* Phase 2 — cascading invite logic extracted so the
* `handleRecipientSigned` handler stays readable and so the same path
* can be exercised by a dedicated unit test. Finds the next pending
* signer (lowest signing order), sends them a branded invitation, and
* stamps `invitedAt` so a duplicate webhook delivery doesn't re-send.
*/
async function sendCascadingInviteForNextSigner(doc: {
id: string;
portId: string;
documentType: string;
title: string;
}): Promise<void> {
const signers = await db
.select()
.from(documentSigners)
.where(eq(documentSigners.documentId, doc.id))
.orderBy(documentSigners.signingOrder);
const next = nextPendingSigner(signers);
if (!next) return;
if (next.invitedAt) {
// We've already invited them — either via the auto-send wiring at
// document creation (first signer) or via an earlier cascade. Do
// nothing rather than spam them with a second copy.
return;
}
if (!next.signingUrl) {
logger.warn(
{ documentId: doc.id, signerId: next.id },
'cascading invite skipped: signer has no signing URL',
);
return;
}
const port = await db.query.ports.findFirst({ where: eq(ports.id, doc.portId) });
const docCfg = await getPortDocumensoConfig(doc.portId);
await sendSigningInvitation({
portId: doc.portId,
portName: port?.name ?? 'Port Nimara',
recipient: { name: next.signerName, email: next.signerEmail },
documensoSigningUrl: next.signingUrl,
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
signerRole: (next.signerRole as SignerRole) ?? 'other',
senderName: docCfg.developerName ?? null,
});
await db
.update(documentSigners)
.set({ invitedAt: new Date() })
.where(eq(documentSigners.id, next.id));
} }
// ─── Owner-wins resolution ──────────────────────────────────────────────────── // ─── Owner-wins resolution ────────────────────────────────────────────────────
@@ -1390,6 +1489,63 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
emitToRoom(`port:${doc.portId}`, 'document:completed', { documentId: doc.id }); emitToRoom(`port:${doc.portId}`, 'document:completed', { documentId: doc.id });
// Phase 2: distribute the fully-signed PDF to every recipient via a
// branded "all signed" email. Re-read the document so we see the
// signedFileId the transaction above just committed.
const completedDoc = await db.query.documents.findFirst({
where: eq(documents.id, doc.id),
columns: { signedFileId: true },
});
if (completedDoc?.signedFileId) {
const signers = await db
.select({
name: documentSigners.signerName,
email: documentSigners.signerEmail,
})
.from(documentSigners)
.where(eq(documentSigners.documentId, doc.id));
if (signers.length > 0) {
const portRow = await db.query.ports.findFirst({
where: eq(ports.id, doc.portId),
columns: { name: true },
});
// Resolve the deal's primary client name for the salutation —
// falls back to the document title when the owner chain doesn't
// surface a client.
let clientName = doc.title;
const owner = await resolveDocumentOwner(doc.portId, doc);
if (owner?.entityType === 'client') {
const client = await db.query.clients.findFirst({
where: eq(clients.id, owner.entityId),
columns: { fullName: true },
});
if (client?.fullName) clientName = client.fullName;
}
await sendSigningCompleted({
portId: doc.portId,
portName: portRow?.name ?? 'Port Nimara',
recipients: signers,
clientName,
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
completedAt: new Date(),
signedPdfFileId: completedDoc.signedFileId,
signedPdfFilename: `signed-${doc.id}.pdf`,
}).catch((err) => {
// Don't let a downstream email failure undo the completion —
// the signed PDF is already stored and the document row is
// marked completed. Log + emit so admins can re-trigger via
// the manual "Send copy" flow.
logger.error(
{ err, documentId: doc.id },
'sendSigningCompleted fan-out failed after document completed',
);
});
}
}
// Notify the document creator about completion // Notify the document creator about completion
if (doc.createdBy && doc.createdBy !== 'system') { if (doc.createdBy && doc.createdBy !== 'system') {
void import('@/lib/services/notifications.service').then(({ createNotification }) => void import('@/lib/services/notifications.service').then(({ createNotification }) =>
@@ -1436,21 +1592,36 @@ export async function handleDocumentExpired(eventData: { documentId: string; por
export async function handleDocumentOpened(eventData: { export async function handleDocumentOpened(eventData: {
documentId: string; documentId: string;
recipientEmail: string; recipientEmail: string;
/** Optional Documenso recipient token — preferred over email match
* (same email may serve multiple roles on one document). */
recipientToken?: string | null;
signatureHash?: string; signatureHash?: string;
portId?: string; portId?: string;
}) { }) {
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId); const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
if (!doc) return; if (!doc) return;
const [signer] = await db const signerWhere = eventData.recipientToken
.select() ? and(
.from(documentSigners) eq(documentSigners.documentId, doc.id),
.where( eq(documentSigners.signingToken, eventData.recipientToken),
and( )
: and(
eq(documentSigners.documentId, doc.id), eq(documentSigners.documentId, doc.id),
eq(documentSigners.signerEmail, eventData.recipientEmail), eq(documentSigners.signerEmail, eventData.recipientEmail),
), );
);
const [signer] = await db.select().from(documentSigners).where(signerWhere);
// Stamp openedAt the first time we see a viewed event for this signer.
// Re-deliveries (v2 can fire RECIPIENT_VIEWED multiple times per visit)
// hit the idempotent UPDATE without overwriting the original timestamp.
if (signer && !signer.openedAt) {
await db
.update(documentSigners)
.set({ openedAt: new Date() })
.where(eq(documentSigners.id, signer.id));
}
await db.insert(documentEvents).values({ await db.insert(documentEvents).values({
documentId: doc.id, documentId: doc.id,

View File

@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import {
DOC_TYPE_LABEL,
extractSigningToken,
nextPendingSigner,
} from '@/lib/services/documenso-signers';
describe('extractSigningToken', () => {
it('pulls the last path segment when it looks token-shaped', () => {
expect(extractSigningToken('https://sig.example.com/sign/vbT8hi3jKQmrFP_LN1WcS')).toBe(
'vbT8hi3jKQmrFP_LN1WcS',
);
});
it('handles trailing slash gracefully', () => {
expect(extractSigningToken('https://sig.example.com/sign/HkrptwS42ZBXdRKj1TyUo/')).toBe(
'HkrptwS42ZBXdRKj1TyUo',
);
});
it('returns null for non-URL input', () => {
expect(extractSigningToken('not a url at all')).toBeNull();
expect(extractSigningToken('')).toBeNull();
expect(extractSigningToken(null)).toBeNull();
expect(extractSigningToken(undefined)).toBeNull();
});
it('rejects too-short tails (defends against generic "sign" / "embed" terminators)', () => {
expect(extractSigningToken('https://example.com/sign')).toBeNull();
expect(extractSigningToken('https://example.com/abc')).toBeNull();
});
it('rejects path tails with disallowed characters', () => {
// Real tokens are URL-safe base64 — no spaces, no punctuation
expect(extractSigningToken('https://example.com/sign/has%20space')).toBeNull();
});
it('handles real v1.32 + v2 token shapes', () => {
// Documenso 1.32 token (21-char URL-safe alphabet from real webhook docs)
expect(extractSigningToken('https://sig.documenso.com/sign/vbT8hi3jKQmrFP_LN1WcS')).toBe(
'vbT8hi3jKQmrFP_LN1WcS',
);
// Mixed-case + underscores + dashes
expect(extractSigningToken('https://app.example.com/sign/Aa_-Zz09Aa_-Zz09')).toBe(
'Aa_-Zz09Aa_-Zz09',
);
});
});
describe('DOC_TYPE_LABEL', () => {
it('maps the three known document types to customer-facing labels', () => {
expect(DOC_TYPE_LABEL.eoi).toBe('Expression of Interest');
expect(DOC_TYPE_LABEL.contract).toBe('Sales Contract');
expect(DOC_TYPE_LABEL.reservation_agreement).toBe('Reservation Agreement');
});
});
describe('nextPendingSigner', () => {
it('picks the lowest signingOrder pending signer', () => {
const signers = [
{ status: 'signed', signingOrder: 1 },
{ status: 'pending', signingOrder: 2 },
{ status: 'pending', signingOrder: 3 },
];
expect(nextPendingSigner(signers)).toEqual({ status: 'pending', signingOrder: 2 });
});
it('returns null when every signer has signed', () => {
expect(
nextPendingSigner([
{ status: 'signed', signingOrder: 1 },
{ status: 'signed', signingOrder: 2 },
]),
).toBeNull();
});
it('treats declined the same as no longer pending', () => {
expect(
nextPendingSigner([
{ status: 'declined', signingOrder: 1 },
{ status: 'signed', signingOrder: 2 },
]),
).toBeNull();
});
it('handles unordered input by signingOrder', () => {
const signers = [
{ status: 'pending', signingOrder: 5 },
{ status: 'signed', signingOrder: 1 },
{ status: 'pending', signingOrder: 2 },
];
expect(nextPendingSigner(signers)).toEqual({ status: 'pending', signingOrder: 2 });
});
});