feat(interests): upload externally-signed EOI (paper / non-Documenso)

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) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 18:33:15 +02:00
parent 789656bc70
commit 8c02f88cbd
4 changed files with 419 additions and 0 deletions

View File

@@ -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);
}
}),
);

View File

@@ -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<File | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload externally-signed EOI</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<Label>PDF file *</Label>
<Input
type="file"
accept="application/pdf,.pdf"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="mt-1"
/>
</div>
<div>
<Label>Title (optional)</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Defaults to 'External EOI - <date>'"
className="mt-1"
/>
</div>
<div>
<Label>Date signed</Label>
<Input
type="date"
value={signedAt}
onChange={(e) => setSignedAt(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Signer names (comma-separated)</Label>
<Input
value={signerNames}
onChange={(e) => setSignerNames(e.target.value)}
placeholder="e.g. John Smith, Marina Director"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
Recorded in the audit trail alongside the document.
</p>
</div>
<div>
<Label>Notes (optional)</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Where / how this EOI was signed"
rows={2}
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button onClick={() => mutation.mutate()} disabled={!file || mutation.isPending}>
{mutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
) : (
<Upload className="h-3.5 w-3.5 mr-1.5" />
)}
Upload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -13,6 +13,7 @@ import {
MessageCircle,
Phone,
AlarmClock,
Upload,
} from 'lucide-react';
import Link from 'next/link';
@@ -23,6 +24,7 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { InlineStagePicker } from '@/components/interests/inline-stage-picker';
import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog';
import { apiFetch } from '@/lib/api/client';
@@ -102,6 +104,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
const [externalEoiOpen, setExternalEoiOpen] = useState(false);
const isArchived = !!interest.archivedAt;
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
@@ -376,6 +379,20 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</>
)}
</PermissionGate>
<PermissionGate resource="documents" action="upload_signed">
<button
type="button"
onClick={() => setExternalEoiOpen(true)}
aria-label="Upload externally-signed EOI"
title="Upload externally-signed EOI (paper / outside Documenso)"
className={cn(
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
'hover:bg-foreground/5 hover:text-foreground',
)}
>
<Upload className="size-4" />
</button>
</PermissionGate>
<PermissionGate resource="interests" action="edit">
<button
type="button"
@@ -439,6 +456,12 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
}}
isLoading={archiveMutation.isPending || restoreMutation.isPending}
/>
<ExternalEoiUploadDialog
open={externalEoiOpen}
onOpenChange={setExternalEoiOpen}
interestId={interest.id}
/>
</>
);
}

View File

@@ -0,0 +1,178 @@
/**
* External EOI upload — for EOIs signed outside Documenso (paper signing,
* different e-sign vendor, signed in person, etc).
*
* Creates BOTH the document row AND the signed-file record in one shot,
* then advances the interest stage. Distinct from the existing
* uploadSignedManually flow which augments a document row that was
* already created via the Documenso pathway.
*/
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { documents, documentEvents, files } from '@/lib/db/schema/documents';
import { ports } from '@/lib/db/schema/ports';
import { env } from '@/lib/env';
import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { CodedError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
export interface ExternalEoiInput {
interestId: string;
portId: string;
/** PDF bytes. */
fileData: { buffer: Buffer; originalName: string; mimeType: string; size: number };
/** Free-text title for the document row. Defaults to "External EOI - <date>". */
title?: string;
/** When the signing actually happened (the date on the paper / contract). */
signedAt?: Date;
/** Names of the people who signed (free-text — we don't manage signer
* identities for external sigs). Recorded in metadata. */
signerNames?: string[];
/** Free-text note (e.g. "signed in person at boat show"). */
notes?: string;
meta: AuditMeta;
}
export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
const { interestId, portId, fileData, meta } = input;
if (fileData.size <= 0) throw new ValidationError('Empty file');
if (fileData.size > 25 * 1024 * 1024) {
throw new ValidationError('File too large (max 25 MB)');
}
if (
fileData.mimeType !== 'application/pdf' &&
!fileData.originalName.toLowerCase().endsWith('.pdf')
) {
throw new ValidationError('Only PDF uploads are accepted for signed EOIs');
}
const interest = await db.query.interests.findFirst({
where: eq(interests.id, interestId),
});
if (!interest || interest.portId !== portId) throw new NotFoundError('Interest');
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
if (!port) throw new NotFoundError('Port');
const documentId = crypto.randomUUID();
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf');
await (
await getStorageBackend()
).put(storagePath, fileData.buffer, {
contentType: 'application/pdf',
sizeBytes: fileData.size,
});
const [fileRecord] = await db
.insert(files)
.values({
portId,
clientId: interest.clientId,
filename: fileData.originalName,
originalName: fileData.originalName,
mimeType: 'application/pdf',
sizeBytes: String(fileData.size),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'eoi',
uploadedBy: meta.userId,
})
.returning();
if (!fileRecord) {
throw new CodedError('INSERT_RETURNING_EMPTY', {
internalMessage: 'External EOI file insert returned no row',
});
}
const title =
input.title ?? `External EOI — ${(input.signedAt ?? new Date()).toISOString().slice(0, 10)}`;
const [doc] = await db
.insert(documents)
.values({
id: documentId,
portId,
interestId,
clientId: interest.clientId,
yachtId: interest.yachtId,
documentType: 'eoi',
title,
status: 'completed',
isManualUpload: true,
signedFileId: fileRecord.id,
notes: input.notes ?? null,
createdBy: meta.userId,
})
.returning();
if (!doc) {
throw new CodedError('INSERT_RETURNING_EMPTY', {
internalMessage: 'External EOI document insert returned no row',
});
}
await db.insert(documentEvents).values({
documentId: doc.id,
eventType: 'completed',
eventData: {
isManualUpload: true,
external: true,
signerNames: input.signerNames ?? [],
signedAt: (input.signedAt ?? new Date()).toISOString(),
fileId: fileRecord.id,
},
});
// Advance the interest stage to eoi_signed (no-op if already past it).
// We bypass canTransitionStage explicitly because the operator just
// brought concrete proof that the EOI is signed — that's higher
// confidence than a normal forward-jump.
if (
interest.pipelineStage === 'open' ||
interest.pipelineStage === 'details_sent' ||
interest.pipelineStage === 'in_communication' ||
interest.pipelineStage === 'eoi_sent'
) {
await db
.update(interests)
.set({
pipelineStage: 'eoi_signed',
eoiStatus: 'signed',
dateEoiSigned: input.signedAt ?? new Date(),
updatedAt: new Date(),
})
.where(eq(interests.id, interestId));
} else {
// Past eoi_signed — just record the document, don't touch stage.
await db.update(interests).set({ updatedAt: new Date() }).where(eq(interests.id, interestId));
}
void createAuditLog({
portId,
userId: meta.userId,
action: 'create',
entityType: 'document',
entityId: doc.id,
metadata: {
kind: 'external_eoi_upload',
interestId,
title,
signerNames: input.signerNames ?? [],
signedAt: (input.signedAt ?? new Date()).toISOString(),
fileSizeBytes: fileData.size,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:completed', { documentId: doc.id });
return { documentId: doc.id, fileId: fileRecord.id };
}