feat(document-templates): delete TipTap-to-pdfme bridge

Phase 1 / commit 12 of 14 — strips out the 571-line tiptap-to-pdfme
serializer and every code path that depended on it. TipTap document
templates remain as Documenso-template seed bodies; the CRM no longer
renders them to PDF in-app.

Deleted:
  src/lib/pdf/tiptap-to-pdfme.ts                                (571 LOC)
  src/lib/pdf/templates/eoi-standard-inapp.ts                   (337 LOC)
  src/app/api/v1/admin/templates/preview/route.ts
  src/app/api/v1/document-templates/[id]/generate/route.ts
  src/app/api/v1/document-templates/[id]/generate-and-send/route.ts
  src/lib/services/document-templates.ts:generateFromTemplate (~140 LOC)
  src/lib/services/document-templates.ts:generateAndSend       (~40 LOC)
  src/lib/validators/document-templates.ts:generateAndSendSchema
  src/lib/validators/document-templates.ts:previewAdminTemplateSchema
  tests/unit/tiptap-serializer.test.ts (old bridge tests)

Preserved as src/lib/pdf/tiptap-validation.ts (~70 LOC):
  - validateTipTapDocument()  — still used to reject unsupported nodes
    on save in the admin template editor
  - TEMPLATE_VARIABLES        — drives the merge-token picker in the
    admin template form + preview UI

generateAndSign() now throws a clear ValidationError when a non-EOI
template tries the in-app pathway. Use a Documenso template, or wait
for the deferred AcroForm-fill admin-upload feature.

seed-data.ts: "Standard EOI (in-app)" template row now seeds with stub
bodyHtml + small MERGE_FIELDS array; the deleted HTML helper was never
actually rendered (in-app EOI is pdf-lib AcroForm fill on the source
PDF — generateEoiPdfFromTemplate, unchanged).

After this commit, pdfme has zero callers left. Commit 14 drops the
deps and the generate.ts shim.

1298/1298 vitest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 21:11:23 +02:00
parent ed2424cc68
commit 411d0764e8
14 changed files with 137 additions and 1497 deletions

View File

@@ -1,74 +0,0 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { generatePdf } from '@/lib/pdf/generate';
import {
validateTipTapDocument,
tipTapToPdfmeTemplate,
buildContentInputsFromDoc,
substituteVariables,
type TipTapNode,
} from '@/lib/pdf/tiptap-to-pdfme';
import { previewAdminTemplateSchema } from '@/lib/validators/document-templates';
/**
* POST /api/v1/admin/templates/preview
*
* Generates a preview PDF from a TipTap JSON content block.
* Returns { data: { pdfBase64: string } } - the client can render this
* in an <iframe src="data:application/pdf;base64,..."> or open in a new tab.
*
* Body:
* content: TipTap JSON document
* sampleData?: Record<string, string> - variable substitutions
*/
export const POST = withAuth(
withPermission('document_templates', 'manage', async (req, _ctx) => {
try {
const body = await parseBody(req, previewAdminTemplateSchema);
const doc = body.content as unknown as TipTapNode;
const sampleData = body.sampleData ?? {};
// Validate content nodes
const unsupported = validateTipTapDocument(doc);
if (unsupported.length > 0) {
throw new ValidationError(
`Content contains unsupported node types: ${unsupported.join(', ')}`,
);
}
// Substitute variables in text nodes
const substitutedDoc = substituteInDoc(doc, sampleData);
// Convert to pdfme template + inputs
const template = tipTapToPdfmeTemplate(substitutedDoc);
const inputs = buildContentInputsFromDoc(substitutedDoc, template);
const pdfBytes = await generatePdf(template, inputs);
const pdfBase64 = Buffer.from(pdfBytes).toString('base64');
return NextResponse.json({ data: { pdfBase64 } });
} catch (error) {
return errorResponse(error);
}
}),
);
/**
* Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc.
*/
function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
if (node.type === 'text' && node.text) {
return { ...node, text: substituteVariables(node.text, data) };
}
if (node.content) {
return {
...node,
content: node.content.map((child) => substituteInDoc(child, data)),
};
}
return node;
}

View File

@@ -1,34 +0,0 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { generateAndSend } from '@/lib/services/document-templates';
import { generateAndSendSchema } from '@/lib/validators/document-templates';
export const POST = withAuth(
withPermission('documents', 'create', async (req, ctx, params) => {
try {
const body = await parseBody(req, generateAndSendSchema);
const result = await generateAndSend(
params.id!,
ctx.portId,
{
clientId: body.clientId,
interestId: body.interestId,
berthId: body.berthId,
},
body.recipientEmail,
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
return NextResponse.json({ data: result }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,24 +0,0 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { generateFromTemplate } from '@/lib/services/document-templates';
import { generateSchema } from '@/lib/validators/document-templates';
export const POST = withAuth(
withPermission('documents', 'create', async (req, ctx, params) => {
try {
const body = await parseBody(req, generateSchema);
const result = await generateFromTemplate(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -14,7 +14,7 @@ import {
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-validation';
const DOCUMENT_TYPES = [
{ value: 'eoi', label: 'Expression of Interest' },

View File

@@ -5,7 +5,7 @@ import { Eye, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-validation';
interface TemplatePreviewProps {
content: Record<string, unknown> | null;

View File

@@ -42,12 +42,26 @@ import {
interestBerths,
documentTemplates,
} from './schema';
import {
getStandardEoiTemplateHtml,
STANDARD_EOI_MERGE_FIELDS,
} from '@/lib/pdf/templates/eoi-standard-inapp';
import berthSnapshot from './seed-data/berths.json';
// Seed body for the default "Standard EOI" document_templates row.
// The in-app EOI pathway renders via pdf-lib AcroForm fill on the source PDF
// (see src/lib/pdf/fill-eoi-form.ts), not from this HTML. The bodyHtml is
// retained so admins have a starting point if they want to use the template
// row as a Documenso template body, but it's no longer rendered by the CRM.
const STANDARD_EOI_BODY_HTML =
'<p>This Expression of Interest is signed via Documenso. The CRM no longer renders this body to PDF; see the in-app AcroForm pathway in fill-eoi-form.ts.</p>';
const STANDARD_EOI_MERGE_FIELDS = [
'date.today',
'date.year',
'port.name',
'client.fullName',
'client.primaryEmail',
'yacht.name',
'berth.mooringNumber',
'interest.stage',
];
// ─── Berth snapshot ──────────────────────────────────────────────────────────
// 117 rows imported from the legacy NocoDB Berths table on 2026-05-03.
// Refresh via `pnpm tsx scripts/import-berths-from-nocodb.ts --update-snapshot`.
@@ -798,7 +812,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
description:
'Default Expression of Interest / Letter of Intent template, rendered in-app via pdfme. Use for ports that prefer in-app PDF generation over the Documenso template path.',
templateType: 'eoi',
bodyHtml: getStandardEoiTemplateHtml(),
bodyHtml: STANDARD_EOI_BODY_HTML,
mergeFields: STANDARD_EOI_MERGE_FIELDS,
isActive: true,
createdBy: SEED_USER_ID,

View File

@@ -1,337 +0,0 @@
/**
* Standard in-app EOI (Letter of Intent) template.
*
* Rendered in-app via pdfme (HTML → PDF pipeline) for ports that prefer the
* in-app PDF generation path over the Documenso template flow.
*
* Merge tokens use the {{section.field}} convention and match the
* `EoiContext` shape produced by `buildEoiContext` in
* `src/lib/services/eoi-context.ts`. The tokens are resolved by
* `resolveTemplate` (Task 11.4 wires the expanded resolver).
*
* Related:
* - Field mapping: docs/eoi-documenso-field-mapping.md
* - Context builder: src/lib/services/eoi-context.ts
* - Schema: document_templates (src/lib/db/schema/documents.ts)
*/
export const STANDARD_EOI_MERGE_FIELDS: string[] = [
'date.today',
'date.year',
'port.name',
'port.defaultCurrency',
'client.fullName',
'client.nationality',
'client.primaryEmail',
'client.primaryPhone',
'client.address.street',
'client.address.city',
'client.address.country',
'yacht.name',
'yacht.hullNumber',
'yacht.flag',
'yacht.yearBuilt',
'yacht.lengthFt',
'yacht.widthFt',
'yacht.draftFt',
'yacht.lengthM',
'yacht.widthM',
'yacht.draftM',
'company.name',
'company.legalName',
'company.taxId',
'company.billingAddress',
'owner.type',
'owner.name',
'owner.legalName',
'berth.mooringNumber',
'berth.area',
'berth.lengthFt',
'berth.price',
'berth.priceCurrency',
'berth.tenureType',
'interest.stage',
'interest.leadCategory',
'interest.dateFirstContact',
'interest.notes',
];
export function getStandardEoiTemplateHtml(): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Expression of Interest - Letter of Intent</title>
<style>
@page {
size: letter;
margin: 0.9in 0.9in 1.0in 0.9in;
}
html, body {
margin: 0;
padding: 0;
font-family: "Times New Roman", Georgia, serif;
font-size: 12pt;
color: #111;
line-height: 1.45;
}
.header {
border-bottom: 2px solid #111;
padding-bottom: 8pt;
margin-bottom: 18pt;
}
.header .port-name {
font-size: 18pt;
font-weight: 700;
letter-spacing: 0.5pt;
}
.header .doc-title {
margin-top: 4pt;
font-size: 14pt;
font-weight: 600;
font-style: italic;
}
.meta {
display: flex;
justify-content: space-between;
margin-bottom: 18pt;
font-size: 11pt;
}
h2.section {
font-size: 12pt;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8pt;
border-bottom: 1px solid #555;
padding-bottom: 2pt;
margin: 16pt 0 8pt 0;
}
table.fields {
width: 100%;
border-collapse: collapse;
margin-bottom: 6pt;
}
table.fields td {
padding: 3pt 6pt 3pt 0;
vertical-align: top;
}
table.fields td.label {
width: 34%;
font-weight: 600;
color: #333;
}
.addr-line {
margin: 0;
padding: 0;
}
.signatures {
margin-top: 36pt;
display: table;
width: 100%;
}
.signatures .slot {
display: table-cell;
width: 50%;
padding-right: 18pt;
vertical-align: top;
}
.signatures .slot:last-child {
padding-right: 0;
padding-left: 18pt;
}
.sig-line {
border-top: 1px solid #111;
margin-top: 42pt;
padding-top: 4pt;
font-size: 10pt;
color: #333;
}
.footer {
position: fixed;
bottom: 0.4in;
left: 0.9in;
right: 0.9in;
text-align: center;
font-size: 9pt;
color: #666;
border-top: 1px solid #ccc;
padding-top: 4pt;
}
.muted {
color: #666;
}
</style>
</head>
<body>
<div class="header">
<div class="port-name">{{port.name}}</div>
<div class="doc-title">Expression of Interest - Letter of Intent</div>
</div>
<div class="meta">
<div><strong>Date:</strong> {{date.today}}</div>
<div><strong>Port:</strong> {{port.name}}</div>
</div>
<p>
This Expression of Interest (the &ldquo;EOI&rdquo;) is entered into between
<strong>{{port.name}}</strong> and the Applicant named below, and records the
Applicant&rsquo;s non-binding intent to proceed toward a berth acquisition at
the port. It is subject to subsequent definitive documentation.
</p>
<h2 class="section">1. Applicant</h2>
<table class="fields">
<tr>
<td class="label">Full name</td>
<td>{{client.fullName}}</td>
</tr>
<tr>
<td class="label">Nationality</td>
<td>{{client.nationality}}</td>
</tr>
<tr>
<td class="label">Email</td>
<td>{{client.primaryEmail}}</td>
</tr>
<tr>
<td class="label">Phone</td>
<td>{{client.primaryPhone}}</td>
</tr>
<tr>
<td class="label">Address</td>
<td>
<p class="addr-line">{{client.address.street}}</p>
<p class="addr-line">{{client.address.city}}</p>
<p class="addr-line">{{client.address.country}}</p>
</td>
</tr>
</table>
<h2 class="section">2. Yacht</h2>
<table class="fields">
<tr>
<td class="label">Name</td>
<td>{{yacht.name}}</td>
</tr>
<tr>
<td class="label">Hull number</td>
<td>{{yacht.hullNumber}}</td>
</tr>
<tr>
<td class="label">Flag</td>
<td>{{yacht.flag}}</td>
</tr>
<tr>
<td class="label">Year built</td>
<td>{{yacht.yearBuilt}}</td>
</tr>
<tr>
<td class="label">Length (ft / m)</td>
<td>{{yacht.lengthFt}} ft &nbsp;/&nbsp; {{yacht.lengthM}} m</td>
</tr>
<tr>
<td class="label">Beam (ft / m)</td>
<td>{{yacht.widthFt}} ft &nbsp;/&nbsp; {{yacht.widthM}} m</td>
</tr>
<tr>
<td class="label">Draft (ft / m)</td>
<td>{{yacht.draftFt}} ft &nbsp;/&nbsp; {{yacht.draftM}} m</td>
</tr>
</table>
<h2 class="section">3. Owner</h2>
<p>
Owner type: <strong>{{owner.type}}</strong><br />
Owner name: <strong>{{owner.name}}</strong>
<span class="muted"> (legal: {{owner.legalName}})</span>
</p>
<table class="fields">
<tr>
<td class="label">Company name</td>
<td>{{company.name}}</td>
</tr>
<tr>
<td class="label">Legal name</td>
<td>{{company.legalName}}</td>
</tr>
<tr>
<td class="label">Tax ID</td>
<td>{{company.taxId}}</td>
</tr>
<tr>
<td class="label">Billing address</td>
<td>{{company.billingAddress}}</td>
</tr>
</table>
<p class="muted" style="font-size:10pt;">
The company block is populated only where the yacht is company-owned; for
client-owned yachts these fields render empty.
</p>
<h2 class="section">4. Berth</h2>
<table class="fields">
<tr>
<td class="label">Mooring number</td>
<td>{{berth.mooringNumber}}</td>
</tr>
<tr>
<td class="label">Area</td>
<td>{{berth.area}}</td>
</tr>
<tr>
<td class="label">Length</td>
<td>{{berth.lengthFt}} ft</td>
</tr>
<tr>
<td class="label">Price</td>
<td>{{berth.price}} {{berth.priceCurrency}}</td>
</tr>
<tr>
<td class="label">Tenure type</td>
<td>{{berth.tenureType}}</td>
</tr>
</table>
<h2 class="section">5. Interest Summary</h2>
<table class="fields">
<tr>
<td class="label">Pipeline stage</td>
<td>{{interest.stage}}</td>
</tr>
<tr>
<td class="label">Lead category</td>
<td>{{interest.leadCategory}}</td>
</tr>
<tr>
<td class="label">First contact</td>
<td>{{interest.dateFirstContact}}</td>
</tr>
<tr>
<td class="label">Notes</td>
<td>{{interest.notes}}</td>
</tr>
</table>
<h2 class="section">6. Signatures</h2>
<div class="signatures">
<div class="slot">
<div class="sig-line">
Applicant - {{client.fullName}}
</div>
<div class="muted" style="font-size:10pt; margin-top:2pt;">Date: __________________</div>
</div>
<div class="slot">
<div class="sig-line">
For and on behalf of {{port.name}}
</div>
<div class="muted" style="font-size:10pt; margin-top:2pt;">Date: __________________</div>
</div>
</div>
<div class="footer">
{{port.name}} &middot; Expression of Interest &middot; {{date.year}}
</div>
</body>
</html>`;
}

View File

@@ -1,571 +0,0 @@
/**
* TipTap JSON → @pdfme Template Serializer
*
* Converts a TipTap document JSON into a @pdfme Template suitable for PDF generation.
* Supports a constrained formatting subset; unsupported nodes are rejected at validation time.
*
* Supported nodes:
* paragraph, heading (h1-h3), bulletList, orderedList, listItem,
* table, tableRow, tableCell, tableHeader, image, hardBreak,
* text (with marks: bold, italic, underline)
*
* Unsupported (rejected at save time):
* blockquote, codeBlock, horizontalRule, taskList
*/
import type { Template } from '@pdfme/common';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface TipTapMark {
type: string;
attrs?: Record<string, unknown>;
}
export interface TipTapNode {
type: string;
content?: TipTapNode[];
text?: string;
marks?: TipTapMark[];
attrs?: Record<string, unknown>;
}
// @pdfme schema field shape (matches pdfme text plugin schema)
// We use an index signature to satisfy @pdfme/common's Schema type requirement
interface SchemaField {
name: string;
type: 'text';
position: { x: number; y: number };
width: number;
height: number;
fontSize: number;
fontName?: string;
fontColor?: string;
alignment?: 'left' | 'center' | 'right';
lineHeight?: number;
[key: string]: unknown;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const PAGE_WIDTH_MM = 170; // A4 content width (210 - 20mm margins each side)
const PAGE_BREAK_THRESHOLD = 250; // y position (mm from page top) to start new logical page
const MARGIN_X_MM = 20; // Left margin
const MARGIN_TOP_MM = 20; // Top margin
const UNSUPPORTED_NODES = new Set([
'blockquote',
'codeBlock',
'horizontalRule',
'taskList',
'taskItem',
]);
// Line heights per node type (mm)
const PARAGRAPH_HEIGHT = 6;
const H1_HEIGHT = 12;
const H2_HEIGHT = 9;
const H3_HEIGHT = 7;
const LIST_ITEM_HEIGHT = 6;
const TABLE_ROW_HEIGHT = 8;
// Font sizes
const PARAGRAPH_FONT_SIZE = 10;
const H1_FONT_SIZE = 20;
const H2_FONT_SIZE = 16;
const H3_FONT_SIZE = 14;
const LIST_FONT_SIZE = 10;
const TABLE_FONT_SIZE = 9;
// ─── Template Variables ───────────────────────────────────────────────────────
export const TEMPLATE_VARIABLES: Array<{ key: string; label: string; example: string }> = [
{ key: 'client.name', label: 'Client Full Name', example: 'John Smith' },
{ key: 'client.company', label: 'Company Name', example: 'Smith Holdings' },
{ key: 'client.email', label: 'Client Email', example: 'john@smithholdings.com' },
{ key: 'client.phone', label: 'Client Phone', example: '+61 400 000 000' },
{ key: 'interest.stage', label: 'Pipeline Stage', example: 'Signed EOI/NDA' },
{ key: 'interest.berthNumber', label: 'Berth Number (from interest)', example: 'A23' },
{ key: 'berth.mooring_number', label: 'Berth Number', example: 'A23' },
{ key: 'berth.price', label: 'Berth Price', example: '$45,000' },
{ key: 'berth.tenure_type', label: 'Tenure Type', example: 'Freehold' },
{ key: 'port.name', label: 'Port Name', example: 'Port Nimara' },
{ key: 'port.currency', label: 'Port Currency', example: 'AUD' },
{ key: 'date.today', label: "Today's Date", example: '2026-03-15' },
{ key: 'date.year', label: 'Current Year', example: '2026' },
];
// ─── Validation ───────────────────────────────────────────────────────────────
/**
* Recursively walks a TipTap node tree and collects any unsupported node types.
* Returns an array of unsupported type names found, or empty array if valid.
*/
export function validateTipTapDocument(doc: TipTapNode): string[] {
const found = new Set<string>();
function walk(node: TipTapNode): void {
if (UNSUPPORTED_NODES.has(node.type)) {
found.add(node.type);
}
if (node.content) {
for (const child of node.content) {
walk(child);
}
}
}
walk(doc);
return Array.from(found);
}
// ─── Text extraction helpers ──────────────────────────────────────────────────
/**
* Extracts plain text from a TipTap node and its children.
* Preserves hardBreak as newline.
*/
function extractText(node: TipTapNode): string {
if (node.type === 'text') {
return node.text ?? '';
}
if (node.type === 'hardBreak') {
return '\n';
}
if (!node.content || node.content.length === 0) {
return '';
}
return node.content.map(extractText).join('');
}
/**
* For a paragraph's inline content, returns text and a flag indicating bold.
* (pdfme text schema doesn't support inline mixed formatting; we honour the
* dominant mark on the paragraph level for simplicity.)
*/
function extractParagraphContent(node: TipTapNode): { text: string; bold: boolean } {
let text = '';
let hasBold = false;
if (node.content) {
for (const child of node.content) {
if (child.type === 'text') {
text += child.text ?? '';
if ((child.marks ?? []).some((m) => m.type === 'bold')) hasBold = true;
} else if (child.type === 'hardBreak') {
text += '\n';
} else {
text += extractText(child);
}
}
}
return { text, bold: hasBold };
}
// ─── Field name generation ────────────────────────────────────────────────────
let fieldCounter = 0;
function nextFieldName(prefix: string): string {
return `${prefix}_${fieldCounter++}`;
}
// ─── Serializer State ─────────────────────────────────────────────────────────
interface SerializerState {
fields: SchemaField[];
y: number;
pageIndex: number;
}
function ensurePageSpace(state: SerializerState, needed: number): void {
if (state.y + needed > PAGE_BREAK_THRESHOLD) {
state.pageIndex++;
state.y = MARGIN_TOP_MM;
}
}
// ─── Node Processors ──────────────────────────────────────────────────────────
function processParagraph(node: TipTapNode, state: SerializerState): void {
const { text, bold } = extractParagraphContent(node);
const lineCount = Math.max(1, (text.match(/\n/g) ?? []).length + 1);
const height = PARAGRAPH_HEIGHT * lineCount;
ensurePageSpace(state, height);
const field: SchemaField = {
name: nextFieldName('para'),
type: 'text',
position: { x: MARGIN_X_MM, y: state.y },
width: PAGE_WIDTH_MM,
height,
fontSize: PARAGRAPH_FONT_SIZE,
fontName: bold ? 'Helvetica-Bold' : 'Helvetica',
};
state.fields.push(field);
state.y += height + 1;
}
function processHeading(node: TipTapNode, state: SerializerState): void {
const level = (node.attrs?.level as number) ?? 1;
let height: number;
let fontSize: number;
if (level === 1) {
height = H1_HEIGHT;
fontSize = H1_FONT_SIZE;
} else if (level === 2) {
height = H2_HEIGHT;
fontSize = H2_FONT_SIZE;
} else {
height = H3_HEIGHT;
fontSize = H3_FONT_SIZE;
}
ensurePageSpace(state, height);
const field: SchemaField = {
name: nextFieldName(`h${level}`),
type: 'text',
position: { x: MARGIN_X_MM, y: state.y },
width: PAGE_WIDTH_MM,
height,
fontSize,
fontName: 'Helvetica-Bold',
};
state.fields.push(field);
state.y += height + 2;
}
function processBulletList(node: TipTapNode, state: SerializerState): void {
if (!node.content) return;
for (const item of node.content) {
if (item.type !== 'listItem') continue;
const height = LIST_ITEM_HEIGHT;
ensurePageSpace(state, height);
state.fields.push({
name: nextFieldName('bullet'),
type: 'text',
position: { x: MARGIN_X_MM + 5, y: state.y },
width: PAGE_WIDTH_MM - 5,
height,
fontSize: LIST_FONT_SIZE,
fontName: 'Helvetica',
});
state.y += height + 0.5;
}
state.y += 2;
}
function processOrderedList(node: TipTapNode, state: SerializerState): void {
if (!node.content) return;
// Use a counter that increments to build ordered list prefixes
let listIndex = (node.attrs?.start as number) ?? 1;
for (const item of node.content) {
if (item.type !== 'listItem') continue;
const height = LIST_ITEM_HEIGHT;
ensurePageSpace(state, height);
// listIndex is used via the field name prefix to distinguish items
state.fields.push({
name: nextFieldName(`ol_${listIndex}`),
type: 'text',
position: { x: MARGIN_X_MM + 5, y: state.y },
width: PAGE_WIDTH_MM - 5,
height,
fontSize: LIST_FONT_SIZE,
fontName: 'Helvetica',
});
state.y += height + 0.5;
listIndex++;
}
state.y += 2;
}
function processTable(node: TipTapNode, state: SerializerState): void {
if (!node.content) return;
const rows = node.content.filter((r) => r.type === 'tableRow');
if (rows.length === 0) return;
const firstRow = rows[0];
const colCount = firstRow?.content?.length ?? 1;
const colWidth = PAGE_WIDTH_MM / colCount;
for (const row of rows) {
if (!row.content) continue;
const rowHeight = TABLE_ROW_HEIGHT;
ensurePageSpace(state, rowHeight);
row.content.forEach((cell, colIdx) => {
const isHeader = cell.type === 'tableHeader';
state.fields.push({
name: nextFieldName(isHeader ? 'th' : 'td'),
type: 'text',
position: {
x: MARGIN_X_MM + colIdx * colWidth,
y: state.y,
},
width: colWidth - 0.5,
height: rowHeight,
fontSize: TABLE_FONT_SIZE,
fontName: isHeader ? 'Helvetica-Bold' : 'Helvetica',
});
});
state.y += rowHeight + 0.5;
}
state.y += 3;
}
// ─── Top-level Node Dispatch ──────────────────────────────────────────────────
function processNode(node: TipTapNode, state: SerializerState): void {
switch (node.type) {
case 'paragraph':
processParagraph(node, state);
break;
case 'heading':
processHeading(node, state);
break;
case 'bulletList':
processBulletList(node, state);
break;
case 'orderedList':
processOrderedList(node, state);
break;
case 'table':
processTable(node, state);
break;
case 'image':
state.y += 20;
break;
case 'hardBreak':
state.y += 3;
break;
default:
break;
}
}
// ─── Main Serializer ──────────────────────────────────────────────────────────
/**
* Converts a TipTap JSON document to a @pdfme Template.
* Variables like {{client.name}} are left as-is in text content.
* Call buildContentInputsFromDoc to get inputs with actual values.
*/
export function tipTapToPdfmeTemplate(doc: TipTapNode): Template {
fieldCounter = 0;
const state: SerializerState = {
fields: [],
y: MARGIN_TOP_MM,
pageIndex: 0,
};
const children = doc.type === 'doc' ? (doc.content ?? []) : [doc];
for (const node of children) {
processNode(node, state);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const basePdf = 'BLANK_PDF' as any;
// pdfme's Schema type has a string index signature we satisfy via the
// [key: string]: unknown on SchemaField
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const schemas = [state.fields] as any;
return { basePdf, schemas } as Template;
}
// ─── Input Builder ────────────────────────────────────────────────────────────
/**
* Given a @pdfme Template and a flat key→value data record,
* builds empty input records keyed by field name.
* Use buildContentInputsFromDoc for content-populated inputs.
*/
export function buildTemplateInputs(
template: Template,
data: Record<string, string>,
): Record<string, string>[] {
return template.schemas.map((pageSchema) => {
const record: Record<string, string> = {};
for (const field of pageSchema as SchemaField[]) {
record[field.name] = data[field.name] ?? '';
}
return record;
});
}
/**
* Replaces all {{variable.key}} tokens in a string with values from the data map.
* Unmatched tokens are left as-is.
*/
export function substituteVariables(text: string, data: Record<string, string>): string {
return text.replace(/\{\{([^}]+)\}\}/g, (_match, key: string) => {
return data[key.trim()] ?? _match;
});
}
/**
* Public alias for internal buildContentInputs.
* Builds pdfme input records by extracting text content from the TipTap doc
* and mapping it to generated field names (in schema order).
* Use after calling tipTapToPdfmeTemplate on the same (already-substituted) doc.
*/
export function buildContentInputsFromDoc(
doc: TipTapNode,
template: Template,
): Record<string, string>[] {
return buildContentInputs(doc, template);
}
/**
* Full pipeline: validate → substitute variables → convert to pdfme template + inputs.
* Returns { template, inputs, errors }.
*/
export function tiptapDocumentToTemplateWithData(
doc: TipTapNode,
data: Record<string, string> = {},
): {
template: Template | null;
inputs: Record<string, string>[];
errors: string[];
} {
const errors = validateTipTapDocument(doc);
if (errors.length > 0) {
return { template: null, inputs: [], errors };
}
const substitutedDoc = substituteInDoc(doc, data);
const template = tipTapToPdfmeTemplate(substitutedDoc);
const inputs = buildContentInputs(substitutedDoc, template);
return { template, inputs, errors: [] };
}
// ─── Internals ────────────────────────────────────────────────────────────────
/**
* Deeply substitutes variables in all text nodes of a TipTap document.
*/
function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
if (node.type === 'text' && node.text) {
return { ...node, text: substituteVariables(node.text, data) };
}
if (node.content) {
return {
...node,
content: node.content.map((child) => substituteInDoc(child, data)),
};
}
return node;
}
/**
* Builds pdfme input records by extracting text content from the TipTap doc
* and mapping it to generated field names (in schema order).
*/
function buildContentInputs(doc: TipTapNode, template: Template): Record<string, string>[] {
const textContents = extractAllTextContents(doc);
const schemas = template.schemas;
return schemas.map((pageSchema, pageIdx) => {
const record: Record<string, string> = {};
const fields = pageSchema as SchemaField[];
fields.forEach((field, fieldIdx) => {
const globalIdx =
schemas.slice(0, pageIdx).reduce((acc, ps) => acc + (ps as SchemaField[]).length, 0) +
fieldIdx;
record[field.name] = textContents[globalIdx] ?? '';
});
return record;
});
}
/**
* Extracts text content for each schema field in document order.
* Order must mirror the order that processNode() creates fields.
*/
function extractAllTextContents(doc: TipTapNode): string[] {
const contents: string[] = [];
const children = doc.type === 'doc' ? (doc.content ?? []) : [doc];
for (const node of children) {
extractNodeContent(node, contents);
}
return contents;
}
function extractNodeContent(node: TipTapNode, out: string[]): void {
switch (node.type) {
case 'paragraph': {
const { text } = extractParagraphContent(node);
out.push(text);
break;
}
case 'heading': {
out.push(extractText(node));
break;
}
case 'bulletList': {
if (node.content) {
for (const item of node.content) {
if (item.type === 'listItem') {
out.push('• ' + extractText(item).replace(/\n/g, ' '));
}
}
}
break;
}
case 'orderedList': {
if (node.content) {
let idx = (node.attrs?.start as number) ?? 1;
for (const item of node.content) {
if (item.type === 'listItem') {
out.push(`${idx}. ` + extractText(item).replace(/\n/g, ' '));
idx++;
}
}
}
break;
}
case 'table': {
if (node.content) {
for (const row of node.content) {
if (row.type !== 'tableRow' || !row.content) continue;
for (const cell of row.content) {
out.push(extractText(cell));
}
}
}
break;
}
case 'image':
out.push('');
break;
case 'hardBreak':
break;
default:
break;
}
}

View File

@@ -0,0 +1,71 @@
/**
* TipTap content validation helpers shared by the admin template editor
* and the document-template service. Surfaces unsupported node types so
* the admin gets a clear validation error before saving, and exports the
* `TEMPLATE_VARIABLES` catalog so the editor can offer token suggestions.
*
* Note: this file was the remaining sliver of the legacy 571-line
* `tiptap-to-pdfme.ts` bridge. The pdfme rendering path it once fed has
* been removed (see the PDF stack overhaul spec); only the validation +
* variable catalog survive because they're still useful for the
* Documenso-template-body editor UX.
*/
export interface TipTapMark {
type: string;
attrs?: Record<string, unknown>;
}
export interface TipTapNode {
type: string;
content?: TipTapNode[];
text?: string;
marks?: TipTapMark[];
attrs?: Record<string, unknown>;
}
const UNSUPPORTED_NODES = new Set([
'blockquote',
'codeBlock',
'horizontalRule',
'taskList',
'taskItem',
]);
export const TEMPLATE_VARIABLES: Array<{ key: string; label: string; example: string }> = [
{ key: 'client.name', label: 'Client Full Name', example: 'John Smith' },
{ key: 'client.company', label: 'Company Name', example: 'Smith Holdings' },
{ key: 'client.email', label: 'Client Email', example: 'john@smithholdings.com' },
{ key: 'client.phone', label: 'Client Phone', example: '+61 400 000 000' },
{ key: 'interest.stage', label: 'Pipeline Stage', example: 'Signed EOI/NDA' },
{ key: 'interest.berthNumber', label: 'Berth Number (from interest)', example: 'A23' },
{ key: 'berth.mooring_number', label: 'Berth Number', example: 'A23' },
{ key: 'berth.price', label: 'Berth Price', example: '$45,000' },
{ key: 'berth.tenure_type', label: 'Tenure Type', example: 'Freehold' },
{ key: 'port.name', label: 'Port Name', example: 'Port Nimara' },
{ key: 'port.currency', label: 'Port Currency', example: 'AUD' },
{ key: 'date.today', label: "Today's Date", example: '2026-03-15' },
{ key: 'date.year', label: 'Current Year', example: '2026' },
];
/**
* Recursively walks a TipTap node tree and collects any unsupported node types.
* Returns an array of unsupported type names found, or empty array if valid.
*/
export function validateTipTapDocument(doc: TipTapNode): string[] {
const found = new Set<string>();
function walk(node: TipTapNode): void {
if (UNSUPPORTED_NODES.has(node.type)) {
found.add(node.type);
}
if (node.content) {
for (const child of node.content) {
walk(child);
}
}
}
walk(doc);
return Array.from(found);
}

View File

@@ -17,7 +17,7 @@ import { documentTemplates } from '@/lib/db/schema/documents';
import { auditLogs } from '@/lib/db/schema/system';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { validateTipTapDocument } from '@/lib/pdf/tiptap-to-pdfme';
import { validateTipTapDocument } from '@/lib/pdf/tiptap-validation';
import type {
CreateAdminTemplateInput,
UpdateAdminTemplateInput,

View File

@@ -16,8 +16,6 @@ import { emitToRoom } from '@/lib/socket/server';
import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { generatePdf } from '@/lib/pdf/generate';
import { getCountryName } from '@/lib/i18n/countries';
import {
createDocument as documensoCreate,
@@ -30,7 +28,6 @@ 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 { getPrimaryBerth } from '@/lib/services/interest-berths.service';
import { sendEmail } from '@/lib/email';
import type {
CreateTemplateInput,
UpdateTemplateInput,
@@ -457,184 +454,20 @@ export async function resolveTemplate(
return resolved;
}
// ─── Generate From Template ───────────────────────────────────────────────────
/**
* BR-142: Resolve template → HTML → PDF. Store in MinIO + create file/document records.
*/
export async function generateFromTemplate(
templateId: string,
portId: string,
context: GenerateInput,
meta: AuditMeta,
): Promise<{ document: unknown; file: unknown }> {
const template = await getTemplateById(templateId, portId);
const resolvedHtml = await resolveTemplate(templateId, { ...context, portId });
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
// Wrap HTML in a minimal full-page document for pdfme text block
const wrappedContent = resolvedHtml
.replace(/<[^>]+>/g, ' ') // strip HTML tags for plain-text PDF rendering
.replace(/\s+/g, ' ')
.trim();
// Use a simple single-field pdfme template for the HTML body
const pdfTemplate = {
basePdf: 'BLANK_PDF' as unknown as string,
schemas: [
[
{
name: 'portName',
type: 'text' as const,
position: { x: 20, y: 15 },
width: 170,
height: 10,
fontSize: 14,
},
{
name: 'body',
type: 'text' as const,
position: { x: 20, y: 30 },
width: 170,
height: 230,
fontSize: 9,
},
{
name: 'generatedAt',
type: 'text' as const,
position: { x: 20, y: 275 },
width: 170,
height: 6,
fontSize: 7,
},
],
],
};
const pdfBytes = await generatePdf(pdfTemplate, [
{
portName: `${port?.name ?? 'Port Nimara'} - ${template.name}`,
body: wrappedContent,
generatedAt: `Generated: ${new Date().toLocaleString('en-GB')}`,
},
]);
// Store in MinIO
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(
port?.slug ?? portId,
'document-templates',
templateId,
fileId,
'pdf',
);
{
const buffer = Buffer.from(pdfBytes);
const backend = await getStorageBackend();
await backend.put(storagePath, buffer, {
contentType: 'application/pdf',
sizeBytes: buffer.length,
});
}
// Create file record
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: 'correspondence',
uploadedBy: meta.userId,
})
.returning();
// Create document record
const [documentRecord] = await db
.insert(documents)
.values({
portId,
clientId: context.clientId ?? null,
interestId: context.interestId ?? null,
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,
templateName: template.name,
clientId: context.clientId,
interestId: context.interestId,
berthId: context.berthId,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:created', { documentId: documentRecord!.id });
return { document: documentRecord!, file: fileRecord! };
}
// ─── Generate and Send ────────────────────────────────────────────────────────
export async function generateAndSend(
templateId: string,
portId: string,
context: GenerateInput,
recipientEmail: string,
meta: AuditMeta,
) {
const { document, file } = await generateFromTemplate(templateId, portId, context, meta);
const template = await getTemplateById(templateId, portId);
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
// Send email with PDF as attachment (base64 encoded body)
try {
const resolvedHtml = await resolveTemplate(templateId, { ...context, portId });
await sendEmail(
recipientEmail,
template.name,
`<p>Please find the attached document: <strong>${template.name}</strong></p><hr/>${resolvedHtml}`,
`${port?.name ?? 'Port Nimara'} <noreply@${env.SMTP_HOST}>`,
);
} catch (err) {
logger.error({ err, templateId, recipientEmail }, 'Failed to send template email');
// Don't throw - document was created successfully; email failure is non-fatal
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'documentTemplate',
entityId: templateId,
metadata: { action: 'generate_and_send', recipientEmail },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return { document, file };
}
// ─── 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) ─────────────────
@@ -807,15 +640,22 @@ async function generateAndSignViaInApp(
}
// EOI templates fill the same source PDF as the Documenso template (so both
// pathways yield the same document). Other template types stay on the
// HTML→pdfme rendering path.
const { document: documentRecord, file } =
template.templateType === 'eoi'
? await generateEoiFromSourcePdf(template, portId, context, meta)
: ((await generateFromTemplate(templateId, portId, context, meta)) as {
document: DbDocument;
file: DbFile;
});
// 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,
);
// Fetch PDF bytes from the active storage backend to send to Documenso.
const pdfStream = await (await getStorageBackend()).get(file.storagePath);

View File

@@ -63,10 +63,6 @@ export const generateSchema = z.object({
berthId: z.string().optional(),
});
export const generateAndSendSchema = generateSchema.extend({
recipientEmail: z.string().email(),
});
export const generateAndSignSchema = generateSchema.extend({
pathway: z.enum(['inapp', 'documenso-template']).default('inapp'),
signers: z
@@ -86,7 +82,6 @@ export type CreateTemplateInput = z.infer<typeof createTemplateSchema>;
export type UpdateTemplateInput = z.infer<typeof updateTemplateSchema>;
export type ListTemplatesInput = z.infer<typeof listTemplatesSchema>;
export type GenerateInput = z.infer<typeof generateSchema>;
export type GenerateAndSendInput = z.infer<typeof generateAndSendSchema>;
export type GenerateAndSignInput = z.infer<typeof generateAndSignSchema>;
// ─── TipTap-based Admin Template Schemas ─────────────────────────────────────
@@ -113,11 +108,6 @@ export const updateAdminTemplateSchema = z.object({
isActive: z.boolean().optional(),
});
export const previewAdminTemplateSchema = z.object({
content: z.record(z.string(), z.unknown()),
sampleData: z.record(z.string(), z.string()).optional(),
});
export const rollbackAdminTemplateSchema = z.object({
version: z.number().int().min(1),
});
@@ -132,6 +122,5 @@ export const listAdminTemplatesSchema = baseListQuerySchema.extend({
export type CreateAdminTemplateInput = z.infer<typeof createAdminTemplateSchema>;
export type UpdateAdminTemplateInput = z.infer<typeof updateAdminTemplateSchema>;
export type PreviewAdminTemplateInput = z.infer<typeof previewAdminTemplateSchema>;
export type RollbackAdminTemplateInput = z.infer<typeof rollbackAdminTemplateSchema>;
export type ListAdminTemplatesInput = z.infer<typeof listAdminTemplatesSchema>;