Files
pn-new-crm/src/lib/services/external-eoi.service.ts
Matt 301375a3c3 feat(uat-batch-6): external-EOI structured signatories + X/Y signed counter
Replace the freetext CSV signer-names field with a structured recipient
editor (name / email / role per row). Service now persists each
non-CC signatory as a `document_signers` row pre-stamped
`status='signed'` so the document-detail "X / Y signed" badge counts
correctly for manually-uploaded EOIs.

- ExternalEoiInput gains a structured `signatories` field; legacy
  `signerNames` retained for back-compat. Role enum:
  `client | developer | rep | witness | cc`.
- uploadExternallySignedEoi inserts `document_signers` rows for every
  non-CC entry inside the existing transaction.
- documentEvents.completed event records both shapes for full audit
  fidelity.
- POST /api/v1/interests/[id]/external-eoi parses the `signatories`
  JSON multipart field defensively; malformed payloads fall back to
  signerNames.
- Dialog UI: per-row Name / Email / Role inputs with add / remove.
  Seeds from interest's clientName + clientPrimaryEmail via a
  signatoriesOverride/null pattern (React-Compiler safe — no
  setState-in-effect).

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:34:59 +02:00

248 lines
8.8 KiB
TypeScript

/**
* 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, documentSigners, 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';
/** A single signatory on an externally-signed document. */
export interface ExternalSignatory {
name: string;
email: string;
/** Drives auto-fill on the dialog + downstream "Email copy" recipient
* list. `cc` recipients aren't signers but show up on send-out lists. */
role: 'client' | 'developer' | 'rep' | 'witness' | 'cc';
}
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;
/**
* Structured signatory list — preferred over the legacy `signerNames`
* CSV. When present, the service inserts one `document_signers` row
* per non-CC entry pre-stamped `status='signed'` so the
* "X / Y signed" badge renders correctly downstream.
*/
signatories?: ExternalSignatory[];
/** Legacy CSV form kept for backwards compat with older callers. */
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');
// Upload to storage FIRST so we have a stable key for the DB rows,
// then commit all four DB writes in one transaction. If the tx fails
// the storage object becomes orphaned (S3 isn't transactional) but
// the DB stays clean — orphan reaper handles those.
await (
await getStorageBackend()
).put(storagePath, fileData.buffer, {
contentType: 'application/pdf',
sizeBytes: fileData.size,
});
const title =
input.title ?? `External EOI — ${(input.signedAt ?? new Date()).toISOString().slice(0, 10)}`;
const result = await db.transaction(async (tx) => {
const [fileRecord] = await tx
.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 [doc] = await tx
.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',
});
}
// Backfill document_signers rows for the structured signatory list
// so the document-detail "X / Y signed" badge counts correctly. CC
// recipients aren't signatories — they're recipients of the email
// copy and don't show up in the signed-count denominator.
const signedAtMoment = input.signedAt ?? new Date();
const signerRows = (input.signatories ?? [])
.filter((s) => s.role !== 'cc')
.map((s, i) => ({
documentId: doc.id,
signerName: s.name,
signerEmail: s.email,
signerRole: s.role,
signingOrder: i + 1,
status: 'signed' as const,
signedAt: signedAtMoment,
}));
if (signerRows.length > 0) {
await tx.insert(documentSigners).values(signerRows);
}
await tx.insert(documentEvents).values({
documentId: doc.id,
eventType: 'completed',
eventData: {
isManualUpload: true,
external: true,
signerNames: input.signerNames ?? (input.signatories ?? []).map((s) => s.name),
signatories: input.signatories ?? null,
signedAt: signedAtMoment.toISOString(),
fileId: fileRecord.id,
},
});
// Two concerns to keep separate:
// 1. Document metadata — always write `dateEoiSigned` + `eoiStatus`
// from the upload. Even if the rep already advanced the stage
// manually, the paper signing event needs a recorded date so
// downstream surfaces (SkipAheadBanner, milestone strip, EOI
// merge fields) reflect reality. Honour an existing
// dateEoiSigned (don't overwrite if already set — covers the
// case where the rep is uploading evidence for an event whose
// date was already backfilled).
// 2. Stage advance — only when the deal hasn't reached eoi_signed
// yet. Bypasses canTransitionStage because the operator just
// brought concrete proof.
const shouldAdvanceStage =
interest.pipelineStage === 'open' ||
interest.pipelineStage === 'details_sent' ||
interest.pipelineStage === 'in_communication' ||
interest.pipelineStage === 'eoi_sent';
await tx
.update(interests)
.set({
dateEoiSigned: interest.dateEoiSigned ?? input.signedAt ?? new Date(),
eoiStatus: 'signed',
...(shouldAdvanceStage ? { pipelineStage: 'eoi_signed' as const } : {}),
updatedAt: new Date(),
})
.where(eq(interests.id, interestId));
return {
documentId: doc.id,
fileId: fileRecord.id,
stageChanged: shouldAdvanceStage,
newStage: shouldAdvanceStage ? ('eoi_signed' as const) : interest.pipelineStage,
};
});
const { documentId: docId, fileId: fId, stageChanged, newStage } = result;
void createAuditLog({
portId,
userId: meta.userId,
action: 'create',
entityType: 'document',
entityId: docId,
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: docId });
// Berth rules engine: a manually-uploaded external EOI is still an
// EOI-signed event for the rules that watch this trigger (e.g.
// auto-mark the primary berth Under Offer). Fire via dynamic import
// to dodge the circular dep between berth-rules-engine and the
// interest services.
try {
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
await evaluateRule('eoi_signed', interestId, portId, meta);
} catch {
// Swallow — rules engine failures should never block the upload
// that the rep has already completed end-to-end. The orphan-reaper
// path doesn't apply; a missed rule evaluation is a soft failure.
}
return { documentId: docId, fileId: fId, stageChanged, newStage };
}