feat(documenso-phase-4): recipient configurator + field placement UI
Phase 4 lands the visual half of the Documenso build — the upload-
for-signing dialog the Contract + Reservation tabs hand off to. Four
files of new code; the existing tab placeholders point at it.
Files added:
- lib/services/document-field-detector.ts — Phase 4c auto-detect
scanner. Uses pdfjs-dist to extract per-page text + positions, then
matches anchor patterns (Signature, Date, Initials, Email, Name,
underscore-runs) and produces percent-coordinate DetectedField
rows. Recipient label inference walks ±100pt of each match for
Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the
PDF is image-only; UI falls back to manual placement without an
error. 6 unit tests pin the matching + coordinate math.
- app/api/v1/documents/auto-detect-fields/route.ts — multipart POST
endpoint that delegates to detectFields(). Permission-gated by
documents.send_for_signing.
- app/api/v1/documents/signing-defaults/route.ts — GET endpoint that
surfaces just the per-port developer + approver display name/email
+ sendMode flag. No secrets exposed; lets the dialog prefill the
recipient configurator without an admin-scoped settings read.
- components/documents/upload-for-signing-dialog.tsx — the Phase 4
UI. Three-step state machine inside a single Dialog:
1. select-file: drop/click PDF picker + title input
2. configure-recipients: client + developer + approver prefilled,
rep can add/remove/reorder + change role (SIGNER/APPROVER/CC)
3. place-fields: react-pdf renders the source PDF; auto-detect
runs in the background on file load and seeds the overlay;
rep places, drags, resizes, deletes, reassigns fields via the
palette + side panel. Native DOM drag (no dnd-kit dependency
added — the coordinate math stays obvious).
Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3
service); success toast reflects port sendMode (auto fires the
invite immediately, manual leaves it for the rep).
Files modified:
- components/interests/interest-contract-tab.tsx + reservation-tab.tsx:
swap the ComingSoonDialog placeholder for the real
UploadForSigningDialog with the matching documentType prop. The
placeholder ComingSoonDialog helper is deleted from both.
- scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp
staged-only tsconfig so side-effect CSS imports (e.g.
react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing
declare-module shim. Without this fix the staged compile reports
TS2882 even though the full tsc --noEmit pass passes.
Design choices noted in code comments:
- Native drag over dnd-kit: the field overlay's percent-based
coordinate math is short enough that adding a drag library adds
complexity without saving lines.
- Auto-detect on file-load (not on demand): runs immediately so the
rep doesn't have to click a second button — empty result drops
back to manual placement silently.
- Per-recipient color swatches indexed by signingOrder.
- Recipient seed via useMemo + user-event handler instead of
useEffect → setRecipients (Wave 3 set-state-in-effect avoidance).
Server-side, Phase 3 plumbing handles the rest: tenant guard, magic-
byte verify, Documenso round-trip with per-port v1/v2 routing,
recipient signingToken capture for Phase 2 webhook cascade, auto-
send when port.sendMode === 'auto'.
Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean.
Deferred polish (Phase 6):
- Per-field metadata side panel for DROPDOWN/RADIO option lists
- Pinch-zoom + zoom-out controls on the field-placement canvas
- Recipient drag-reorder via dnd-kit
- Required toggle per field
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
107
tests/unit/services/document-field-detector.test.ts
Normal file
107
tests/unit/services/document-field-detector.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock pdfjs-dist before importing the service. The detector calls
|
||||
// `import('pdfjs-dist/legacy/build/pdf.mjs')` dynamically; we stub the
|
||||
// module with a fake document whose pages return canned text items so
|
||||
// we can assert the anchor-matching + coordinate-conversion logic
|
||||
// without needing a real PDF.
|
||||
vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
|
||||
getDocument: (_opts: unknown) => ({
|
||||
promise: Promise.resolve({
|
||||
numPages: 1,
|
||||
getPage: async (_n: number) => ({
|
||||
getViewport: ({ scale: _s }: { scale: number }) => ({
|
||||
width: 595, // A4 in pt
|
||||
height: 842,
|
||||
}),
|
||||
getTextContent: async () => ({
|
||||
items: [
|
||||
// Item 0: a signature anchor near the bottom-left
|
||||
{
|
||||
str: 'Signature: ',
|
||||
transform: [1, 0, 0, 1, 50, 100],
|
||||
width: 70,
|
||||
},
|
||||
// Item 1: a date anchor next to it
|
||||
{
|
||||
str: 'Date: ',
|
||||
transform: [1, 0, 0, 1, 250, 100],
|
||||
width: 40,
|
||||
},
|
||||
// Item 2: recipient label nearby
|
||||
{
|
||||
str: 'Buyer',
|
||||
transform: [1, 0, 0, 1, 50, 130],
|
||||
width: 40,
|
||||
},
|
||||
// Item 3: unrelated body text (should not match)
|
||||
{
|
||||
str: 'The parties hereby agree…',
|
||||
transform: [1, 0, 0, 1, 50, 200],
|
||||
width: 200,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { detectFields } from '@/lib/services/document-field-detector';
|
||||
|
||||
describe('detectFields', () => {
|
||||
it('returns matches for known anchors with the right type + page', async () => {
|
||||
const result = await detectFields(Buffer.from('%PDF-1.7'));
|
||||
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||
const sig = result.find((r) => r.type === 'SIGNATURE');
|
||||
const date = result.find((r) => r.type === 'DATE');
|
||||
expect(sig).toBeDefined();
|
||||
expect(date).toBeDefined();
|
||||
expect(sig?.pageNumber).toBe(1);
|
||||
expect(date?.pageNumber).toBe(1);
|
||||
});
|
||||
|
||||
it('infers recipient label from nearby text', async () => {
|
||||
const result = await detectFields(Buffer.from('%PDF-1.7'));
|
||||
const sig = result.find((r) => r.type === 'SIGNATURE');
|
||||
expect(sig?.inferredRecipientLabel).toBe('Buyer');
|
||||
});
|
||||
|
||||
it('returns percent coordinates in [0, 100]', async () => {
|
||||
const result = await detectFields(Buffer.from('%PDF-1.7'));
|
||||
for (const f of result) {
|
||||
expect(f.pageX).toBeGreaterThanOrEqual(0);
|
||||
expect(f.pageX).toBeLessThanOrEqual(100);
|
||||
expect(f.pageY).toBeGreaterThanOrEqual(0);
|
||||
expect(f.pageY).toBeLessThanOrEqual(100);
|
||||
expect(f.pageWidth).toBeGreaterThan(0);
|
||||
expect(f.pageHeight).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('attaches the anchor text + a confidence score', async () => {
|
||||
const result = await detectFields(Buffer.from('%PDF-1.7'));
|
||||
const sig = result.find((r) => r.type === 'SIGNATURE');
|
||||
expect(sig?.anchorText).toMatch(/signature/i);
|
||||
expect(sig?.confidence).toBeGreaterThan(0.5);
|
||||
expect(sig?.confidence).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('does not match body text that lacks a signing-block keyword', async () => {
|
||||
const result = await detectFields(Buffer.from('%PDF-1.7'));
|
||||
// The "The parties hereby agree" item should not produce a TEXT
|
||||
// detection (no underscore run, no keyword).
|
||||
expect(result.find((r) => r.anchorText?.includes('parties'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gracefully returns [] when pdfjs throws', async () => {
|
||||
// Force pdfjs to reject for this one call
|
||||
const mod = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
||||
const orig = mod.getDocument;
|
||||
(mod as unknown as { getDocument: typeof orig }).getDocument = () =>
|
||||
({ promise: Promise.reject(new Error('boom')) }) as ReturnType<typeof orig>;
|
||||
const result = await detectFields(Buffer.from('not-a-pdf'));
|
||||
expect(result).toEqual([]);
|
||||
(mod as unknown as { getDocument: typeof orig }).getDocument = orig;
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user