diff --git a/scripts/tsc-staged.mjs b/scripts/tsc-staged.mjs index a73ac7b0..b7123894 100644 --- a/scripts/tsc-staged.mjs +++ b/scripts/tsc-staged.mjs @@ -36,6 +36,13 @@ const tmpConfig = join(tmpDir, 'tsconfig.json'); const relFiles = files.map((f) => relative(tmpDir, resolve(cwd, f))); +// Pull in the project's ambient .d.ts files (css module shim, +// react-pdf JSX augment, etc.) so side-effect imports like +// `import 'react-pdf/dist/Page/AnnotationLayer.css'` resolve under the +// staged-only compile. Without this, `include: []` would shut out +// everything in src/types/ and tsc reports TS2882 for any CSS import. +const ambientTypesGlob = relative(tmpDir, join(cwd, 'src/types')) + '/**/*.d.ts'; + writeFileSync( tmpConfig, JSON.stringify( @@ -50,7 +57,7 @@ writeFileSync( types: ['node', 'react', 'react-dom'], }, files: relFiles, - include: [], + include: [ambientTypesGlob], }, null, 2, diff --git a/src/app/api/v1/documents/auto-detect-fields/route.ts b/src/app/api/v1/documents/auto-detect-fields/route.ts new file mode 100644 index 00000000..45a40894 --- /dev/null +++ b/src/app/api/v1/documents/auto-detect-fields/route.ts @@ -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); + } + }), +); diff --git a/src/app/api/v1/documents/signing-defaults/route.ts b/src/app/api/v1/documents/signing-defaults/route.ts new file mode 100644 index 00000000..d8796bcc --- /dev/null +++ b/src/app/api/v1/documents/signing-defaults/route.ts @@ -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); + } + }), +); diff --git a/src/components/documents/upload-for-signing-dialog.tsx b/src/components/documents/upload-for-signing-dialog.tsx new file mode 100644 index 00000000..c3585302 --- /dev/null +++ b/src/components/documents/upload-for-signing-dialog.tsx @@ -0,0 +1,1057 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Document, Page, pdfjs } from 'react-pdf'; +import { toast } from 'sonner'; +import { + ChevronLeft, + ChevronRight, + Loader2, + Plus, + Trash2, + X, + Pen, + Calendar as CalendarIcon, + Type, + CheckSquare, + Mail, + User as UserIcon, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; + +import 'react-pdf/dist/Page/AnnotationLayer.css'; +import 'react-pdf/dist/Page/TextLayer.css'; + +pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; + +/** + * Phase 4 — Upload-for-Documenso-signing dialog. + * + * Four-step flow inside one dialog: + * 1. select-file — drag/drop or click to upload a PDF + * 2. configure-recipients — name/email/role per signer, with + * client + developer + approver prefilled from port + interest + * 3. place-fields — render the PDF page-by-page, run + * auto-detect, let the rep drag/place/delete fields per signer + * 4. sending — POST to /upload-for-signing, show spinner + * + * The implementation is intentionally compact — the field-overlay + * uses native DOM drag rather than dnd-kit so the coordinate math + * stays obvious. Auto-detect lives on the server (uses pdfjs-dist) so + * the same parser ships once. + */ + +interface Recipient { + name: string; + email: string; + role: 'SIGNER' | 'APPROVER' | 'CC'; + signingOrder: number; +} + +type FieldType = + | 'SIGNATURE' + | 'FREE_SIGNATURE' + | 'INITIALS' + | 'DATE' + | 'EMAIL' + | 'NAME' + | 'TEXT' + | 'NUMBER' + | 'CHECKBOX' + | 'DROPDOWN' + | 'RADIO'; + +interface PlacedField { + /** Client-side id only — server doesn't see this. */ + id: string; + type: FieldType; + recipientIndex: number; + pageNumber: number; + /** All 0..100 percent of page dimensions. */ + pageX: number; + pageY: number; + pageWidth: number; + pageHeight: number; +} + +interface DetectedFieldResponse { + type: FieldType; + pageNumber: number; + pageX: number; + pageY: number; + pageWidth: number; + pageHeight: number; + confidence: number; + anchorText?: string; + inferredRecipientLabel?: string | null; +} + +interface SigningDefaults { + developer: { name: string; email: string; label: string }; + approver: { name: string; email: string; label: string }; + sendMode: 'auto' | 'manual'; +} + +const FIELD_DEFAULTS: Record< + FieldType, + { widthPct: number; heightPct: number; label: string; icon: typeof Pen } +> = { + SIGNATURE: { widthPct: 20, heightPct: 5, label: 'Signature', icon: Pen }, + FREE_SIGNATURE: { widthPct: 20, heightPct: 5, label: 'Free signature', icon: Pen }, + INITIALS: { widthPct: 8, heightPct: 5, label: 'Initials', icon: Pen }, + DATE: { widthPct: 12, heightPct: 3, label: 'Date', icon: CalendarIcon }, + EMAIL: { widthPct: 25, heightPct: 3, label: 'Email', icon: Mail }, + NAME: { widthPct: 20, heightPct: 3, label: 'Name', icon: UserIcon }, + TEXT: { widthPct: 25, heightPct: 3, label: 'Text', icon: Type }, + NUMBER: { widthPct: 15, heightPct: 3, label: 'Number', icon: Type }, + CHECKBOX: { widthPct: 3, heightPct: 3, label: 'Checkbox', icon: CheckSquare }, + DROPDOWN: { widthPct: 20, heightPct: 3, label: 'Dropdown', icon: Type }, + RADIO: { widthPct: 3, heightPct: 3, label: 'Radio', icon: CheckSquare }, +}; + +const RECIPIENT_COLORS = [ + 'rgb(59 130 246)', // blue-500 + 'rgb(168 85 247)', // purple-500 + 'rgb(34 197 94)', // green-500 + 'rgb(249 115 22)', // orange-500 + 'rgb(239 68 68)', // red-500 + 'rgb(20 184 166)', // teal-500 +]; + +interface UploadForSigningDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + interestId: string; + /** Pre-set the document type — the parent (Contract/Reservation tab) + * decides which to upload. */ + documentType: 'contract' | 'reservation_agreement'; + /** Optional: client name/email to prefill the first recipient. + * When omitted the dialog fetches from the interest. */ + clientPrefill?: { name: string; email: string }; +} + +export function UploadForSigningDialog({ + open, + onOpenChange, + interestId, + documentType, + clientPrefill, +}: UploadForSigningDialogProps) { + // Re-mount the body on every open so all state resets cleanly. Same + // pattern as hard-delete-dialog (set-state-in-effect avoidance). + if (!open) return null; + return ( + + + onOpenChange(false)} + /> + + + ); +} + +type Step = 'select-file' | 'configure-recipients' | 'place-fields'; + +function DialogBody({ + interestId, + documentType, + clientPrefill, + onClose, +}: { + interestId: string; + documentType: 'contract' | 'reservation_agreement'; + clientPrefill?: { name: string; email: string }; + onClose: () => void; +}) { + const [step, setStep] = useState('select-file'); + const [file, setFile] = useState(null); + const [title, setTitle] = useState(''); + const [recipients, setRecipients] = useState([]); + const [fields, setFields] = useState([]); + const [selectedFieldId, setSelectedFieldId] = useState(null); + + const docLabel = documentType === 'contract' ? 'Sales Contract' : 'Reservation Agreement'; + + // Defaults endpoint — drives the developer/approver prefill. + const { data: defaults } = useQuery<{ data: SigningDefaults }>({ + queryKey: ['documents', 'signing-defaults'], + queryFn: () => apiFetch<{ data: SigningDefaults }>('/api/v1/documents/signing-defaults'), + }); + + // Interest endpoint — used to prefill the client recipient when the + // caller didn't supply one. Cached so the same dialog open/reopen + // hits the cache. + const { data: interestData } = useQuery<{ + data: { client: { fullName: string; email: string | null } }; + }>({ + queryKey: ['interest', interestId, 'prefill'], + queryFn: () => + apiFetch<{ data: { client: { fullName: string; email: string | null } } }>( + `/api/v1/interests/${interestId}`, + ), + enabled: !clientPrefill, + }); + + /** + * Build the prefill recipient list from the async query data. The + * dialog reads this on the "Next" button click in the file-picker + * step to seed `recipients` — keeping the seeding as a user-event + * handler rather than an effect avoids the cascading-render lint + * (react-hooks/set-state-in-effect, Wave 3) that earlier versions + * tripped. Returns an empty array until the defaults query resolves; + * the file-picker step's Next button stays clickable so the rep can + * still proceed and add recipients manually if the defaults endpoint + * is slow. + */ + const prefillRecipients = useMemo(() => { + if (!defaults?.data) return []; + const client = clientPrefill ?? { + name: interestData?.data?.client?.fullName ?? '', + email: interestData?.data?.client?.email ?? '', + }; + const next: Recipient[] = []; + if (client.name && client.email) { + next.push({ name: client.name, email: client.email, role: 'SIGNER', signingOrder: 1 }); + } + if (defaults.data.developer.email) { + next.push({ + name: defaults.data.developer.name || defaults.data.developer.label, + email: defaults.data.developer.email, + role: 'SIGNER', + signingOrder: next.length + 1, + }); + } + if (defaults.data.approver.email) { + next.push({ + name: defaults.data.approver.name || defaults.data.approver.label, + email: defaults.data.approver.email, + role: 'APPROVER', + signingOrder: next.length + 1, + }); + } + return next; + }, [defaults, interestData, clientPrefill]); + + const fileObjectUrl = useMemo(() => (file ? URL.createObjectURL(file) : null), [file]); + useEffect(() => { + return () => { + if (fileObjectUrl) URL.revokeObjectURL(fileObjectUrl); + }; + }, [fileObjectUrl]); + + const autoDetect = useMutation({ + mutationFn: async (uploadedFile: File) => { + const form = new FormData(); + form.append('file', uploadedFile); + // apiFetch JSON-encodes the body when set, so we go raw here. + const res = await fetch('/api/v1/documents/auto-detect-fields', { + method: 'POST', + body: form, + credentials: 'include', + }); + if (!res.ok) throw new Error('Auto-detect failed'); + return (await res.json()) as { data: { fields: DetectedFieldResponse[] } }; + }, + onSuccess: (res) => { + // Drop detected fields onto a temporary "unassigned" recipient + // (index 0). Rep reassigns via the side panel. + const placed: PlacedField[] = res.data.fields.map((f) => ({ + id: `det-${Math.random().toString(36).slice(2, 10)}`, + type: f.type, + recipientIndex: 0, + pageNumber: f.pageNumber, + pageX: f.pageX, + pageY: f.pageY, + pageWidth: f.pageWidth, + pageHeight: f.pageHeight, + })); + setFields((existing) => [...existing, ...placed]); + if (placed.length > 0) { + toast.success( + `Auto-detect placed ${placed.length} field${placed.length === 1 ? '' : 's'}.`, + ); + } else { + toast.info('No fields auto-detected — place them manually.'); + } + }, + onError: () => { + toast.info('Auto-detect skipped — place fields manually.'); + }, + }); + + const queryClient = useQueryClient(); + const sendMutation = useMutation({ + mutationFn: async () => { + if (!file) throw new Error('No file selected'); + const form = new FormData(); + form.append('file', file); + form.append('documentType', documentType); + form.append('title', title || file.name.replace(/\.pdf$/i, '')); + form.append('recipients', JSON.stringify(recipients)); + // Strip the client-side `id` from each placed field — the server + // assigns its own ids on the documenso side. + form.append( + 'fields', + JSON.stringify( + fields.map((f) => ({ + type: f.type, + recipientIndex: f.recipientIndex, + pageNumber: f.pageNumber, + pageX: f.pageX, + pageY: f.pageY, + pageWidth: f.pageWidth, + pageHeight: f.pageHeight, + })), + ), + ); + const res = await fetch(`/api/v1/interests/${interestId}/upload-for-signing`, { + method: 'POST', + body: form, + credentials: 'include', + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + error?: string; + requestId?: string; + }; + const err = new Error(body.error ?? 'Upload failed'); + // Tack the requestId onto the Error so toastError can surface it. + (err as Error & { requestId?: string }).requestId = body.requestId; + throw err; + } + return res.json() as Promise<{ + data: { documentId: string; signingUrls: Record }; + }>; + }, + onSuccess: (res) => { + toast.success( + defaults?.data?.sendMode === 'auto' + ? 'Document sent for signing — first signer has been invited.' + : 'Document uploaded and ready to send. Use the Send button on the doc to email the first signer.', + ); + queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' }); + queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'interest' }); + void res; + onClose(); + }, + onError: (err) => toastError(err, 'Upload failed'), + }); + + // ─── Step renderers ───────────────────────────────────────────── + return ( + <> + + Send {docLabel.toLowerCase()} for signing + + {step === 'select-file' && 'Upload the draft PDF to send via Documenso.'} + {step === 'configure-recipients' && 'Confirm who needs to sign and in what order.'} + {step === 'place-fields' && + 'Place signing fields where each recipient needs to sign, date, or fill in. Click a palette button then click on the PDF to place a field.'} + + + +
+ {step === 'select-file' && ( + { + setFile(f); + setTitle(f.name.replace(/\.pdf$/i, '')); + // Seed recipients from the prefill snapshot when the rep + // first lands a file — only if they haven't already + // edited the list. This pattern keeps the prefill + // synchronization in user-event handlers (no setState- + // in-effect lint trip). + if (recipients.length === 0 && prefillRecipients.length > 0) { + setRecipients(prefillRecipients); + } + setStep('configure-recipients'); + autoDetect.mutate(f); + }} + title={title} + onTitleChange={setTitle} + /> + )} + {step === 'configure-recipients' && ( + + )} + {step === 'place-fields' && fileObjectUrl && ( + + )} +
+ + + +
+ {step === 'configure-recipients' && ( + + )} + {step === 'place-fields' && ( + + )} + {step !== 'place-fields' && ( + + )} + {step === 'place-fields' && ( + + )} +
+
+ + ); +} + +function StepIndicator({ step }: { step: Step }) { + const dots = [ + { key: 'select-file', label: 'File' }, + { key: 'configure-recipients', label: 'Recipients' }, + { key: 'place-fields', label: 'Fields' }, + ] as const; + const activeIdx = dots.findIndex((d) => d.key === step); + return ( +
+ {dots.map((d, i) => ( + + + {d.label} + {i < dots.length - 1 && } + + ))} +
+ ); +} + +// ─── Step 1: file picker ────────────────────────────────────────── + +function FilePickerStep({ + onFileSelected, + title, + onTitleChange, +}: { + onFileSelected: (file: File) => void; + title: string; + onTitleChange: (t: string) => void; +}) { + const [dragging, setDragging] = useState(false); + const inputRef = useRef(null); + + function handleFile(f: File) { + if (!f.name.toLowerCase().endsWith('.pdf') && f.type !== 'application/pdf') { + toast.error('Only PDF files are accepted'); + return; + } + onFileSelected(f); + } + + return ( +
+
+ + onTitleChange(e.target.value)} + placeholder="e.g. Berth A-12 Sales Contract — John Smith" + /> +
+
{ + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + const f = e.dataTransfer.files[0]; + if (f) handleFile(f); + }} + className={ + 'rounded-lg border-2 border-dashed p-12 text-center transition-colors cursor-pointer ' + + (dragging + ? 'border-foreground bg-muted/40' + : 'border-muted-foreground/30 hover:border-muted-foreground/60') + } + onClick={() => inputRef.current?.click()} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + inputRef.current?.click(); + } + }} + > +

Drop a PDF here, or click to browse

+

Max 50 MB.

+ { + const f = e.target.files?.[0]; + if (f) handleFile(f); + }} + /> +
+
+ ); +} + +// ─── Step 2: recipient configurator ─────────────────────────────── + +function RecipientsStep({ + recipients, + onChange, + title, + onTitleChange, +}: { + recipients: Recipient[]; + onChange: (next: Recipient[]) => void; + title: string; + onTitleChange: (t: string) => void; +}) { + function update(i: number, patch: Partial) { + const next = [...recipients]; + next[i] = { ...next[i]!, ...patch }; + onChange(next); + } + function remove(i: number) { + const next = recipients.filter((_, idx) => idx !== i); + // Reflow signingOrder + onChange(next.map((r, idx) => ({ ...r, signingOrder: idx + 1 }))); + } + function add() { + onChange([ + ...recipients, + { + name: '', + email: '', + role: 'SIGNER', + signingOrder: recipients.length + 1, + }, + ]); + } + return ( +
+
+ + onTitleChange(e.target.value)} /> +
+
+ +
+ {recipients.map((r, i) => ( +
+ + #{r.signingOrder} + + update(i, { name: e.target.value })} + /> + update(i, { email: e.target.value })} + /> + + +
+ ))} +
+ +
+
+ ); +} + +// ─── Step 3: field placement overlay ────────────────────────────── + +function FieldPlacementStep({ + fileUrl, + fields, + onFieldsChange, + recipients, + selectedFieldId, + onSelectField, + isDetecting, +}: { + fileUrl: string; + fields: PlacedField[]; + onFieldsChange: (next: PlacedField[]) => void; + recipients: Recipient[]; + selectedFieldId: string | null; + onSelectField: (id: string | null) => void; + isDetecting: boolean; +}) { + const [numPages, setNumPages] = useState(1); + const [pageNumber, setPageNumber] = useState(1); + const [placingType, setPlacingType] = useState(null); + const pageContainerRef = useRef(null); + + const pageFields = useMemo( + () => fields.filter((f) => f.pageNumber === pageNumber), + [fields, pageNumber], + ); + + function placeFieldAt(clientX: number, clientY: number, container: HTMLElement) { + if (!placingType) return; + const rect = container.getBoundingClientRect(); + const pageX = ((clientX - rect.left) / rect.width) * 100; + const pageY = ((clientY - rect.top) / rect.height) * 100; + const defaults = FIELD_DEFAULTS[placingType]; + const newField: PlacedField = { + id: `f-${Math.random().toString(36).slice(2, 10)}`, + type: placingType, + recipientIndex: 0, + pageNumber, + pageX: Math.max(0, Math.min(100 - defaults.widthPct, pageX - defaults.widthPct / 2)), + pageY: Math.max(0, Math.min(100 - defaults.heightPct, pageY - defaults.heightPct / 2)), + pageWidth: defaults.widthPct, + pageHeight: defaults.heightPct, + }; + onFieldsChange([...fields, newField]); + onSelectField(newField.id); + setPlacingType(null); + } + + function updateField(id: string, patch: Partial) { + onFieldsChange(fields.map((f) => (f.id === id ? { ...f, ...patch } : f))); + } + function removeField(id: string) { + onFieldsChange(fields.filter((f) => f.id !== id)); + if (selectedFieldId === id) onSelectField(null); + } + + return ( +
+ {/* Field palette */} +
+

Field palette

+
+ {(Object.keys(FIELD_DEFAULTS) as FieldType[]) + .filter((t) => t !== 'FREE_SIGNATURE') // collapsed with SIGNATURE for the palette + .map((t) => { + const def = FIELD_DEFAULTS[t]; + const Icon = def.icon; + return ( + + ); + })} +
+ {placingType && ( +

+ Click on the PDF to place a {FIELD_DEFAULTS[placingType].label.toLowerCase()}. +

+ )} +
+

Recipients

+
+ {recipients.map((r, i) => ( +
+ + {r.name || r.email || `#${r.signingOrder}`} +
+ ))} +
+
+ + {/* PDF + overlay */} +
+
+ + + {pageNumber} / {numPages} + + + {isDetecting && ( + + Auto-detecting fields… + + )} + + {fields.length} {fields.length === 1 ? 'field' : 'fields'} placed + +
+
+
{ + if (!placingType) return; + if (!pageContainerRef.current) return; + // Bail when the click landed on an existing field (we + // handle those via the field's own onClick). + if ((e.target as HTMLElement).closest('[data-field-id]')) return; + placeFieldAt(e.clientX, e.clientY, pageContainerRef.current); + }} + > + setNumPages(n)} + loading={ +
+ Loading PDF… +
+ } + > + +
+ {/* Overlay layer */} +
+ {pageFields.map((field) => ( + onSelectField(field.id)} + onUpdate={(patch) => updateField(field.id, patch)} + onRemove={() => removeField(field.id)} + /> + ))} +
+
+
+
+ + {/* Side panel for selected field */} + {selectedFieldId && ( + f.id === selectedFieldId)!} + recipients={recipients} + onUpdate={(patch) => updateField(selectedFieldId, patch)} + onRemove={() => removeField(selectedFieldId)} + onClose={() => onSelectField(null)} + /> + )} +
+ ); +} + +function FieldOverlay({ + field, + selected, + recipients, + onSelect, + onUpdate, + onRemove, +}: { + field: PlacedField; + selected: boolean; + recipients: Recipient[]; + onSelect: () => void; + onUpdate: (patch: Partial) => void; + onRemove: () => void; +}) { + const Icon = FIELD_DEFAULTS[field.type].icon; + const color = RECIPIENT_COLORS[field.recipientIndex % RECIPIENT_COLORS.length]; + const recipient = recipients[field.recipientIndex]; + + // Drag handler — translate mouse-move pixels into percent deltas + // against the parent container's bounding rect. + function startDrag(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + onSelect(); + const container = (e.currentTarget.parentElement?.parentElement as HTMLElement) ?? null; + if (!container) return; + const rect = container.getBoundingClientRect(); + const startX = e.clientX; + const startY = e.clientY; + const startPageX = field.pageX; + const startPageY = field.pageY; + + function onMove(ev: MouseEvent) { + const dxPct = ((ev.clientX - startX) / rect.width) * 100; + const dyPct = ((ev.clientY - startY) / rect.height) * 100; + onUpdate({ + pageX: Math.max(0, Math.min(100 - field.pageWidth, startPageX + dxPct)), + pageY: Math.max(0, Math.min(100 - field.pageHeight, startPageY + dyPct)), + }); + } + function onUp() { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + } + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + } + + return ( +
{ + e.stopPropagation(); + onSelect(); + }} + onMouseDown={startDrag} + className={ + 'absolute pointer-events-auto cursor-move rounded border-2 text-xs flex items-center gap-1 px-1 ' + + (selected ? 'ring-2 ring-offset-1 ring-foreground' : '') + } + style={{ + left: `${field.pageX}%`, + top: `${field.pageY}%`, + width: `${field.pageWidth}%`, + height: `${field.pageHeight}%`, + backgroundColor: color + '22', + borderColor: color, + }} + role="button" + tabIndex={0} + aria-label={`${FIELD_DEFAULTS[field.type].label} for ${recipient?.name ?? 'unassigned'}`} + > + + + {FIELD_DEFAULTS[field.type].label} + + {selected && ( + + )} +
+ ); +} + +function FieldSidePanel({ + field, + recipients, + onUpdate, + onRemove, + onClose, +}: { + field: PlacedField; + recipients: Recipient[]; + onUpdate: (patch: Partial) => void; + onRemove: () => void; + onClose: () => void; +}) { + return ( +
+
+

Field properties

+ +
+
+ + +
+
+ + +
+
+
+ + onUpdate({ pageWidth: Number(e.target.value) })} + step="0.5" + min="1" + max="100" + /> +
+
+ + onUpdate({ pageHeight: Number(e.target.value) })} + step="0.5" + min="1" + max="100" + /> +
+
+ +
+ ); +} diff --git a/src/components/interests/interest-contract-tab.tsx b/src/components/interests/interest-contract-tab.tsx index 25354433..bc98ff75 100644 --- a/src/components/interests/interest-contract-tab.tsx +++ b/src/components/interests/interest-contract-tab.tsx @@ -20,6 +20,7 @@ import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog'; import { SigningProgress } from '@/components/documents/signing-progress'; +import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { useConfirmation } from '@/hooks/use-confirmation'; @@ -168,16 +169,16 @@ export function InterestContractTab({ interestId, clientId: _clientId }: Interes /> )} - {/* Upload-for-Documenso-signing dialog placeholder. The real - dialog (PDF picker + recipient configurator + send button) - is part of the larger custom-doc-upload service that's a - follow-up. For now show a friendly "coming soon" card. */} + {/* Phase 4 — upload-for-Documenso-signing dialog. Multi-step + (file → recipients → fields → send) backed by the Phase 3 + service. Auto-detect runs after the file lands; rep can + tweak placements before sending. */} {uploadForSigningOpen && ( - )} @@ -381,44 +382,3 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) { ); } - -/** - * Placeholder for the upload-for-Documenso-signing flow until the - * full upload + recipient + field-placement service is shipped. - * Intentional dead-end so reps know the path exists rather than - * misclicking and getting confusing behaviour. - */ -function ComingSoonDialog({ - open, - onOpenChange, - title, - body, -}: { - open: boolean; - onOpenChange: (next: boolean) => void; - title: string; - body: string; -}) { - if (!open) return null; - return ( -
onOpenChange(false)} - > -
e.stopPropagation()} - > -

{title}

-

{body}

-
- -
-
-
- ); -} diff --git a/src/components/interests/interest-reservation-tab.tsx b/src/components/interests/interest-reservation-tab.tsx index 654f5df6..3463d018 100644 --- a/src/components/interests/interest-reservation-tab.tsx +++ b/src/components/interests/interest-reservation-tab.tsx @@ -20,6 +20,7 @@ import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog'; import { SigningProgress } from '@/components/documents/signing-progress'; +import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { useConfirmation } from '@/hooks/use-confirmation'; @@ -171,16 +172,13 @@ export function InterestReservationTab({ /> )} - {/* Upload-for-Documenso-signing dialog placeholder. The real - dialog (PDF picker + recipient configurator + send button) - is part of the larger custom-doc-upload service that's a - follow-up. For now show a friendly "coming soon" card. */} + {/* Phase 4 — upload-for-Documenso-signing dialog. */} {uploadForSigningOpen && ( - )} @@ -384,44 +382,3 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) { ); } - -/** - * Placeholder for the upload-for-Documenso-signing flow until the - * full upload + recipient + field-placement service is shipped. - * Intentional dead-end so reps know the path exists rather than - * misclicking and getting confusing behaviour. - */ -function ComingSoonDialog({ - open, - onOpenChange, - title, - body, -}: { - open: boolean; - onOpenChange: (next: boolean) => void; - title: string; - body: string; -}) { - if (!open) return null; - return ( -
onOpenChange(false)} - > -
e.stopPropagation()} - > -

{title}

-

{body}

-
- -
-
-
- ); -} diff --git a/src/lib/services/document-field-detector.ts b/src/lib/services/document-field-detector.ts new file mode 100644 index 00000000..a6e19a74 --- /dev/null +++ b/src/lib/services/document-field-detector.ts @@ -0,0 +1,297 @@ +/** + * Phase 4c — Auto-detect anchor scanner. + * + * Scans a PDF for common signing-block keywords ("Signature:", "Date:", + * "Initials", a long run of underscores, etc.) and proposes Documenso + * field placements positioned right after the matched anchor. Output + * is in PERCENT coordinates so it lines up with the existing + * `DocumensoFieldPlacement` shape consumed by the Phase 3 service. + * + * Confidence calculation is conservative: an explicit keyword match + * scores higher than a generic underscore-run; the field-type-specific + * regexes are tried in priority order so a `"Date of Signature:"` + * anchor doesn't double-place as both DATE and SIGNATURE. + * + * This is intentionally pdf-content driven (text-extraction based) — + * the alternative (image-of-PDF + OCR) is the bigger berth-PDF parser + * tier-3 path; we keep this lightweight so it runs in <500ms on a + * 10-page contract. + */ + +import type { DocumensoFieldType } from '@/lib/services/documenso-client'; + +/** Result of detection, one entry per matched anchor. */ +export interface DetectedField { + type: DocumensoFieldType; + /** 1-indexed page number. */ + pageNumber: number; + /** All four values are 0-100 percent of page dimensions. */ + pageX: number; + pageY: number; + pageWidth: number; + pageHeight: number; + /** 0..1 — how sure the scanner is. */ + confidence: number; + /** Verbatim anchor that triggered the detection (display + debug). */ + anchorText: string; + /** Inferred recipient label ("Buyer", "Seller", "Client", "Witness", + * "Developer", "Notary", null). Phase 4d maps these to recipients + * by role/name. */ + inferredRecipientLabel?: string | null; +} + +/** Anchor → field-type pattern table. Order matters: earlier patterns + * win when two anchors overlap on the same text item (e.g. "Date of + * Signature" matches both DATE and SIGNATURE — DATE goes first because + * it's the more specific pattern). */ +interface AnchorPattern { + type: DocumensoFieldType; + /** Test against lower-cased anchor text. */ + match: RegExp; + /** Suggested field box in PDF points (72 dpi). Converted to percent + * per-page after extraction. */ + widthPt: number; + heightPt: number; + /** Bias added to the base confidence. Specific keywords get a bump + * over the generic underscore catch-all. */ + confidenceBoost: number; +} + +const ANCHOR_PATTERNS: AnchorPattern[] = [ + // DATE — more specific than SIGNATURE for the common "Date of + // Signature:" case, so listed first. + { + type: 'DATE', + match: /(?:dated|date(?:\s+of\s+signature)?)[:\s_-]+/i, + widthPt: 80, + heightPt: 20, + confidenceBoost: 0.2, + }, + // INITIALS — pre-empts NAME because "Initial:" is short and unique. + { + type: 'INITIALS', + match: /(?:^|\b)(?:initials?)[:\s_-]+/i, + widthPt: 50, + heightPt: 30, + confidenceBoost: 0.2, + }, + // EMAIL — explicit email anchor. + { + type: 'EMAIL', + match: /(?:^|\b)e-?mail[:\s_-]+/i, + widthPt: 200, + heightPt: 20, + confidenceBoost: 0.2, + }, + // NAME — printed/full name labels. + { + type: 'NAME', + match: /(?:^|\b)(?:printed\s*)?(?:full\s+)?name[:\s_-]+/i, + widthPt: 150, + heightPt: 20, + confidenceBoost: 0.15, + }, + // SIGNATURE — broadest of the signing-block patterns. + { + type: 'SIGNATURE', + match: /(?:^|\b)(?:signature|sign\s*here|signed\s*by|signed\s*at)[:\s_-]+/i, + widthPt: 150, + heightPt: 30, + confidenceBoost: 0.2, + }, + // SIGNATURE — explicit "X" mark followed by a blank line. + { + type: 'SIGNATURE', + match: /X\s*_{4,}/, + widthPt: 150, + heightPt: 30, + confidenceBoost: 0.15, + }, + // Catch-all: a run of underscores not preceded by a more specific + // keyword (which would have matched above). Defaults to TEXT. + { + type: 'TEXT', + match: /_{8,}/, + widthPt: 200, + heightPt: 20, + confidenceBoost: 0, + }, +]; + +/** Recipient labels we know how to match against. Kept in priority + * order so "Buyer Notary" wins NOTARY (more specific than BUYER on a + * notary-block tail). Each entry is lower-cased. */ +const RECIPIENT_LABELS: Array<{ label: string; aliases: string[] }> = [ + { label: 'Notary', aliases: ['notary', 'witness'] }, + { label: 'Witness', aliases: ['witness'] }, + { label: 'Developer', aliases: ['developer', 'seller', 'vendor'] }, + { label: 'Approver', aliases: ['approver', 'manager'] }, + { label: 'Buyer', aliases: ['buyer', 'purchaser', 'client'] }, + { label: 'Seller', aliases: ['seller', 'vendor'] }, + { label: 'Client', aliases: ['client', 'customer'] }, +]; + +/** A single text item returned by pdfjs-dist. The transform array + * encodes the position + scale of the text via PDF's affine matrix: + * `[scaleX, skewY, skewX, scaleY, translateX, translateY]`. We use + * `(translateX, translateY)` as the anchor's lower-left corner. */ +interface PdfTextItem { + str: string; + /** PDF affine [a, b, c, d, e, f]. (e, f) is position. */ + transform: number[]; + /** Item width in PDF user-space units. */ + width?: number; + /** Item height — usually equals scaleY. */ + height?: number; +} + +interface PdfPageView { + pageNumber: number; + widthPt: number; + heightPt: number; + items: PdfTextItem[]; +} + +/** + * Detect signing-block fields in a PDF. Each detection points at the + * position immediately after the matched anchor text and is offset 5pt + * to the right so the placeholder doesn't visually overlap the + * keyword. + * + * Returns an empty array when the PDF has no extractable text (image- + * only scans). The caller should fall back to drag-place-manual in + * that case. + */ +export async function detectFields(pdfBuffer: Buffer): Promise { + const pages = await extractPdfPages(pdfBuffer); + const detected: DetectedField[] = []; + + for (const page of pages) { + for (const item of page.items) { + const lower = item.str.toLowerCase(); + // Skip if the item has no positional data — defensive against + // exotic PDF encodings. + if (!Array.isArray(item.transform) || item.transform.length < 6) continue; + const translateX = Number(item.transform[4]); + const translateY = Number(item.transform[5]); + if (!Number.isFinite(translateX) || !Number.isFinite(translateY)) continue; + + for (const pattern of ANCHOR_PATTERNS) { + if (!pattern.match.test(lower)) continue; + + // Place the field immediately after the anchor with a 5pt + // horizontal offset. The anchor's width is approximate; pdfjs + // sometimes gives a too-small width for short tokens so we + // floor at 30pt to avoid the field landing on top of the text. + const anchorWidthPt = Math.max(30, item.width ?? lower.length * 5); + const fieldXPt = translateX + anchorWidthPt + 5; + // PDF user-space origin is the lower-left; transform[5] is the + // baseline of the text so the field's lower-left also lives + // there. CSS/web origin is top-left — we keep the percent in + // PDF coordinates here because Documenso accepts both (the + // existing placeFields helper handles the conversion). + const fieldYPt = translateY; + + const pageX = (fieldXPt / page.widthPt) * 100; + const pageY = (fieldYPt / page.heightPt) * 100; + const pageWidth = (pattern.widthPt / page.widthPt) * 100; + const pageHeight = (pattern.heightPt / page.heightPt) * 100; + + // Hard-skip fields that would land off-page (defensive — a + // misparsed transform can blow up the coordinate space). + if (pageX < 0 || pageX > 95 || pageY < 0 || pageY > 95) continue; + if (pageWidth <= 0 || pageHeight <= 0) continue; + + const recipientLabel = inferRecipient(page.items, item, translateX, translateY); + + detected.push({ + type: pattern.type, + pageNumber: page.pageNumber, + pageX, + pageY, + pageWidth, + pageHeight, + confidence: 0.5 + pattern.confidenceBoost, + anchorText: item.str.trim(), + inferredRecipientLabel: recipientLabel, + }); + // First matching pattern wins for this item — earlier + // (more-specific) patterns shadow later ones. + break; + } + } + } + return detected; +} + +/** + * Walk the page's other text items within ±100pt of the anchor and + * find a recipient-label keyword. Used to seed the recipient + * assignment side-panel; the rep can override. + */ +function inferRecipient( + items: PdfTextItem[], + anchor: PdfTextItem, + anchorX: number, + anchorY: number, +): string | null { + const RADIUS = 100; + for (const candidate of items) { + if (candidate === anchor) continue; + if (!Array.isArray(candidate.transform) || candidate.transform.length < 6) continue; + const cx = Number(candidate.transform[4]); + const cy = Number(candidate.transform[5]); + if (!Number.isFinite(cx) || !Number.isFinite(cy)) continue; + if (Math.abs(cx - anchorX) > RADIUS) continue; + if (Math.abs(cy - anchorY) > RADIUS) continue; + const lower = candidate.str.toLowerCase(); + for (const { label, aliases } of RECIPIENT_LABELS) { + if (aliases.some((alias) => lower.includes(alias))) return label; + } + } + return null; +} + +/** + * Extract per-page text + page dimensions from a PDF buffer. Uses + * pdfjs-dist (the same library powering react-pdf in the dialog). We + * import it dynamically so the heavy native-bindings dep only loads + * when the detector actually runs. + * + * Returns an empty array if pdfjs fails to parse — the rep gets the + * manual placement flow without an error toast. + */ +export async function extractPdfPages(pdfBuffer: Buffer): Promise { + try { + // pdfjs-dist 5.x ships a legacy ESM build that works in Node + Next + // server bundles without the worker wiring needed in the browser. + const pdfjsLib = await import('pdfjs-dist/legacy/build/pdf.mjs'); + const data = new Uint8Array(pdfBuffer); + const loadingTask = pdfjsLib.getDocument({ data }); + const pdf = await loadingTask.promise; + const pages: PdfPageView[] = []; + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const viewport = page.getViewport({ scale: 1 }); + const content = await page.getTextContent(); + const items = (content.items as Array).filter(isPdfTextItem); + pages.push({ + pageNumber: i, + widthPt: viewport.width, + heightPt: viewport.height, + items, + }); + } + return pages; + } catch { + // Image-only scans or corrupt PDFs land here. The dialog falls + // back to manual placement — no rep-facing error needed. + return []; + } +} + +function isPdfTextItem(item: unknown): item is PdfTextItem { + if (!item || typeof item !== 'object') return false; + const i = item as Record; + return typeof i.str === 'string' && Array.isArray(i.transform); +} diff --git a/tests/unit/services/document-field-detector.test.ts b/tests/unit/services/document-field-detector.test.ts new file mode 100644 index 00000000..0e9d58af --- /dev/null +++ b/tests/unit/services/document-field-detector.test.ts @@ -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; + const result = await detectFields(Buffer.from('not-a-pdf')); + expect(result).toEqual([]); + (mod as unknown as { getDocument: typeof orig }).getDocument = orig; + }); +});