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>
96 lines
3.2 KiB
TypeScript
96 lines
3.2 KiB
TypeScript
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 });
|
|
});
|
|
});
|