feat(eoi): seed Standard EOI in-app template per port

Adds a new per-port document_templates row of type 'eoi' containing an
HTML EOI / Letter of Intent body with {{section.field}} merge tokens
that mirror the EoiContext shape. Enables the in-app pdfme PDF path as
an alternative to the Documenso template flow.

- New getStandardEoiTemplateHtml() returns the Letter-sized HTML body
  with Applicant / Yacht / Owner / Berth / Interest / Signatures blocks
- STANDARD_EOI_MERGE_FIELDS exported for resolveTemplate wiring (11.4)
- seed-data.ts inserts one document_templates row per port inside the
  existing withTransaction block, between ownership transfers and
  interests, using SEED_USER_ID for audit consistency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-24 16:13:51 +02:00
parent 7200c31486
commit 7ef7b9bb5f
2 changed files with 357 additions and 0 deletions

View File

@@ -33,7 +33,12 @@ import {
berths, berths,
berthReservations, berthReservations,
interests, interests,
documentTemplates,
} from './schema'; } from './schema';
import {
getStandardEoiTemplateHtml,
STANDARD_EOI_MERGE_FIELDS,
} from '@/lib/pdf/templates/eoi-standard-inapp';
// ─── Tunables ──────────────────────────────────────────────────────────────── // ─── Tunables ────────────────────────────────────────────────────────────────
@@ -840,6 +845,21 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
.where(eq(yachts.id, yachtId)); .where(eq(yachts.id, yachtId));
} }
// ── 6b. Standard EOI Template (in-app PDF path) ────────────────────────
// One row per port. Used by the in-app pdfme renderer when the port opts
// for in-app PDF generation over the Documenso template flow.
await tx.insert(documentTemplates).values({
portId,
name: 'Standard EOI (in-app)',
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(),
mergeFields: STANDARD_EOI_MERGE_FIELDS,
isActive: true,
createdBy: SEED_USER_ID,
});
// ── 7. Interests (15) ────────────────────────────────────────────────── // ── 7. Interests (15) ──────────────────────────────────────────────────
// Spread across pipeline stages. // Spread across pipeline stages.
// Valid stages (from interests schema comment): // Valid stages (from interests schema comment):

View File

@@ -0,0 +1,337 @@
/**
* 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>`;
}