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