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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user