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:
178
src/lib/services/external-eoi.service.ts
Normal file
178
src/lib/services/external-eoi.service.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user