From 8c02f88cbd7272122fa360255d54dfd124cbfeca Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Wed, 6 May 2026 18:33:15 +0200 Subject: [PATCH] feat(interests): upload externally-signed EOI (paper / non-Documenso) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sales reps need to file EOIs that were signed outside Documenso — on paper, in person at a boat show, or via an alternate e-sign vendor. Until now the EOI flow assumed Documenso was the only path. - external-eoi.service.uploadExternallySignedEoi creates BOTH the document row AND the signed-file record in one shot. Document is marked isManualUpload=true with status=completed and signedFileId set. Distinct from the existing uploadSignedManually which augments a document row that came from the Documenso pathway. - POST /api/v1/interests/[id]/external-eoi accepts multipart with the PDF + optional title + signedAt date + comma-separated signer names + free-text notes. Gated on documents.upload_signed permission. - Interest stage auto-advances to eoi_signed (only when the interest is currently at or before eoi_sent — past that, just file the doc). - The signing date, signer names, and any notes are captured into document_events.eventData + the audit_log metadata so the audit trail records who said the document was signed and when. - ExternalEoiUploadDialog renders a small modal: file picker, title override, signed-date (defaults to today), comma-separated signer names, notes. Wired into interest-detail-header behind an Upload icon button (gated on documents.upload_signed). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v1/interests/[id]/external-eoi/route.ts | 61 ++++++ .../interests/external-eoi-upload-dialog.tsx | 157 +++++++++++++++ .../interests/interest-detail-header.tsx | 23 +++ src/lib/services/external-eoi.service.ts | 178 ++++++++++++++++++ 4 files changed, 419 insertions(+) create mode 100644 src/app/api/v1/interests/[id]/external-eoi/route.ts create mode 100644 src/components/interests/external-eoi-upload-dialog.tsx create mode 100644 src/lib/services/external-eoi.service.ts diff --git a/src/app/api/v1/interests/[id]/external-eoi/route.ts b/src/app/api/v1/interests/[id]/external-eoi/route.ts new file mode 100644 index 0000000..7408fd2 --- /dev/null +++ b/src/app/api/v1/interests/[id]/external-eoi/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { uploadExternallySignedEoi } from '@/lib/services/external-eoi.service'; +import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; + +export const POST = withAuth( + withPermission('documents', 'upload_signed', async (req, ctx, params) => { + try { + const interestId = params.id; + if (!interestId) throw new NotFoundError('Interest'); + + const form = await req.formData(); + const file = form.get('file'); + if (!file || !(file instanceof File)) { + throw new ValidationError('Missing file'); + } + const buffer = Buffer.from(await file.arrayBuffer()); + + const title = (form.get('title') as string | null) ?? undefined; + const notes = (form.get('notes') as string | null) ?? undefined; + const signerNamesRaw = (form.get('signerNames') as string | null) ?? ''; + const signerNames = signerNamesRaw + ? signerNamesRaw + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : undefined; + const signedAtRaw = (form.get('signedAt') as string | null) ?? null; + const signedAt = signedAtRaw ? new Date(signedAtRaw) : undefined; + if (signedAt && Number.isNaN(signedAt.getTime())) { + throw new ValidationError('Invalid signedAt'); + } + + const result = await uploadExternallySignedEoi({ + interestId, + portId: ctx.portId, + fileData: { + buffer, + originalName: file.name, + mimeType: file.type || 'application/pdf', + size: file.size, + }, + title, + signedAt, + signerNames, + notes, + meta: { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + }); + + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/interests/external-eoi-upload-dialog.tsx b/src/components/interests/external-eoi-upload-dialog.tsx new file mode 100644 index 0000000..01fb54f --- /dev/null +++ b/src/components/interests/external-eoi-upload-dialog.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader2, Upload } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +interface Props { + open: boolean; + onOpenChange: (next: boolean) => void; + interestId: string; + onSuccess?: () => void; +} + +export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSuccess }: Props) { + const qc = useQueryClient(); + const [file, setFile] = useState(null); + const [title, setTitle] = useState(''); + const [signedAt, setSignedAt] = useState(() => new Date().toISOString().slice(0, 10)); + const [signerNames, setSignerNames] = useState(''); + const [notes, setNotes] = useState(''); + + const mutation = useMutation({ + mutationFn: async () => { + if (!file) throw new Error('No file selected'); + const form = new FormData(); + form.append('file', file); + if (title) form.append('title', title); + if (signedAt) form.append('signedAt', signedAt); + if (signerNames) form.append('signerNames', signerNames); + if (notes) form.append('notes', notes); + const res = await fetch(`/api/v1/interests/${interestId}/external-eoi`, { + method: 'POST', + body: form, + credentials: 'include', + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Upload failed' })); + throw new Error(err.error ?? 'Upload failed'); + } + return res.json(); + }, + onSuccess: () => { + toast.success('External EOI uploaded — interest advanced to EOI Signed'); + qc.invalidateQueries({ queryKey: ['interests', interestId] }); + qc.invalidateQueries({ queryKey: ['interests'] }); + qc.invalidateQueries({ queryKey: ['documents'] }); + setFile(null); + setTitle(''); + setSignerNames(''); + setNotes(''); + onOpenChange(false); + onSuccess?.(); + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Upload failed'); + }, + }); + + return ( + + + + Upload externally-signed EOI + + For EOIs signed outside Documenso (paper, in person, alternate e-sign vendor). The + uploaded PDF is filed against this interest and the pipeline stage is advanced to EOI + Signed. + + + +
+
+ + setFile(e.target.files?.[0] ?? null)} + className="mt-1" + /> +
+
+ + setTitle(e.target.value)} + placeholder="Defaults to 'External EOI - '" + className="mt-1" + /> +
+
+ + setSignedAt(e.target.value)} + className="mt-1" + /> +
+
+ + setSignerNames(e.target.value)} + placeholder="e.g. John Smith, Marina Director" + className="mt-1" + /> +

+ Recorded in the audit trail alongside the document. +

+
+
+ +