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. +

+
+
+ +