Files
pn-new-crm/src/lib/services/document-templates.ts
Matt cd6b19e173 feat(eoi-generate): Include-yacht toggle to omit Section 3 when yacht is a placeholder
EoiGenerateDialog gains an inline "Include on EOI" checkbox in the
Section 3 header (renders only when ctx.yacht is set; defaults ON so
existing behaviour is unchanged). When OFF, the generate-and-sign POST
flips includeYachtDetails=false on the body; service blanks
eoiContext.yacht before either pathway runs:

- Documenso template payload: buildDocumensoPayload reads no yacht so
  yacht.* and owner.* merge fields ship empty. Existing template tolerates
  blanks per the "left blank if absent" copy.
- In-app PDF fill (pdf-lib): generateEoiPdfFromTemplate sees no yacht so
  AcroForm field writes for the yacht block are skipped.

Persists the rep's choice in the document-create audit log
(metadata.includeYachtDetails) so an audit trail records explicit opt-outs
even though documents has no JSONB metadata column today.

ft/m unit toggle in the Section 3 header now hides when Include is OFF
(unit choice is meaningless without yacht details).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:11:19 +02:00

962 lines
38 KiB
TypeScript

import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { formatDate } from '@/lib/utils/format-date';
import { documentTemplates, documents, documentSigners, files } from '@/lib/db/schema/documents';
import type { File as DbFile, Document as DbDocument } from '@/lib/db/schema/documents';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { ports } from '@/lib/db/schema/ports';
import { yachts } from '@/lib/db/schema/yachts';
import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage';
import { env } from '@/lib/env';
import { getCountryName } from '@/lib/i18n/countries';
import {
createDocument as documensoCreate,
sendDocument as documensoSend,
generateDocumentFromTemplate as documensoGenerateFromTemplate,
} from '@/lib/services/documenso-client';
import { buildDocumensoPayload, getPortEoiSigners } from '@/lib/services/documenso-payload';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form';
import { MERGE_FIELDS, type MergeFieldCatalog } from '@/lib/templates/merge-fields';
import { buildEoiContext } from '@/lib/services/eoi-context';
import {
applyEoiOverridesBeforeRender,
applyOverridesToContext,
persistDocumentOverrides,
type EoiOverridesInput,
type AppliedOverrides,
} from '@/lib/services/eoi-overrides.service';
import { getPrimaryBerth } from '@/lib/services/interest-berths.service';
import type {
CreateTemplateInput,
UpdateTemplateInput,
ListTemplatesInput,
GenerateInput,
GenerateAndSignInput,
} from '@/lib/validators/document-templates';
// ─── Types ────────────────────────────────────────────────────────────────────
// ─── Merge Field Definitions ──────────────────────────────────────────────────
export function getMergeFields(): MergeFieldCatalog {
return MERGE_FIELDS;
}
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listTemplates(portId: string, query: ListTemplatesInput) {
const { page, limit, sort, order, search, templateType, isActive } = query;
const filters = [];
if (templateType) {
filters.push(eq(documentTemplates.templateType, templateType));
}
if (isActive !== undefined) {
filters.push(eq(documentTemplates.isActive, isActive));
}
const sortColumn =
sort === 'name'
? documentTemplates.name
: sort === 'templateType'
? documentTemplates.templateType
: sort === 'createdAt'
? documentTemplates.createdAt
: documentTemplates.updatedAt;
return buildListQuery({
table: documentTemplates,
portIdColumn: documentTemplates.portId,
portId,
idColumn: documentTemplates.id,
updatedAtColumn: documentTemplates.updatedAt,
searchColumns: [documentTemplates.name],
searchTerm: search,
filters,
sort: { column: sortColumn, direction: order },
page,
pageSize: limit,
});
}
// ─── Get by ID ────────────────────────────────────────────────────────────────
export async function getTemplateById(id: string, portId: string) {
const template = await db.query.documentTemplates.findFirst({
where: eq(documentTemplates.id, id),
});
if (!template || template.portId !== portId) {
throw new NotFoundError('Document template');
}
return template;
}
// ─── Create ───────────────────────────────────────────────────────────────────
export async function createTemplate(portId: string, data: CreateTemplateInput, meta: AuditMeta) {
const [template] = await db
.insert(documentTemplates)
.values({
portId,
name: data.name,
description: data.description ?? null,
templateType: data.templateType,
bodyHtml: data.bodyHtml,
mergeFields: data.mergeFields ?? [],
isActive: data.isActive ?? true,
createdBy: meta.userId,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'documentTemplate',
entityId: template!.id,
newValue: { name: template!.name, templateType: template!.templateType },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'documentTemplate:created', { templateId: template!.id });
return template!;
}
// ─── Update ───────────────────────────────────────────────────────────────────
export async function updateTemplate(
id: string,
portId: string,
data: UpdateTemplateInput,
meta: AuditMeta,
) {
const existing = await getTemplateById(id, portId);
const { diff } = diffEntity(existing as Record<string, unknown>, data as Record<string, unknown>);
const [updated] = await db
.update(documentTemplates)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(documentTemplates.id, id), eq(documentTemplates.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'documentTemplate',
entityId: id,
oldValue: diff as Record<string, unknown>,
newValue: data as Record<string, unknown>,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'documentTemplate:updated', { templateId: id });
return updated!;
}
// ─── Delete ───────────────────────────────────────────────────────────────────
export async function deleteTemplate(id: string, portId: string, meta: AuditMeta) {
const existing = await getTemplateById(id, portId);
await db
.delete(documentTemplates)
.where(and(eq(documentTemplates.id, id), eq(documentTemplates.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
entityType: 'documentTemplate',
entityId: id,
oldValue: { name: existing.name, templateType: existing.templateType },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'documentTemplate:deleted', { templateId: id });
}
// ─── Resolve Template ─────────────────────────────────────────────────────────
/**
* Interpolates all {{entity.field}} tokens in the template body HTML.
* BR-140: Required merge fields with no value throw ValidationError.
*/
export async function resolveTemplate(
templateId: string,
context: {
clientId?: string;
interestId?: string;
berthId?: string;
portId: string;
},
): Promise<string> {
const template = await getTemplateById(templateId, context.portId);
// Build token→value map from context
const tokenMap: Record<string, string> = {};
// Date tokens
const now = new Date();
tokenMap['{{date.today}}'] = formatDate(now, 'date.medium');
tokenMap['{{date.year}}'] = String(now.getFullYear());
// Port tokens
const port = await db.query.ports.findFirst({ where: eq(ports.id, context.portId) });
if (port) {
tokenMap['{{port.name}}'] = port.name;
tokenMap['{{port.defaultCurrency}}'] = port.defaultCurrency;
}
// ─── EOI-style resolution ───────────────────────────────────────────────────
// If an interestId is provided, prefer the shared buildEoiContext payload so
// that yacht.*, company.*, owner.*, and berth.* tokens all resolve from the
// same denormalised snapshot the PDF/Documenso pipelines use.
// Falls back to the legacy path below if the interest isn't EOI-ready
// (missing yacht or berth), so non-EOI templates still work.
let eoiContextLoaded = false;
if (context.interestId) {
try {
const eoi = await buildEoiContext(context.interestId, context.portId);
eoiContextLoaded = true;
// Client tokens (from EoiContext)
tokenMap['{{client.fullName}}'] = eoi.client.fullName;
tokenMap['{{client.email}}'] = eoi.client.primaryEmail ?? '';
tokenMap['{{client.phone}}'] = eoi.client.primaryPhone ?? '';
tokenMap['{{client.nationality}}'] = eoi.client.nationality ?? '';
// Yacht tokens - `eoi.yacht` is null when no yacht is linked
// (Section 3 of the EOI is optional). Tokens render as empty strings
// in that case so the template still produces output.
tokenMap['{{yacht.name}}'] = eoi.yacht?.name ?? '';
tokenMap['{{yacht.hullNumber}}'] = eoi.yacht?.hullNumber ?? '';
tokenMap['{{yacht.flag}}'] = eoi.yacht?.flag ?? '';
tokenMap['{{yacht.yearBuilt}}'] =
eoi.yacht?.yearBuilt != null ? String(eoi.yacht.yearBuilt) : '';
tokenMap['{{yacht.lengthFt}}'] = eoi.yacht?.lengthFt ?? '';
tokenMap['{{yacht.widthFt}}'] = eoi.yacht?.widthFt ?? '';
tokenMap['{{yacht.draftFt}}'] = eoi.yacht?.draftFt ?? '';
tokenMap['{{yacht.lengthM}}'] = eoi.yacht?.lengthM ?? '';
tokenMap['{{yacht.widthM}}'] = eoi.yacht?.widthM ?? '';
tokenMap['{{yacht.draftM}}'] = eoi.yacht?.draftM ?? '';
// EoiContext doesn't expose the yacht.registration column - look it up
// separately (cheap, indexed fetch) so the token resolves when present.
try {
const interestRow = await db.query.interests.findFirst({
where: eq(interests.id, context.interestId),
columns: { yachtId: true },
});
if (interestRow?.yachtId) {
const yachtRow = await db.query.yachts.findFirst({
where: eq(yachts.id, interestRow.yachtId),
columns: { registration: true },
});
tokenMap['{{yacht.registration}}'] = yachtRow?.registration ?? '';
} else {
tokenMap['{{yacht.registration}}'] = '';
}
} catch {
tokenMap['{{yacht.registration}}'] = '';
}
// Company tokens (only populated when owner is a company)
tokenMap['{{company.name}}'] = eoi.company?.name ?? '';
tokenMap['{{company.legalName}}'] = eoi.company?.legalName ?? '';
tokenMap['{{company.taxId}}'] = eoi.company?.taxId ?? '';
tokenMap['{{company.billingAddress}}'] = eoi.company?.billingAddress ?? '';
// Owner tokens
tokenMap['{{owner.type}}'] = eoi.owner.type;
tokenMap['{{owner.name}}'] = eoi.owner.name;
tokenMap['{{owner.legalName}}'] = eoi.owner.legalName ?? '';
// Berth tokens - also optional. Render empty when no berth is linked.
tokenMap['{{berth.mooringNumber}}'] = eoi.berth?.mooringNumber ?? '';
tokenMap['{{berth.area}}'] = eoi.berth?.area ?? '';
tokenMap['{{berth.lengthFt}}'] = eoi.berth?.lengthFt ?? '';
tokenMap['{{berth.price}}'] = eoi.berth?.price ?? '';
tokenMap['{{berth.priceCurrency}}'] = eoi.berth?.priceCurrency ?? '';
tokenMap['{{berth.tenureType}}'] = eoi.berth?.tenureType ?? '';
// Interest tokens
tokenMap['{{interest.stage}}'] = eoi.interest.stage;
tokenMap['{{interest.leadCategory}}'] = eoi.interest.leadCategory ?? '';
tokenMap['{{interest.berthNumber}}'] = eoi.berth?.mooringNumber ?? '';
tokenMap['{{interest.dateFirstContact}}'] = eoi.interest.dateFirstContact
? formatDate(eoi.interest.dateFirstContact, 'date.medium')
: '';
tokenMap['{{interest.notes}}'] = eoi.interest.notes ?? '';
} catch (err) {
// buildEoiContext throws ValidationError when the EOI's required client
// fields (name/email/address - Section 2) are missing. For non-EOI
// templates (correspondence, welcome letters, etc.) those gates don't
// apply - fall through to the legacy resolution path below. Re-throw
// anything else.
if (
!(err instanceof ValidationError) ||
!/missing required client details|interest has no (yacht|berth)/i.test(err.message)
) {
throw err;
}
}
}
// ─── Legacy / non-EOI fallback ──────────────────────────────────────────────
// Client tokens from direct client lookup (welcome letters, correspondence,
// or EOI-flow clients where we still want client.source to resolve).
if (context.clientId) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, context.clientId),
});
if (client && client.portId === context.portId) {
// Always resolve source from the DB - EoiContext doesn't carry it.
if (tokenMap['{{client.source}}'] === undefined) {
tokenMap['{{client.source}}'] = client.source ?? '';
}
// Only fill client.* tokens if the EOI path didn't already populate them.
if (!eoiContextLoaded) {
const contactList = await db.query.clientContacts.findMany({
where: eq(clientContacts.clientId, context.clientId),
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
});
const emailContact = contactList.find((c) => c.channel === 'email');
const phoneContact = contactList.find(
(c) => c.channel === 'phone' || c.channel === 'whatsapp',
);
tokenMap['{{client.fullName}}'] = client.fullName ?? '';
tokenMap['{{client.email}}'] = emailContact?.value ?? '';
tokenMap['{{client.phone}}'] = phoneContact?.value ?? '';
tokenMap['{{client.nationality}}'] = client.nationalityIso
? getCountryName(client.nationalityIso, 'en')
: '';
}
}
}
// Interest tokens (legacy path - fills in fields EoiContext doesn't expose,
// like eoiStatus / dateEoiSigned / dateContractSigned, or populates the
// whole interest.* block when EOI resolution was skipped).
if (context.interestId) {
const interest = await db.query.interests.findFirst({
where: eq(interests.id, context.interestId),
});
if (interest && interest.portId === context.portId) {
if (!eoiContextLoaded) {
tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? '';
tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? '';
tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact
? formatDate(interest.dateFirstContact, 'date.medium', { fallback: '' })
: '';
// `{{interest.notes}}` is now sourced from the threaded
// interest_notes timeline via EoiContext.interest.notes; this
// shallow-fallback path leaves the token blank if EoiContext
// wasn't loaded for this template render.
tokenMap['{{interest.notes}}'] = '';
}
// These are never populated by EoiContext - always fill them in.
tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? '';
tokenMap['{{interest.dateEoiSigned}}'] = interest.dateEoiSigned
? formatDate(interest.dateEoiSigned, 'date.medium', { fallback: '' })
: '';
tokenMap['{{interest.dateContractSigned}}'] = interest.dateContractSigned
? formatDate(interest.dateContractSigned, 'date.medium', { fallback: '' })
: '';
// Derive berth number from the interest when berthId wasn't passed and
// the EOI path didn't already populate it. Resolves through the
// interest_berths junction (plan §3.4) - the legacy interest.berth_id
// column has been removed.
const interestPrimaryBerth =
!eoiContextLoaded && !context.berthId ? await getPrimaryBerth(interest.id) : null;
if (!eoiContextLoaded && interestPrimaryBerth?.berthId && !context.berthId) {
if (interestPrimaryBerth.mooringNumber) {
tokenMap['{{interest.berthNumber}}'] = interestPrimaryBerth.mooringNumber;
if (!tokenMap['{{berth.mooringNumber}}']) {
tokenMap['{{berth.mooringNumber}}'] = interestPrimaryBerth.mooringNumber;
}
} else {
tokenMap['{{interest.berthNumber}}'] ??= '';
}
} else if (!eoiContextLoaded) {
tokenMap['{{interest.berthNumber}}'] ??= context.berthId
? (tokenMap['{{berth.mooringNumber}}'] ?? '')
: '';
}
}
}
// Berth tokens (legacy path - when a berthId is passed directly and EOI
// resolution didn't already populate the berth block).
if (context.berthId && !eoiContextLoaded) {
const berth = await db.query.berths.findFirst({
where: eq(berths.id, context.berthId),
});
if (berth && berth.portId === context.portId) {
tokenMap['{{berth.mooringNumber}}'] = berth.mooringNumber;
tokenMap['{{berth.area}}'] = berth.area ?? '';
tokenMap['{{berth.status}}'] = berth.status;
tokenMap['{{berth.price}}'] = berth.price ? String(berth.price) : '';
tokenMap['{{berth.priceCurrency}}'] = berth.priceCurrency;
tokenMap['{{berth.lengthFt}}'] = berth.lengthFt ? String(berth.lengthFt) : '';
tokenMap['{{berth.widthFt}}'] = berth.widthFt ? String(berth.widthFt) : '';
tokenMap['{{berth.tenureType}}'] = berth.tenureType;
tokenMap['{{berth.tenureYears}}'] = berth.tenureYears ? String(berth.tenureYears) : '';
tokenMap['{{interest.berthNumber}}'] = berth.mooringNumber;
}
}
// BR-140: Check required merge fields have values
const missing: string[] = [];
for (const [, fields] of Object.entries(MERGE_FIELDS)) {
for (const field of fields) {
if (field.required) {
const value = tokenMap[field.token];
if (value !== undefined && value.trim() === '') {
missing.push(field.label);
}
}
}
}
if (missing.length > 0) {
throw new ValidationError(`Missing required merge field values: ${missing.join(', ')}`);
}
// HTML body is required for the html template format; non-html formats
// resolve elsewhere (see template_format dispatch in PR6).
if (template.bodyHtml === null) {
throw new ValidationError('Template has no HTML body to render');
}
// Interpolate all tokens
let resolved: string = template.bodyHtml;
for (const [token, value] of Object.entries(tokenMap)) {
// Escape token for use in regex
const escaped = token.replace(/[{}]/g, '\\$&');
resolved = resolved.replace(new RegExp(escaped, 'g'), value);
}
return resolved;
}
// ─── Generate from template (REMOVED) ─────────────────────────────────────────
//
// The in-app TipTap-to-PDF rendering path (`generateFromTemplate` and the
// public `generateAndSend` wrapper) was removed in the PDF stack overhaul
// (see docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md).
// Only the EOI in-app pathway survives, and it renders via pdf-lib AcroForm
// fill on the source PDF (`generateEoiFromSourcePdf` below). All other
// template types must go through Documenso.
//
// The old API routes `/api/v1/document-templates/[id]/generate` and
// `/api/v1/document-templates/[id]/generate-and-send` have been deleted.
// `generateAndSign` (the EOI signing entry point) now throws a clear
// ValidationError when a non-EOI template is requested through the in-app
// pathway.
// ─── EOI from source PDF (in-app pathway, EOI templates only) ─────────────────
/**
* BR-142: For EOI templates, the in-app pathway uses the same source PDF as
* the Documenso template - filled via pdf-lib with values from EoiContext.
* Same field names, same legal document; the only difference is who renders
* it. The form is left interactive so a recipient can adjust before signing.
*/
async function generateEoiFromSourcePdf(
template: typeof documentTemplates.$inferSelect,
portId: string,
context: GenerateInput,
meta: AuditMeta,
options?: { dimensionUnit?: 'ft' | 'm'; includeYachtDetails?: boolean },
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
): Promise<{ document: DbDocument; file: DbFile }> {
if (!context.interestId) {
throw new ValidationError('interestId is required for EOI template generation');
}
const eoiContext = applyOverridesToContext(
await buildEoiContext(context.interestId, portId),
applied,
);
// Rep opted out of Section 3 — blank the yacht slot so the AcroForm fill
// skips writing the yacht.* / owner.* fields (matching the Documenso
// pathway).
if (options?.includeYachtDetails === false) {
eoiContext.yacht = null;
}
const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, {
dimensionUnit: options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft',
});
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(
port?.slug ?? portId,
'document-templates',
template.id,
fileId,
'pdf',
);
{
const buffer = Buffer.from(pdfBytes);
const backend = await getStorageBackend();
await backend.put(storagePath, buffer, {
contentType: 'application/pdf',
sizeBytes: buffer.length,
});
}
const [fileRecord] = await db
.insert(files)
.values({
portId,
clientId: context.clientId ?? null,
filename: `${template.name.toLowerCase().replace(/\s+/g, '-')}.pdf`,
originalName: `${template.name}.pdf`,
mimeType: 'application/pdf',
sizeBytes: String(pdfBytes.byteLength),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'eoi',
uploadedBy: meta.userId,
})
.returning();
const [documentRecord] = await db
.insert(documents)
.values({
portId,
clientId: context.clientId ?? null,
interestId: context.interestId,
documentType: template.templateType,
title: template.name,
status: 'draft',
fileId: fileRecord!.id,
isManualUpload: false,
createdBy: meta.userId,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'document',
entityId: documentRecord!.id,
newValue: {
templateId: template.id,
templateName: template.name,
source: 'eoi-source-pdf',
clientId: context.clientId,
interestId: context.interestId,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:created', { documentId: documentRecord!.id });
return { document: documentRecord!, file: fileRecord! };
}
// ─── Generate and Sign ────────────────────────────────────────────────────────
/**
* BR-142: EOI / NDA signing. Dual pathway:
* - `inapp`: produce the PDF locally (EOI templates fill the same source
* PDF as Documenso via pdf-lib AcroForm; other template types fall
* back to the @react-pdf/renderer path), upload to MinIO, then upload
* to Documenso and send for signing.
* - `documenso-template`: skip our PDF generation entirely; call Documenso's
* template-generate endpoint with the shared EOI context. Documenso owns
* the PDF. We still record a `documents` row for tracking.
*/
export async function generateAndSign(
templateId: string | null,
portId: string,
context: GenerateInput,
signers: GenerateAndSignInput['signers'],
pathway: 'inapp' | 'documenso-template',
meta: AuditMeta,
options?: {
dimensionUnit?: 'ft' | 'm';
overrides?: EoiOverridesInput;
/** False = blank out Section 3 (yacht.* + owner.* merge fields) even
* when the interest carries a linked yacht. True (or unset) keeps the
* current behaviour (auto-fill from yacht record). */
includeYachtDetails?: boolean;
},
) {
// Phase 3b - apply per-field overrides BEFORE either pathway resolves the
// EOI context, so any setAsDefault contact promotion is visible to the
// buildEoiContext read. The returned `applied.resolved` is layered onto
// the in-memory context for useOnlyForThisEoi / fresh-value cases where
// the canonical record isn't being touched.
const applied = context.interestId
? await applyEoiOverridesBeforeRender(portId, context.interestId, options?.overrides, meta)
: { resolved: {}, documentOverrideColumns: {} };
if (pathway === 'documenso-template') {
return generateAndSignViaDocumensoTemplate(portId, context, meta, options, applied);
}
if (!templateId) {
throw new ValidationError('templateId is required for inapp pathway');
}
return generateAndSignViaInApp(templateId, portId, context, signers, meta, options, applied);
}
async function generateAndSignViaInApp(
templateId: string,
portId: string,
context: GenerateInput,
signers: GenerateAndSignInput['signers'],
meta: AuditMeta,
options?: { dimensionUnit?: 'ft' | 'm'; includeYachtDetails?: boolean },
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
) {
const template = await getTemplateById(templateId, portId);
// For EOI templates, signers default to the same set the Documenso template
// pathway uses (interest's client + hardcoded developer + approver), so the
// UI doesn't need to collect them. Non-EOI templates still require explicit
// signers since they have no canonical recipient list.
let resolvedSigners = signers;
if ((!resolvedSigners || resolvedSigners.length === 0) && template.templateType === 'eoi') {
if (!context.interestId) {
throw new ValidationError(
'interestId is required when generating an EOI without explicit signers',
);
}
const eoiCtx = await buildEoiContext(context.interestId, portId);
const signers = await getPortEoiSigners(portId);
resolvedSigners = [
{
name: eoiCtx.client.fullName,
email: eoiCtx.client.primaryEmail ?? '',
role: 'signer',
signingOrder: 1,
},
{
name: signers.developer.name,
email: signers.developer.email,
role: 'signer',
signingOrder: 2,
},
{
name: signers.approver.name,
email: signers.approver.email,
role: 'approver',
signingOrder: 3,
},
];
}
if (!resolvedSigners || resolvedSigners.length === 0) {
throw new ValidationError('signers are required for inapp pathway');
}
// EOI templates fill the same source PDF as the Documenso template (so both
// pathways yield the same document). The HTML→pdfme rendering path for
// non-EOI templates was removed in the PDF stack overhaul (see the design
// spec). Send non-EOI documents via the Documenso pathway, OR - once it
// ships - the admin-uploaded AcroForm-fill template feature.
if (template.templateType !== 'eoi') {
throw new ValidationError(
`In-app PDF rendering for templates of type "${template.templateType}" is not supported. ` +
'Use a Documenso template, or upload a custom PDF (AcroForm-fill feature is deferred).',
);
}
const { document: documentRecord, file } = await generateEoiFromSourcePdf(
template,
portId,
context,
meta,
options,
applied,
);
// Phase 3b - record per-document override columns + backfill the
// source_document_id on any client_contacts rows inserted during the
// override side-effects.
await persistDocumentOverrides(documentRecord.id, applied, meta);
// Fetch PDF bytes from the active storage backend to send to Documenso.
const pdfStream = await (await getStorageBackend()).get(file.storagePath);
const chunks: Buffer[] = [];
for await (const chunk of pdfStream) {
if (Buffer.isBuffer(chunk)) chunks.push(chunk);
else if (typeof chunk === 'string') chunks.push(Buffer.from(chunk));
else chunks.push(Buffer.from(chunk as Uint8Array));
}
const pdfBase64 = Buffer.concat(chunks).toString('base64');
// Create Documenso document
const documensoDoc = await documensoCreate(
template.name,
pdfBase64,
resolvedSigners.map((s) => ({
name: s.name,
email: s.email,
role: s.role,
signingOrder: s.signingOrder,
})),
);
// Send document for signing
await documensoSend(documensoDoc.id);
// Update our document record with Documenso ID and status
await db
.update(documents)
.set({
documensoId: documensoDoc.id,
documensoNumericId: documensoDoc.numericId,
status: 'sent',
updatedAt: new Date(),
})
.where(eq(documents.id, documentRecord.id));
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'document',
entityId: documentRecord.id,
newValue: { status: 'sent', documensoId: documensoDoc.id },
metadata: {
action: 'generate_and_sign',
pathway: 'inapp',
signerCount: resolvedSigners.length,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:updated', {
documentId: documentRecord.id,
changedFields: ['status', 'documensoId'],
});
return { document: { ...documentRecord, documensoId: documensoDoc.id, status: 'sent' }, file };
}
async function generateAndSignViaDocumensoTemplate(
portId: string,
context: GenerateInput,
meta: AuditMeta,
options?: { dimensionUnit?: 'ft' | 'm'; includeYachtDetails?: boolean },
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
) {
if (!context.interestId) {
throw new ValidationError('interestId is required for documenso-template pathway');
}
const eoiContext = applyOverridesToContext(
await buildEoiContext(context.interestId, portId),
applied,
);
// Rep opted out of Section 3 (yacht details) — blank the yacht slot so
// buildDocumensoPayload + the EOI template see "no yacht linked" and
// leave yacht.* / owner.* merge fields empty. Persisted in document
// metadata below for audit (kind: 'eoi_include_yacht_details=false').
const yachtDeclined = options?.includeYachtDetails === false;
if (yachtDeclined) {
eoiContext.yacht = null;
}
const signers = await getPortEoiSigners(portId);
// Per-port Documenso template + recipient IDs (with env fallback). Each
// tenant pointing at its own Documenso instance has different numeric
// template + recipient IDs, so a global env-only setup limits the
// platform to one Documenso instance per CRM process.
const docCfg = await getPortDocumensoConfig(portId);
// v2 prefillFields-by-ID emission requires a field-name → field-ID map
// populated by the admin "Sync from Documenso" button. Absent (or partial)
// map → payload skips prefillFields and v2 accepts the legacy formValues
// shape via backward compat.
const { getEoiFieldMap } = await import('@/lib/services/documenso-template-sync.service');
const fieldMap = await getEoiFieldMap(portId);
// Pick which side of the yacht's stored dimensions ships to Documenso.
// The drawer's toggle drives this; if the caller omitted it, default to
// whichever unit the rep originally typed in (yacht.lengthUnit). Legacy
// yachts without a unit column default to 'ft'.
const dimensionUnit: 'ft' | 'm' = options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft';
const payload = buildDocumensoPayload(
eoiContext,
{
interestId: context.interestId,
clientRecipientId: docCfg.clientRecipientId,
developerRecipientId: docCfg.developerRecipientId,
approvalRecipientId: docCfg.approvalRecipientId,
developerName: signers.developer.name,
developerEmail: signers.developer.email,
approverName: signers.approver.name,
approverEmail: signers.approver.email,
// Prefer per-port post-signing redirect (typically marketing-site
// /sign/success on v2). Falls back to APP_URL on v1 / when unset.
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
// v2-only signing-order enforcement. v1 instances ignore this key.
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
dimensionUnit,
},
fieldMap,
);
const documensoDoc = await documensoGenerateFromTemplate(
docCfg.eoiTemplateId,
payload as unknown as Record<string, unknown>,
portId,
);
// Record a documents row referencing the Documenso document. No local file -
// Documenso owns the PDF and delivers signed copies via webhook (handled elsewhere).
const [documentRecord] = await db
.insert(documents)
.values({
portId,
clientId: context.clientId ?? null,
interestId: context.interestId,
documentType: 'eoi',
title: payload.title,
status: 'sent',
documensoId: documensoDoc.id,
documensoNumericId: documensoDoc.numericId,
isManualUpload: false,
createdBy: meta.userId,
})
.returning();
// Phase 3b - record any per-document override columns + backfill
// source_document_id on freshly inserted contact rows.
await persistDocumentOverrides(documentRecord!.id, applied, meta);
// Persist the per-recipient signer rows from Documenso's create response.
// Without these the EOI tab's "Signing progress" panel shows
// "No signers loaded" forever (the webhook handler only updates existing
// rows by token / email). Each row maps a Documenso recipient slot to
// a CRM document-signer record.
if (documensoDoc.recipients.length > 0) {
await db.insert(documentSigners).values(
documensoDoc.recipients.map((r) => {
// Strip the `(was: <email>)` suffix that `applyRecipientRedirect`
// bakes into recipient names when EMAIL_REDIRECT_TO is on. Without
// this, every downstream surface (email greeting, signing-progress
// card, document-detail page) leaks "Matt Ciaccio (was: matt@...)"
// into reps' faces. Display-only cleanup; the original email is
// still recoverable via the redirect helper.
const cleanName = (r.name || r.email)
.replace(/\s*\(was:[^)]*\)/i, '')
.replace(/\s*\(placeholder\)/i, '')
.replace(/\s*\(placeholder\b[^)]*\)/i, '')
.trim();
// signingOrder 1 with role SIGNER is always the CLIENT in our trio
// (Client → Developer → Approver). Without this special-case the
// role gets stored as 'signer' for the client too, and the email
// template's `isClient` branch wrongly tells the client "you're
// the next signer; the client has already signed."
const role =
r.role.toUpperCase() === 'SIGNER' && r.signingOrder === 1
? 'client'
: normalizeSignerRole(r.role);
return {
documentId: documentRecord!.id,
signerName: cleanName || r.email,
signerEmail: r.email,
signerRole: role,
signingOrder: r.signingOrder,
status: 'pending' as const,
signingUrl: r.signingUrl ?? null,
embeddedUrl: r.embeddedUrl ?? null,
signingToken: r.token ?? null,
// invitedAt deliberately left null at create time. The
// send-invitation route stamps it once the branded invite goes
// out. Pre-stamping would mis-label the signer card as
// "Invited just now" in manual send mode.
invitedAt: null,
};
}),
);
}
// Stamp the interest's EOI milestone so the Overview tab flips the
// "Generate EOI" prompt to the "EOI sent / awaiting signatures" state
// immediately. Cache-invalidation on the client picks the new shape up
// via the document-templates POST's onSuccess.
await db
.update(interests)
.set({
eoiDocStatus: 'sent',
dateEoiSent: new Date(),
updatedAt: new Date(),
})
.where(eq(interests.id, context.interestId));
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'document',
entityId: documentRecord!.id,
newValue: { documensoId: documensoDoc.id, status: 'sent' },
metadata: {
action: 'generate_and_sign',
pathway: 'documenso-template',
templateId: env.DOCUMENSO_TEMPLATE_ID_EOI,
interestId: context.interestId,
// Rep's explicit Section-3 choice. Audit-only — Docs row has no
// metadata jsonb; the blanked yacht.* / owner.* merge fields on the
// generated PDF are the user-visible evidence.
includeYachtDetails: !yachtDeclined,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:created', { documentId: documentRecord!.id });
return { document: documentRecord!, file: null };
}
/**
* Documenso recipient roles arrive as ALL-CAPS strings ('SIGNER' | 'APPROVER'
* | 'CC' | 'VIEWER'); the CRM's `document_signers.signer_role` column uses
* the lowercase domain vocabulary ('client' | 'developer' | 'approver' |
* 'cc' | 'viewer' | 'other'). Map them so the UI's progress panel renders
* the right label per row. SIGNER → developer is a safe default because
* the client slot is identified positionally elsewhere (signingOrder=1
* always).
*/
function normalizeSignerRole(documensoRole: string): string {
const r = documensoRole.toUpperCase();
if (r === 'APPROVER') return 'approver';
if (r === 'CC') return 'cc';
if (r === 'VIEWER') return 'viewer';
return 'signer';
}