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:
50
src/app/api/v1/documents/auto-detect-fields/route.ts
Normal file
50
src/app/api/v1/documents/auto-detect-fields/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { detectFields } from '@/lib/services/document-field-detector';
|
||||
import { isPdfMagic } from '@/lib/services/berth-pdf-parser';
|
||||
|
||||
/**
|
||||
* Phase 4 — Auto-detect anchor scanner endpoint.
|
||||
*
|
||||
* POST `/api/v1/documents/auto-detect-fields`
|
||||
*
|
||||
* Body: multipart/form-data
|
||||
* - file: the source PDF the rep just uploaded
|
||||
*
|
||||
* Returns: `{ data: { fields: DetectedField[] } }` — seed state for the
|
||||
* drag-drop overlay. Empty array when the PDF has no extractable text
|
||||
* (image-only scan) — the dialog falls back to manual placement
|
||||
* without an error toast.
|
||||
*
|
||||
* Permission: documents.send_for_signing — the only flow that calls
|
||||
* this endpoint is the upload-for-signing dialog, which already
|
||||
* requires that bit. Reusing it here means a custom role with the
|
||||
* upload bit but no send bit can't dry-run the detector to pull
|
||||
* structural metadata out of a contract before sending.
|
||||
*/
|
||||
const MAX_PDF_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('documents', 'send_for_signing', async (req) => {
|
||||
try {
|
||||
const form = await req.formData();
|
||||
const file = form.get('file');
|
||||
if (!file || !(file instanceof File)) {
|
||||
throw new ValidationError('Missing file');
|
||||
}
|
||||
if (file.size > MAX_PDF_BYTES) {
|
||||
throw new ValidationError(`File exceeds ${MAX_PDF_BYTES / 1024 / 1024} MB cap`);
|
||||
}
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
if (!isPdfMagic(buffer)) {
|
||||
throw new ValidationError('Uploaded file is not a PDF');
|
||||
}
|
||||
const fields = await detectFields(buffer);
|
||||
return NextResponse.json({ data: { fields } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
43
src/app/api/v1/documents/signing-defaults/route.ts
Normal file
43
src/app/api/v1/documents/signing-defaults/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
||||
|
||||
/**
|
||||
* GET `/api/v1/documents/signing-defaults`
|
||||
*
|
||||
* Returns the per-port developer + approver defaults the
|
||||
* UploadForSigningDialog uses to prefill the recipient configurator.
|
||||
* No secrets are exposed — just the display name, email, and the
|
||||
* sendMode flag so the UI can show the right CTA copy ("Send now" vs
|
||||
* "Save draft, send manually").
|
||||
*
|
||||
* Permission: documents.send_for_signing — the only caller is the
|
||||
* upload-for-signing dialog which already requires this permission to
|
||||
* complete the flow.
|
||||
*/
|
||||
export const GET = withAuth(
|
||||
withPermission('documents', 'send_for_signing', async (_req, ctx) => {
|
||||
try {
|
||||
const cfg = await getPortDocumensoConfig(ctx.portId);
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
developer: {
|
||||
name: cfg.developerName ?? '',
|
||||
email: cfg.developerEmail ?? '',
|
||||
label: cfg.developerLabel ?? 'Developer',
|
||||
},
|
||||
approver: {
|
||||
name: cfg.approverName ?? '',
|
||||
email: cfg.approverEmail ?? '',
|
||||
label: cfg.approverLabel ?? 'Approver',
|
||||
},
|
||||
sendMode: cfg.sendMode,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user