10 Commits

Author SHA1 Message Date
Matt Ciaccio
0ed401d083 refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.

Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.

Caller cleanup (zero behavioral change to remaining flows):

- Drops the legacy `generateEoi` flow entirely (route, service function,
  pdfme template, validator schema). The dual-path generate-and-sign
  service from PR 11 has fully replaced it; the route was no longer
  wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
  removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
  `yachts` via `interest.yachtId` instead of the dropped
  `client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
  lookup (direct + active company memberships); interest-summary fetches
  yacht via `interest.yachtId`. Both PDF templates updated to read
  yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
  `search-result-item`, `use-search` hook, `types/domain.ts`,
  `search.service` — drop the companyName badge / sub-label / typed
  field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
  prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
  yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.

Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
Matt Ciaccio
456d399ee2 refactor(templates): merge-field allow-list rejects unknown tokens
Extracts the MERGE_FIELDS catalog out of the document-templates service
into src/lib/templates/merge-fields.ts so the Zod validator can import
it without circular deps. createTemplateSchema now refines mergeFields
against VALID_MERGE_TOKENS — unknown tokens (including the deprecated
`{{client.yachtName}}` / `{{client.companyName}}` family) are rejected
at template creation time with a message naming the offenders.

Adds the missing `eoi` value to templateType enum so seeded EOI rows
round-trip through the validator. Drops the historical "Removed (PR 11):"
comment from the catalog (per project convention against `// removed`
markers).

6 new validator unit tests; 652/652 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:48:06 +02:00
Matt Ciaccio
f4ec51002c feat(eoi): template-aware generate-EOI dialog
The EOI dialog now lists "Documenso Standard EOI" (default) plus any
seeded in-app EOI templates and routes the submit to the dual-path
generate-and-sign endpoint with the correct pathway:

  - "documenso-template" sentinel id → pathway: documenso-template
  - any other template id → pathway: inapp

Signers are derived server-side from EoiContext for both pathways when
the template type is EOI (interest's client + hardcoded developer +
approver), so the dialog doesn't collect them. Non-EOI templates still
require explicit signers.

Drops the legacy `client.yachtLengthFt` prerequisite check (yacht is now
a first-class entity) and replaces it with hasYacht based on
interest.yachtId. Tests updated; 646/646 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:42:08 +02:00
Matt Ciaccio
2ff24a7132 feat(eoi): in-app pathway fills the same source PDF as Documenso
When the in-app pathway is used for EOI templates, we now load the same
source PDF that the Documenso template uploads and fill its AcroForm
fields with values from EoiContext via pdf-lib. Field names mirror the
Documenso template's formValues keys exactly (Name, Email, Address,
Yacht Name, Length, Width, Draft, Berth Number + Lease_10 / Purchase
checkboxes), so both pathways produce equivalent legal documents — only
the renderer differs.

The form is left interactive (not flattened) so a recipient can still
adjust values before signing. Non-EOI templates (welcome letters,
acknowledgments, etc.) keep using the existing HTML→pdfme path.

Adds:
- pdf-lib direct dep
- src/lib/pdf/fill-eoi-form.ts — load + fill helpers, EOI_TEMPLATE_PDF_PATH
  env override
- assets/ + README documenting the expected source PDF
- next.config outputFileTracingIncludes so the asset is bundled in the
  standalone build

Tests: 8 new (4 fill-form unit + 2 source-PDF route + 2 fallback);
645/645 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:38:02 +02:00
Matt Ciaccio
f8255cedb8 feat(eoi): dual-path generateAndSign (inapp + documenso-template)
generateAndSign now accepts a `pathway` parameter:

- `inapp` (existing): resolve in-app template -> pdfme -> MinIO -> Documenso
  createDocument + sendDocument.
- `documenso-template` (new): build EOI context from interestId, assemble
  the Documenso template payload, and call Documenso's
  /api/v1/templates/{id}/generate-document. Documenso owns the PDF; we
  still record a documents row for tracking.

Adds generateDocumentFromTemplate helper to the Documenso client and new
env vars (DOCUMENSO_TEMPLATE_ID_EOI + client/developer/approval recipient
IDs) with defaults matching the legacy flow. Covered by 6 new integration
tests (637/637 green).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:43:41 +02:00
Matt Ciaccio
13d07e3906 feat(templates): merge-field resolver supports yacht/company/owner scopes
Task 11.4. Extends resolveTemplate to use buildEoiContext when interestId
is provided, populating the new yacht.*, company.*, owner.* token scopes
from the shared EOI context. Legacy non-EOI templates still resolve via
direct client/berth/port lookups. Deprecated client.yachtName /
client.companyName / client.yacht*Ft tokens are removed from the catalog;
PR 12 will drop the backing columns. berth.mooringNumber is relaxed to
required:false so welcome-letter-style templates without a berth context
no longer trip the required-merge-field check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:20:53 +02:00
Matt Ciaccio
7ef7b9bb5f 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>
2026-04-24 16:13:51 +02:00
Matt Ciaccio
7200c31486 feat(eoi): add Documenso template payload builder 2026-04-24 16:09:27 +02:00
Matt Ciaccio
db74c9394b docs(eoi): document Documenso template field name mapping
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:06:39 +02:00
Matt Ciaccio
d133d6d656 feat(ui): wire OwnerPicker into invoice billing-entity field 2026-04-24 16:04:07 +02:00
46 changed files with 11510 additions and 554 deletions

48
assets/README.md Normal file
View File

@@ -0,0 +1,48 @@
# `assets/`
Server-side runtime assets bundled by Next.js (via `outputFileTracingIncludes`
in `next.config.ts`). These files are read with `fs.readFile` from
`process.cwd()` at runtime, so they are NOT served as public URLs — use
`public/` for that.
## `eoi-template.pdf`
The source PDF used by the in-app EOI generation pathway
(`src/lib/pdf/fill-eoi-form.ts`). It must be the **same** PDF that the
Documenso EOI template uploads, so both pathways produce equivalent
documents.
The PDF must contain AcroForm fields with these exact names (mirroring the
Documenso template's `formValues` keys — see
`docs/eoi-documenso-field-mapping.md`):
| Field name | Type | Filled with |
| -------------- | -------- | ----------------------------------------------------- |
| `Name` | Text | `EoiContext.client.fullName` |
| `Email` | Text | `EoiContext.client.primaryEmail` |
| `Address` | Text | `street, city, country` |
| `Yacht Name` | Text | `EoiContext.yacht.name` |
| `Length` | Text | `EoiContext.yacht.lengthFt` |
| `Width` | Text | `EoiContext.yacht.widthFt` |
| `Draft` | Text | `EoiContext.yacht.draftFt` |
| `Berth Number` | Text | `EoiContext.berth.mooringNumber` |
| `Lease_10` | Checkbox | always `false` (legacy default — Purchase, not Lease) |
| `Purchase` | Checkbox | always `true` |
Form fields stay interactive after generation (not flattened), so the
recipient can still tweak values before signing if the in-app pathway is
followed by a Documenso send.
### Override path
In dev/test, set `EOI_TEMPLATE_PDF_PATH=/abs/path/to/your/template.pdf` to
point at a different file (e.g. a fixture).
### How to extract this PDF
The legacy flow uploads this PDF to Documenso template ID 8. To get the
exact bytes:
1. In Documenso, open the EOI template.
2. Download the source PDF.
3. Drop it here as `eoi-template.pdf`.

View File

@@ -0,0 +1,76 @@
# Documenso EOI Template — Field Mapping
**Purpose:** This doc is the canonical reference for mapping the Documenso EOI template's `formValues` keys to the new data model's `EoiContext` shape. It drives `buildDocumensoPayload()` (Task 11.2), the in-app Standard EOI HTML tokens (Task 11.3), and the Spec 2 importer's yacht/company hydration.
## Source
The legacy field list comes from `client-portal/server/api/eoi/generate-quick-eoi.ts`, specifically the POST body sent to `POST /api/v1/templates/{templateId}/generate-document` (Documenso template 8). The relevant lines in that file are around the `createDocumentPayload.formValues` object.
## Documenso template `formValues` keys
Documenso template IDs and recipient IDs are configured via env vars:
- `NUXT_DOCUMENSO_TEMPLATE_ID` (default: `8`)
- `NUXT_DOCUMENSO_CLIENT_RECIPIENT_ID` (default: `192`) — signing order 1
- `NUXT_DOCUMENSO_DEVELOPER_RECIPIENT_ID` (default: `193`) — signing order 2
- `NUXT_DOCUMENSO_APPROVAL_RECIPIENT_ID` (default: `194`) — APPROVER, signing order 3
The template exposes eight text fields (`formValues` keys) and two boolean checkboxes.
## Field mapping
| Documenso key | Type | Legacy source | New `EoiContext` path | Notes |
| -------------- | ------- | --------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------- |
| `Name` | text | `interest['Full Name']` | `context.client.fullName` | The interest's point-of-contact client (billing signer). |
| `Email` | text | `interest['Email Address']` | `context.client.primaryEmail` | Primary email contact from `client_contacts`. |
| `Address` | text | `interest['Address']` | concat `context.client.address.{street,city,country}` | Concatenate street, city, country with `', '`. Empty if address is null. |
| `Yacht Name` | text | `interest['Yacht Name']` | `context.yacht.name` | Yacht is now a first-class row; pulled via `interest.yachtId`. |
| `Length` | text | `interest['Length']` | `context.yacht.lengthFt` | Send as string. Documenso doesn't enforce numeric format. |
| `Width` | text | `interest['Width']` | `context.yacht.widthFt` | Same. |
| `Draft` | text | `interest['Depth']` | `context.yacht.draftFt` | Legacy field was named "Depth" in NocoDB; Documenso key is "Draft". |
| `Berth Number` | text | `berthNumbers` (joined) | `context.berth.mooringNumber` | One berth per reservation. Multi-berth case was multi-interest in legacy. |
| `Lease_10` | boolean | hardcoded `false` | `false` | Hardcoded — legacy flow defaults to Purchase (not Lease). |
| `Purchase` | boolean | hardcoded `true` | `true` | Hardcoded — legacy flow defaults to Purchase. |
## Document `meta` fields (non-`formValues`)
| Documenso key | Type | Legacy source | New source |
| ------------------------- | ---- | ---------------------------------------- | ----------------------------------------------------------------- |
| `meta.message` | text | `Dear ${interest['Full Name']}...` | `Dear ${context.client.fullName}, ...port name interpolated` |
| `meta.subject` | text | `"Your LOI is ready to be signed"` | Same — constant. |
| `meta.redirectUrl` | text | `"https://portnimara.com"` | `context.port.redirectUrl` if per-port; otherwise global app URL. |
| `meta.distributionMethod` | text | `"NONE"` | Same — constant. We use manual send flow (Documenso webhook). |
| `title` | text | `` `${interest['Full Name']}-EOI-NDA` `` | `` `${context.client.fullName}-EOI-NDA` `` |
| `externalId` | text | `` `loi-${interestId}` `` | Same. |
## Recipients (non-`formValues`)
| Recipient | Role | Name | Email | Signing order |
| ------------------- | -------- | ------------------------- | ----------------------------- | ------------- |
| Client (signer) | SIGNER | `context.client.fullName` | `context.client.primaryEmail` | 1 |
| Developer (signer) | SIGNER | `"David Mizrahi"` | `"dm@portnimara.com"` | 2 |
| Approval (approver) | APPROVER | `"Abbie May"` | `"sales@portnimara.com"` | 3 |
The Developer and Approval recipients are currently hardcoded in the legacy flow. In the new system these should eventually come from port-level settings (e.g., `ports.settings.eoi.developerName` + email). For Task 11.2, keep them hardcoded as the legacy system does — tracking as TODO: "Replace hardcoded Developer/Approval recipients with port-level configuration."
## Company-owned yacht handling
The legacy flow has no concept of company ownership — the signer is always the interest's client. In the new system:
- If `context.yacht.ownerType === 'client'`: behavior unchanged.
- If `context.yacht.ownerType === 'company'`: the interest's point-of-contact client still signs (they're the representative of the yacht's owning company), but an extra block should appear in the message body: `"On behalf of ${context.company.legalName ?? context.company.name} (representing the yacht's owner)."`. This isn't a separate Documenso field — it's woven into `meta.message`.
Tracking this in the mapping doc rather than as a hard TODO because company-owned EOIs were rare in the legacy system and need product input before committing to the final wording.
## Deprecated fields (no longer sourced from `clients`)
The legacy system read these fields from the client row. They are now sourced elsewhere:
| Legacy source | New source |
| ------------------------- | --------------------------------------------------- |
| `client.yachtName` | `yachts.name` via `interest.yachtId` |
| `client.yachtLengthFt` | `yachts.lengthFt` via `interest.yachtId` |
| `client.yachtWidthFt` | `yachts.widthFt` via `interest.yachtId` |
| `client.yachtDraftFt` | `yachts.draftFt` via `interest.yachtId` |
| `client.companyName` | `companies.name` via polymorphic owner resolution |
| `client.berthSizeDesired` | Removed. Berth is picked via reservation, not text. |

View File

@@ -18,6 +18,12 @@ const nextConfig: NextConfig = {
experimental: {
typedRoutes: true,
},
outputFileTracingIncludes: {
// Bundle the EOI source PDF so the in-app EOI pathway can read it at
// runtime in the standalone build. Reading via fs.readFile from
// process.cwd() requires the file to be traced explicitly.
'/api/v1/document-templates/**': ['./assets/eoi-template.pdf'],
},
};
export default nextConfig;

View File

@@ -65,6 +65,7 @@
"next-themes": "^0.4.0",
"nodemailer": "^6.9.0",
"openai": "^6.27.0",
"pdf-lib": "^1.17.1",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"postgres": "^3.4.0",
@@ -91,9 +92,9 @@
"@types/react-dom": "^19.0.0",
"@vitest/coverage-v8": "^4.1.0",
"autoprefixer": "^10.4.27",
"esbuild": "^0.25.0",
"dotenv": "^17.3.1",
"drizzle-kit": "^0.30.0",
"esbuild": "^0.25.0",
"eslint": "^9.0.0",
"eslint-config-next": "15.1.0",
"eslint-config-prettier": "^9.1.0",

18
pnpm-lock.yaml generated
View File

@@ -152,6 +152,9 @@ importers:
openai:
specifier: ^6.27.0
version: 6.27.0(ws@8.18.3)(zod@3.25.76)
pdf-lib:
specifier: ^1.17.1
version: 1.17.1
pino:
specifier: ^9.5.0
version: 9.14.0
@@ -4417,6 +4420,9 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdf-lib@1.17.1:
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
@@ -5375,6 +5381,9 @@ packages:
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -9668,6 +9677,13 @@ snapshots:
pathe@2.0.3: {}
pdf-lib@1.17.1:
dependencies:
'@pdf-lib/standard-fonts': 1.0.0
'@pdf-lib/upng': 1.0.1
pako: 1.0.11
tslib: 1.14.1
peberminta@0.9.0: {}
performance-now@2.1.0: {}
@@ -10843,6 +10859,8 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
tslib@1.14.1: {}
tslib@2.8.1: {}
tsx@4.21.0:

View File

@@ -19,6 +19,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { OwnerPicker } from '@/components/shared/owner-picker';
import { InvoiceLineItems } from '@/components/invoices/invoice-line-items';
import { apiFetch } from '@/lib/api/client';
import { createInvoiceSchema, type CreateInvoiceInput } from '@/lib/validators/invoices';
@@ -156,27 +157,18 @@ export default function NewInvoicePage() {
<CardTitle className="text-base">Client Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="billingEntityType">
Billing Entity <span className="text-destructive">*</span>
<div className="space-y-2">
<Label>
Billing entity <span className="text-destructive">*</span>
</Label>
<div className="grid grid-cols-2 gap-2">
<Select
defaultValue="client"
onValueChange={(v) =>
setValue('billingEntity.type', v as 'client' | 'company')
<OwnerPicker
value={watchedValues.billingEntity ?? null}
onChange={(ref) => {
if (ref) {
setValue('billingEntity', ref, { shouldValidate: true });
}
>
<SelectTrigger id="billingEntityType">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="client">Client</SelectItem>
<SelectItem value="company">Company</SelectItem>
</SelectContent>
</Select>
<Input {...register('billingEntity.id')} placeholder="Entity ID" />
</div>
}}
/>
{errors.billingEntity && (
<p className="text-xs text-destructive">
{errors.billingEntity.message ??
@@ -185,7 +177,8 @@ export default function NewInvoicePage() {
</p>
)}
<p className="text-xs text-muted-foreground">
Picker UI is coming in Task 10.2 for now paste the client or company ID.
Select the client or company to invoice. Their name will be snapshotted into the
invoice.
</p>
</div>
@@ -299,7 +292,16 @@ export default function NewInvoicePage() {
<div>
<span className="text-muted-foreground">Billing Entity</span>
<p className="font-medium mt-0.5">
{watchedValues.billingEntity?.type}: {watchedValues.billingEntity?.id}
{watchedValues.billingEntity ? (
<>
<span className="capitalize">{watchedValues.billingEntity.type}</span>{' '}
<span className="text-xs opacity-60">
{watchedValues.billingEntity.id.slice(0, 12)}
</span>
</>
) : (
<span className="text-muted-foreground italic">Not selected</span>
)}
</p>
</div>
<div>

View File

@@ -11,7 +11,7 @@ export const POST = withAuth(
try {
const body = await parseBody(req, generateAndSignSchema);
const result = await generateAndSign(
params.id!,
params.id === 'documenso-template' ? null : params.id!,
ctx.portId,
{
clientId: body.clientId,
@@ -19,6 +19,7 @@ export const POST = withAuth(
berthId: body.berthId,
},
body.signers,
body.pathway,
{
userId: ctx.userId,
portId: ctx.portId,

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 { generateEoi } from '@/lib/services/documents.service';
import { generateEoiSchema } from '@/lib/validators/documents';
export const POST = withAuth(
withPermission('documents', 'create', async (req, ctx) => {
try {
const body = await parseBody(req, generateEoiSchema);
const doc = await generateEoi(body.interestId, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: doc }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -15,13 +15,7 @@ interface ClientDetailHeaderProps {
client: {
id: string;
fullName: string;
companyName?: string | null;
nationality?: string | null;
isProxy?: boolean;
proxyType?: string | null;
actualOwnerName?: string | null;
yachtName?: string | null;
berthSizeDesired?: string | null;
preferredContactMethod?: string | null;
preferredLanguage?: string | null;
timezone?: string | null;
@@ -36,13 +30,7 @@ interface ClientDetailHeaderProps {
type ClientFormClient = {
id: string;
fullName: string;
companyName?: string | null;
nationality?: string | null;
isProxy?: boolean;
proxyType?: string | null;
actualOwnerName?: string | null;
yachtName?: string | null;
berthSizeDesired?: string | null;
preferredContactMethod?: string | null;
preferredLanguage?: string | null;
timezone?: string | null;
@@ -67,8 +55,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const isArchived = !!client.archivedAt;
const archiveMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/clients/${client.id}`, { method: 'DELETE' }),
mutationFn: () => apiFetch(`/api/v1/clients/${client.id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
queryClient.invalidateQueries({ queryKey: ['clients'] });
@@ -77,8 +64,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
});
const restoreMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/clients/${client.id}/restore`, { method: 'POST' }),
mutationFn: () => apiFetch(`/api/v1/clients/${client.id}/restore`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
queryClient.invalidateQueries({ queryKey: ['clients'] });
@@ -86,10 +72,12 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
},
});
const primaryEmail = client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)
?? client.contacts?.find((c) => c.channel === 'email');
const primaryPhone = client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary)
?? client.contacts?.find((c) => c.channel === 'phone');
const primaryEmail =
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary) ??
client.contacts?.find((c) => c.channel === 'email');
const primaryPhone =
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
client.contacts?.find((c) => c.channel === 'phone');
return (
<>
@@ -97,23 +85,14 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
<div className="flex items-start gap-3 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold text-foreground truncate">
{client.fullName}
</h1>
<h1 className="text-2xl font-bold text-foreground truncate">{client.fullName}</h1>
{isArchived && (
<Badge variant="secondary" className="text-xs">Archived</Badge>
)}
{client.isProxy && (
<Badge variant="outline" className="text-xs capitalize">
Proxy {client.proxyType ? `(${client.proxyType.replace('_', ' ')})` : ''}
<Badge variant="secondary" className="text-xs">
Archived
</Badge>
)}
</div>
{client.companyName && (
<p className="text-muted-foreground mt-0.5">{client.companyName}</p>
)}
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
{client.source && (
<span>
@@ -148,11 +127,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
{/* Actions */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setEditOpen(true)}
>
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>

View File

@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
Dialog,
@@ -12,12 +12,19 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { apiFetch } from '@/lib/api/client';
interface EoiPrerequisites {
hasName: boolean;
hasEmail: boolean;
hasYachtDims: boolean;
hasYacht: boolean;
hasBerth: boolean;
}
@@ -30,11 +37,23 @@ interface EoiGenerateDialogProps {
const PREREQUISITE_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
{ key: 'hasName', label: 'Client has full name' },
{ key: 'hasEmail', label: 'Client has email address' },
{ key: 'hasYachtDims', label: 'Yacht dimensions set' },
{ key: 'hasYacht', label: 'Yacht linked to interest' },
{ key: 'hasBerth', label: 'Berth linked to interest' },
];
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
interface InAppTemplate {
id: string;
name: string;
description?: string | null;
templateType: string;
}
interface ListResponse {
data: InAppTemplate[];
}
export function EoiGenerateDialog({
interestId,
open,
@@ -44,9 +63,21 @@ export function EoiGenerateDialog({
const queryClient = useQueryClient();
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE);
const allMet = Object.values(prerequisites).every(Boolean);
// Load in-app EOI templates so the operator can pick one as an alternative
// to the Documenso external-signing flow.
const { data: templatesRes } = useQuery<ListResponse>({
queryKey: ['document-templates', { templateType: 'eoi', isActive: true }],
queryFn: () =>
apiFetch<ListResponse>('/api/v1/document-templates?templateType=eoi&isActive=true'),
enabled: open,
});
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
const handleGenerate = async () => {
if (!allMet) return;
@@ -54,9 +85,17 @@ export function EoiGenerateDialog({
setError(null);
try {
await apiFetch('/api/v1/documents/generate-eoi', {
const isDocumensoPath = selectedTemplate === DOCUMENSO_TEMPLATE_VALUE;
const url = `/api/v1/document-templates/${encodeURIComponent(selectedTemplate)}/generate-and-sign`;
await apiFetch(url, {
method: 'POST',
body: { interestId },
body: {
interestId,
pathway: isDocumensoPath ? 'documenso-template' : 'inapp',
// Signers are derived server-side from EOI context for both pathways
// when the template type is EOI, so the dialog doesn't collect them.
signers: [],
},
});
queryClient.invalidateQueries({ queryKey: ['documents', { interestId }] });
@@ -74,39 +113,58 @@ export function EoiGenerateDialog({
<DialogHeader>
<DialogTitle>Generate Expression of Interest</DialogTitle>
<DialogDescription>
The following prerequisites must be met before generating the EOI document.
Pick how to render the EOI. Documenso is the primary path; in-app templates use the same
source PDF but render and store the PDF locally before sending for signing.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-2">
{PREREQUISITE_LABELS.map(({ key, label }) => (
<div key={key} className="flex items-center gap-3">
<span
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
prerequisites[key]
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{prerequisites[key] ? '✓' : '✗'}
</span>
<span className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}>
{label}
</span>
</div>
))}
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="eoi-template">Template</Label>
<Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
<SelectTrigger id="eoi-template">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DOCUMENSO_TEMPLATE_VALUE}>
Documenso Standard EOI (recommended)
</SelectItem>
{inAppTemplates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Prerequisites</p>
{PREREQUISITE_LABELS.map(({ key, label }) => (
<div key={key} className="flex items-center gap-3">
<span
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
prerequisites[key] ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}
>
{prerequisites[key] ? '✓' : '✗'}
</span>
<span className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}>
{label}
</span>
</div>
))}
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleGenerate} disabled={!allMet || isGenerating}>
{isGenerating ? 'Generating...' : 'Generate EOI'}
{isGenerating ? 'Generating' : 'Generate EOI'}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -14,13 +14,9 @@ interface InterestDocumentsTabProps {
interface InterestData {
id: string;
yachtId?: string | null;
berthId?: string | null;
client?: {
fullName?: string | null;
yachtLengthFt?: string | null;
yachtLengthM?: string | null;
contacts?: Array<{ channel: string; value: string }>;
};
clientName?: string | null;
}
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
@@ -28,20 +24,14 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
const { data: interestRes } = useQuery({
queryKey: ['interests', interestId],
queryFn: () =>
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`),
queryFn: () => apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`),
});
const interest = interestRes?.data;
const prerequisites = {
hasName: Boolean(interest?.client?.fullName),
hasEmail: Boolean(
interest?.client?.contacts?.some((c) => c.channel === 'email' && c.value),
),
hasYachtDims: Boolean(
interest?.client?.yachtLengthFt || interest?.client?.yachtLengthM,
),
hasName: Boolean(interest?.clientName),
hasYacht: Boolean(interest?.yachtId),
hasBerth: Boolean(interest?.berthId),
};

View File

@@ -152,7 +152,7 @@ export function CommandSearch() {
id: c.id,
icon: 'client',
label: c.fullName,
sub: c.companyName,
sub: null,
}))}
iconMap={iconMap}
onSelect={(id) => navigate(`/${portSlug}/clients/${id}`)}

View File

@@ -9,7 +9,6 @@ import { CommandItem } from '@/components/ui/command';
interface ClientItem {
id: string;
fullName: string;
companyName: string | null;
}
interface InterestItem {
@@ -54,12 +53,7 @@ export function SearchResultItem({ type, item, onSelect }: SearchResultItemProps
return (
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
<User className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col">
<span className="text-sm font-medium">{item.fullName}</span>
{item.companyName && (
<span className="text-xs text-muted-foreground">{item.companyName}</span>
)}
</div>
<span className="text-sm font-medium">{item.fullName}</span>
</CommandItem>
);
}

View File

@@ -21,7 +21,6 @@ import { cn } from '@/lib/utils';
interface ClientOption {
id: string;
fullName: string;
companyName?: string | null;
}
interface ClientPickerProps {
@@ -89,12 +88,7 @@ export function ClientPicker({
<Check
className={cn('mr-2 h-4 w-4', value === c.id ? 'opacity-100' : 'opacity-0')}
/>
<span>
{c.fullName}
{c.companyName ? (
<span className="ml-2 text-xs opacity-60">{c.companyName}</span>
) : null}
</span>
<span>{c.fullName}</span>
</CommandItem>
))}
</CommandGroup>

View File

@@ -8,7 +8,7 @@ import { useDebounce } from '@/hooks/use-debounce';
// ─── Types ────────────────────────────────────────────────────────────────────
interface SearchResults {
clients: Array<{ id: string; fullName: string; companyName: string | null }>;
clients: Array<{ id: string; fullName: string }>;
interests: Array<{
id: string;
clientName: string;

View File

@@ -0,0 +1,13 @@
ALTER TABLE "clients" DROP COLUMN "company_name";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "is_proxy";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "proxy_type";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "actual_owner_name";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "relationship_notes";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "yacht_name";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "yacht_length_ft";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "yacht_width_ft";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "yacht_draft_ft";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "yacht_length_m";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "yacht_width_m";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "yacht_draft_m";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "berth_size_desired";

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,13 @@
"when": 1776959993173,
"tag": "0007_brainy_felicia_hardy",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1777204563579,
"tag": "0008_loud_ikaris",
"breakpoints": true
}
]
}

View File

@@ -3,7 +3,6 @@ import {
text,
boolean,
timestamp,
numeric,
jsonb,
index,
uniqueIndex,
@@ -22,20 +21,7 @@ export const clients = pgTable(
.notNull()
.references(() => ports.id),
fullName: text('full_name').notNull(),
companyName: text('company_name'),
nationality: text('nationality'),
isProxy: boolean('is_proxy').notNull().default(false),
proxyType: text('proxy_type'), // broker, representative, family_member, legal_counsel, other
actualOwnerName: text('actual_owner_name'),
relationshipNotes: text('relationship_notes'),
yachtName: text('yacht_name'),
yachtLengthFt: numeric('yacht_length_ft'),
yachtWidthFt: numeric('yacht_width_ft'),
yachtDraftFt: numeric('yacht_draft_ft'),
yachtLengthM: numeric('yacht_length_m'),
yachtWidthM: numeric('yacht_width_m'),
yachtDraftM: numeric('yacht_draft_m'),
berthSizeDesired: text('berth_size_desired'),
preferredContactMethod: text('preferred_contact_method'), // email, phone, whatsapp
preferredLanguage: text('preferred_language'),
timezone: text('timezone'),

View File

@@ -33,7 +33,12 @@ import {
berths,
berthReservations,
interests,
documentTemplates,
} from './schema';
import {
getStandardEoiTemplateHtml,
STANDARD_EOI_MERGE_FIELDS,
} from '@/lib/pdf/templates/eoi-standard-inapp';
// ─── Tunables ────────────────────────────────────────────────────────────────
@@ -840,6 +845,21 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
.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) ──────────────────────────────────────────────────
// Spread across pipeline stages.
// Valid stages (from interests schema comment):

View File

@@ -24,6 +24,10 @@ const envSchema = z.object({
DOCUMENSO_API_URL: z.string().url(),
DOCUMENSO_API_KEY: z.string().min(1),
DOCUMENSO_WEBHOOK_SECRET: z.string().min(16),
DOCUMENSO_TEMPLATE_ID_EOI: z.coerce.number().int().positive().default(8),
DOCUMENSO_CLIENT_RECIPIENT_ID: z.coerce.number().int().positive().default(192),
DOCUMENSO_DEVELOPER_RECIPIENT_ID: z.coerce.number().int().positive().default(193),
DOCUMENSO_APPROVAL_RECIPIENT_ID: z.coerce.number().int().positive().default(194),
// Email
SMTP_HOST: z.string().min(1),

View File

@@ -0,0 +1,101 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
import type { EoiContext } from '@/lib/services/eoi-context';
/**
* Source PDF for the in-app EOI pathway. Must contain AcroForm fields whose
* names match the Documenso template's `formValues` keys exactly:
*
* Text: Name, Email, Address, Yacht Name, Length, Width, Draft,
* Berth Number
* Checkbox: Lease_10, Purchase
*
* See assets/eoi-template/README.md for full details and the field mapping
* doc at docs/eoi-documenso-field-mapping.md for the canonical list.
*/
const DEFAULT_EOI_TEMPLATE_PATH = path.join(process.cwd(), 'assets', 'eoi-template.pdf');
function eoiTemplatePath(): string {
return process.env.EOI_TEMPLATE_PDF_PATH ?? DEFAULT_EOI_TEMPLATE_PATH;
}
export async function loadEoiTemplatePdf(): Promise<Uint8Array> {
const filePath = eoiTemplatePath();
try {
return await fs.readFile(filePath);
} catch (err) {
throw new Error(
`EOI source PDF not found at ${filePath}. Drop the same PDF used by the Documenso template (with AcroForm fields: Name, Email, Address, Yacht Name, Length, Width, Draft, Berth Number, Lease_10, Purchase) at this path, or override via EOI_TEMPLATE_PDF_PATH. Original error: ${(err as Error).message}`,
);
}
}
function formatAddress(address: EoiContext['client']['address']): string {
if (!address) return '';
return [address.street, address.city, address.country].filter(Boolean).join(', ');
}
function setText(form: ReturnType<PDFDocument['getForm']>, name: string, value: string): void {
try {
form.getTextField(name).setText(value);
} catch {
// Field absent or wrong type — skip silently so a slightly different PDF
// template still produces output. Missing field issues surface in QA, not
// at runtime as a 500.
}
}
function setCheckbox(
form: ReturnType<PDFDocument['getForm']>,
name: string,
checked: boolean,
): void {
try {
const cb = form.getCheckBox(name);
if (checked) cb.check();
else cb.uncheck();
} catch {
// See comment in setText.
}
}
/**
* Fills the AcroForm fields of the EOI source PDF with values drawn from
* EoiContext. Field names mirror the Documenso template `formValues` keys so
* a single source PDF can serve both pathways.
*
* The form is left interactive (not flattened) so a recipient can still tweak
* fields if needed before signing.
*/
export async function fillEoiFormFields(
pdfBytes: Uint8Array,
context: EoiContext,
): Promise<Uint8Array> {
const doc = await PDFDocument.load(pdfBytes);
const form = doc.getForm();
setText(form, 'Name', context.client.fullName);
setText(form, 'Email', context.client.primaryEmail ?? '');
setText(form, 'Address', formatAddress(context.client.address));
setText(form, 'Yacht Name', context.yacht.name);
setText(form, 'Length', context.yacht.lengthFt ?? '');
setText(form, 'Width', context.yacht.widthFt ?? '');
setText(form, 'Draft', context.yacht.draftFt ?? '');
setText(form, 'Berth Number', context.berth.mooringNumber);
setCheckbox(form, 'Purchase', true);
setCheckbox(form, 'Lease_10', false);
return doc.save();
}
/**
* Convenience: loads the source PDF from disk and returns the filled bytes.
*/
export async function generateEoiPdfFromTemplate(context: EoiContext): Promise<Uint8Array> {
const bytes = await loadEoiTemplatePdf();
return fillEoiFormFields(bytes, context);
}

View File

@@ -4,60 +4,126 @@ export const clientSummaryTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
schemas: [
[
{ name: 'portName', type: 'text', position: { x: 20, y: 15 }, width: 100, height: 10, fontSize: 16 },
{ name: 'title', type: 'text', position: { x: 20, y: 30 }, width: 170, height: 8, fontSize: 14 },
{ name: 'clientInfo', type: 'text', position: { x: 20, y: 45 }, width: 80, height: 40, fontSize: 9 },
{ name: 'contacts', type: 'text', position: { x: 110, y: 45 }, width: 80, height: 40, fontSize: 9 },
{ name: 'vesselInfo', type: 'text', position: { x: 20, y: 90 }, width: 170, height: 20, fontSize: 9 },
{ name: 'interests', type: 'text', position: { x: 20, y: 115 }, width: 170, height: 80, fontSize: 8 },
{ name: 'recentActivity', type: 'text', position: { x: 20, y: 200 }, width: 170, height: 60, fontSize: 8 },
{ name: 'generatedAt', type: 'text', position: { x: 20, y: 275 }, width: 170, height: 6, fontSize: 7 },
{
name: 'portName',
type: 'text',
position: { x: 20, y: 15 },
width: 100,
height: 10,
fontSize: 16,
},
{
name: 'title',
type: 'text',
position: { x: 20, y: 30 },
width: 170,
height: 8,
fontSize: 14,
},
{
name: 'clientInfo',
type: 'text',
position: { x: 20, y: 45 },
width: 80,
height: 40,
fontSize: 9,
},
{
name: 'contacts',
type: 'text',
position: { x: 110, y: 45 },
width: 80,
height: 40,
fontSize: 9,
},
{
name: 'yachts',
type: 'text',
position: { x: 20, y: 90 },
width: 170,
height: 25,
fontSize: 9,
},
{
name: 'interests',
type: 'text',
position: { x: 20, y: 120 },
width: 170,
height: 80,
fontSize: 8,
},
{
name: 'recentActivity',
type: 'text',
position: { x: 20, y: 205 },
width: 170,
height: 60,
fontSize: 8,
},
{
name: 'generatedAt',
type: 'text',
position: { x: 20, y: 275 },
width: 170,
height: 6,
fontSize: 7,
},
],
],
};
export interface YachtSummary {
name: string;
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
lengthM: string | null;
widthM: string | null;
draftM: string | null;
}
export function buildClientSummaryInputs(
client: Record<string, unknown>,
contacts: Record<string, unknown>[],
yachtList: YachtSummary[],
interestList: Record<string, unknown>[],
activity: Record<string, unknown>[],
port: Record<string, unknown>,
): Record<string, string> {
const clientInfo = [
`Name: ${client.fullName ?? 'N/A'}`,
client.companyName ? `Company: ${client.companyName}` : null,
client.nationality ? `Nationality: ${client.nationality}` : null,
client.source ? `Source: ${client.source}` : null,
client.isProxy ? `Proxy: Yes${client.proxyType ? ` (${client.proxyType})` : ''}` : null,
`Added: ${new Date(client.createdAt as string | Date).toLocaleDateString('en-GB')}`,
]
.filter(Boolean)
.join('\n');
const contactsText = contacts.length > 0
? contacts
.map(
(c) =>
`${(c.channel as string).charAt(0).toUpperCase() + (c.channel as string).slice(1)}${c.isPrimary ? ' (primary)' : ''}: ${c.value}${c.label ? ` [${c.label}]` : ''}`,
)
.join('\n')
: 'No contacts on file';
const contactsText =
contacts.length > 0
? contacts
.map(
(c) =>
`${(c.channel as string).charAt(0).toUpperCase() + (c.channel as string).slice(1)}${c.isPrimary ? ' (primary)' : ''}: ${c.value}${c.label ? ` [${c.label}]` : ''}`,
)
.join('\n')
: 'No contacts on file';
const vesselInfo = [
client.yachtName ? `Yacht: ${client.yachtName}` : null,
client.yachtLengthFt
? `Length: ${client.yachtLengthFt}ft${client.yachtLengthM ? ` / ${client.yachtLengthM}m` : ''}`
: null,
client.yachtWidthFt
? `Beam: ${client.yachtWidthFt}ft${client.yachtWidthM ? ` / ${client.yachtWidthM}m` : ''}`
: null,
client.yachtDraftFt
? `Draft: ${client.yachtDraftFt}ft${client.yachtDraftM ? ` / ${client.yachtDraftM}m` : ''}`
: null,
client.berthSizeDesired ? `Desired berth size: ${client.berthSizeDesired}` : null,
]
.filter(Boolean)
.join(' | ') || 'No vessel information on file';
const yachtsText =
yachtList.length > 0
? `Owned/Linked Yachts:\n${yachtList
.map((y) => {
const dims = [
y.lengthFt ? `${y.lengthFt}ft` : y.lengthM ? `${y.lengthM}m` : null,
y.widthFt ? `${y.widthFt}ft beam` : null,
y.draftFt ? `${y.draftFt}ft draft` : null,
]
.filter(Boolean)
.join(' · ');
return `${y.name}${dims ? ` (${dims})` : ''}`;
})
.join('\n')}`
: 'No yachts linked to this client';
const interestsText =
interestList.length > 0
@@ -84,7 +150,7 @@ export function buildClientSummaryInputs(
title: `Client Summary — ${client.fullName ?? ''}`,
clientInfo,
contacts: contactsText,
vesselInfo,
yachts: yachtsText,
interests: `Pipeline Interests:\n${interestsText}`,
recentActivity: `Recent Activity:\n${activityText}`,
generatedAt: `Generated: ${new Date().toLocaleString('en-GB')}`,

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>`;
}

View File

@@ -1,45 +0,0 @@
import type { Template } from '@pdfme/common';
export const eoiTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
schemas: [
[
{ name: 'portName', type: 'text', position: { x: 20, y: 20 }, width: 170, height: 10, fontSize: 18 },
{ name: 'title', type: 'text', position: { x: 20, y: 40 }, width: 170, height: 8, fontSize: 14 },
{ name: 'clientName', type: 'text', position: { x: 20, y: 60 }, width: 80, height: 6 },
{ name: 'clientEmail', type: 'text', position: { x: 20, y: 68 }, width: 80, height: 6 },
{ name: 'yachtName', type: 'text', position: { x: 20, y: 80 }, width: 80, height: 6 },
{ name: 'yachtDimensions', type: 'text', position: { x: 20, y: 88 }, width: 80, height: 6 },
{ name: 'berthNumber', type: 'text', position: { x: 110, y: 60 }, width: 80, height: 6 },
{ name: 'berthDimensions', type: 'text', position: { x: 110, y: 68 }, width: 80, height: 6 },
{ name: 'berthPrice', type: 'text', position: { x: 110, y: 76 }, width: 80, height: 6 },
{ name: 'date', type: 'text', position: { x: 20, y: 110 }, width: 80, height: 6 },
{ name: 'terms', type: 'text', position: { x: 20, y: 130 }, width: 170, height: 100, fontSize: 9 },
],
],
};
export function buildEoiInputs(
interest: Record<string, unknown>,
client: Record<string, unknown>,
berth: Record<string, unknown>,
port: Record<string, unknown>,
): Record<string, string> {
const contacts = (client.contacts as Array<{ channel: string; value: string }> | undefined) ?? [];
const emailContact = contacts.find((c) => c.channel === 'email');
return {
portName: (port.name as string) ?? 'Port Nimara',
title: 'Expression of Interest',
clientName: `Client: ${client.fullName as string}`,
clientEmail: `Email: ${emailContact?.value ?? 'N/A'}`,
yachtName: `Yacht: ${(client.yachtName as string) ?? 'N/A'}`,
yachtDimensions: `LOA: ${(client.yachtLengthFt as string) ?? '?'}ft × Beam: ${(client.yachtWidthFt as string) ?? '?'}ft × Draft: ${(client.yachtDraftFt as string) ?? '?'}ft`,
berthNumber: `Berth: ${berth.mooringNumber as string}`,
berthDimensions: `${(berth.lengthFt as string) ?? '?'}ft × ${(berth.widthFt as string) ?? '?'}ft`,
berthPrice: `Price: ${(berth.priceCurrency as string) ?? 'USD'} ${(berth.price as string) ?? 'TBD'}`,
date: `Date: ${new Date().toLocaleDateString('en-GB')}`,
terms:
"This Expression of Interest confirms the above-named client's interest in the specified berth. This document is non-binding until signed by all parties. Upon signing, the client agrees to proceed with the berth acquisition process as outlined in the full terms and conditions provided separately.",
};
}

View File

@@ -4,15 +4,78 @@ export const interestSummaryTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
schemas: [
[
{ name: 'portName', type: 'text', position: { x: 20, y: 15 }, width: 100, height: 10, fontSize: 16 },
{ name: 'title', type: 'text', position: { x: 20, y: 30 }, width: 170, height: 8, fontSize: 14 },
{ name: 'clientInfo', type: 'text', position: { x: 20, y: 45 }, width: 80, height: 30, fontSize: 9 },
{ name: 'berthInfo', type: 'text', position: { x: 110, y: 45 }, width: 80, height: 30, fontSize: 9 },
{ name: 'stageAndCategory', type: 'text', position: { x: 20, y: 80 }, width: 170, height: 15, fontSize: 9 },
{ name: 'milestones', type: 'text', position: { x: 20, y: 100 }, width: 170, height: 40, fontSize: 8 },
{ name: 'notes', type: 'text', position: { x: 20, y: 145 }, width: 170, height: 30, fontSize: 9 },
{ name: 'recentTimeline', type: 'text', position: { x: 20, y: 180 }, width: 170, height: 85, fontSize: 8 },
{ name: 'generatedAt', type: 'text', position: { x: 20, y: 275 }, width: 170, height: 6, fontSize: 7 },
{
name: 'portName',
type: 'text',
position: { x: 20, y: 15 },
width: 100,
height: 10,
fontSize: 16,
},
{
name: 'title',
type: 'text',
position: { x: 20, y: 30 },
width: 170,
height: 8,
fontSize: 14,
},
{
name: 'clientInfo',
type: 'text',
position: { x: 20, y: 45 },
width: 80,
height: 30,
fontSize: 9,
},
{
name: 'berthInfo',
type: 'text',
position: { x: 110, y: 45 },
width: 80,
height: 30,
fontSize: 9,
},
{
name: 'stageAndCategory',
type: 'text',
position: { x: 20, y: 80 },
width: 170,
height: 15,
fontSize: 9,
},
{
name: 'milestones',
type: 'text',
position: { x: 20, y: 100 },
width: 170,
height: 40,
fontSize: 8,
},
{
name: 'notes',
type: 'text',
position: { x: 20, y: 145 },
width: 170,
height: 30,
fontSize: 9,
},
{
name: 'recentTimeline',
type: 'text',
position: { x: 20, y: 180 },
width: 170,
height: 85,
fontSize: 8,
},
{
name: 'generatedAt',
type: 'text',
position: { x: 20, y: 275 },
width: 170,
height: 6,
fontSize: 7,
},
],
],
};
@@ -25,16 +88,16 @@ function formatDate(d: Date | string | null | undefined): string {
export function buildInterestSummaryInputs(
interest: Record<string, unknown>,
client: Record<string, unknown>,
yacht: Record<string, unknown> | null,
berth: Record<string, unknown> | null,
timeline: Record<string, unknown>[],
port: Record<string, unknown>,
): Record<string, string> {
const clientInfo = [
`Name: ${client?.fullName ?? 'N/A'}`,
client?.companyName ? `Company: ${client.companyName}` : null,
client?.yachtName ? `Yacht: ${client.yachtName}` : null,
client?.yachtLengthFt
? `Length: ${client.yachtLengthFt}ft${client.yachtLengthM ? ` / ${client.yachtLengthM}m` : ''}`
yacht?.name ? `Yacht: ${yacht.name}` : null,
yacht?.lengthFt
? `Length: ${yacht.lengthFt}ft${yacht.lengthM ? ` / ${yacht.lengthM}m` : ''}`
: null,
]
.filter(Boolean)
@@ -45,7 +108,9 @@ export function buildInterestSummaryInputs(
`Mooring: ${berth.mooringNumber}`,
berth.area ? `Area: ${berth.area}` : null,
berth.lengthFt ? `Length: ${berth.lengthFt}ft` : null,
berth.price ? `Price: ${berth.priceCurrency ?? 'USD'} ${Number(berth.price).toLocaleString()}` : null,
berth.price
? `Price: ${berth.priceCurrency ?? 'USD'} ${Number(berth.price).toLocaleString()}`
: null,
`Status: ${berth.status ?? 'available'}`,
]
.filter(Boolean)
@@ -73,9 +138,7 @@ export function buildInterestSummaryInputs(
`Deposit received: ${formatDate(interest.dateDepositReceived as Date | string | null | undefined)}`,
].join('\n');
const notesText = interest.notes
? `Notes:\n${interest.notes}`
: 'No notes';
const notesText = interest.notes ? `Notes:\n${interest.notes}` : 'No notes';
const timelineText =
timeline.length > 0

View File

@@ -7,7 +7,7 @@ import { QUEUE_CONFIGS } from '@/lib/queue';
// ─── Email draft generation ───────────────────────────────────────────────────
const MAX_OUTPUT_BYTES = 10 * 1024; // 10 KB
const OPENAI_TIMEOUT_MS = 30_000; // 30 s
const OPENAI_TIMEOUT_MS = 30_000; // 30 s
interface GenerateEmailDraftPayload {
interestId: string;
@@ -76,7 +76,12 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
if (!apiKey) {
// Fallback: template-based draft
return buildTemplateDraft({ clientName: client.fullName, context, berthMooring, pipelineStage: interest.pipelineStage });
return buildTemplateDraft({
clientName: client.fullName,
context,
berthMooring,
pipelineStage: interest.pipelineStage,
});
}
// Build prompt
@@ -91,8 +96,6 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
`Write ${contextDescriptions[context] ?? 'an email'} to a marina berth client.`,
'',
`Client name: ${client.fullName}`,
client.companyName ? `Company: ${client.companyName}` : null,
client.yachtName ? `Yacht: ${client.yachtName}` : null,
berthMooring ? `Berth: ${berthMooring}` : 'Berth: not yet assigned',
`Pipeline stage: ${interest.pipelineStage}`,
'',
@@ -164,7 +167,12 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
} catch (err) {
clearTimeout(timeoutId);
logger.warn({ err, interestId }, 'OpenAI call failed, falling back to template draft');
return buildTemplateDraft({ clientName: client.fullName, context, berthMooring, pipelineStage: interest.pipelineStage });
return buildTemplateDraft({
clientName: client.fullName,
context,
berthMooring,
pipelineStage: interest.pipelineStage,
});
}
return { subject, body, generatedAt: new Date().toISOString() };

View File

@@ -1,4 +1,4 @@
import { and, eq, ilike, inArray, isNull, or } from 'drizzle-orm';
import { and, eq, ilike, inArray, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients, clientContacts, clientRelationships, clientTags } from '@/lib/db/schema/clients';
@@ -65,7 +65,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
portId,
idColumn: clients.id,
updatedAtColumn: clients.updatedAt,
searchColumns: [clients.fullName, clients.companyName],
searchColumns: [clients.fullName],
searchTerm: search,
filters,
sort: sort ? { column: sortColumn, direction: order } : undefined,
@@ -197,7 +197,7 @@ export async function createClient(portId: string, data: CreateClientInput, meta
action: 'create',
entityType: 'client',
entityId: result.id,
newValue: { fullName: result.fullName, companyName: result.companyName },
newValue: { fullName: result.fullName },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
@@ -532,9 +532,7 @@ export async function findDuplicates(portId: string, fullName: string) {
export async function listClientOptions(portId: string, search?: string) {
const conditions = [eq(clients.portId, portId)];
if (search) {
conditions.push(
or(ilike(clients.fullName, `%${search}%`), ilike(clients.companyName, `%${search}%`))!,
);
conditions.push(ilike(clients.fullName, `%${search}%`));
}
return db

View File

@@ -56,6 +56,16 @@ export async function createDocument(
}) as Promise<DocumensoDocument>;
}
export async function generateDocumentFromTemplate(
templateId: number,
payload: Record<string, unknown>,
): Promise<DocumensoDocument> {
return documensoFetch(`/api/v1/templates/${templateId}/generate-document`, {
method: 'POST',
body: JSON.stringify(payload),
}) as Promise<DocumensoDocument>;
}
export async function sendDocument(docId: string): Promise<DocumensoDocument> {
return documensoFetch(`/api/v1/documents/${docId}/send`, {
method: 'POST',

View File

@@ -0,0 +1,120 @@
import type { EoiContext } from '@/lib/services/eoi-context';
export interface DocumensoTemplatePayload {
title: string;
externalId: string;
meta: {
message: string;
subject: string;
redirectUrl: string;
distributionMethod: 'NONE' | 'EMAIL';
};
formValues: {
Name: string;
Email: string;
Address: string;
'Yacht Name': string;
Length: string;
Width: string;
Draft: string;
'Berth Number': string;
Lease_10: boolean;
Purchase: boolean;
};
recipients: Array<{
id: number;
name: string;
email: string;
role: 'SIGNER' | 'APPROVER';
signingOrder: number;
}>;
}
export interface DocumensoPayloadOptions {
/** `interestId` used to build `externalId` and Documenso referencing. */
interestId: string;
/** Documenso recipient IDs — come from env vars. */
clientRecipientId: number;
developerRecipientId: number;
approvalRecipientId: number;
/** Hardcoded developer + approver names/emails (legacy). */
developerName?: string;
developerEmail?: string;
approverName?: string;
approverEmail?: string;
/** Redirect URL after signing. Defaults to the app URL. */
redirectUrl?: string;
}
const DEFAULT_DEVELOPER_NAME = 'David Mizrahi';
const DEFAULT_DEVELOPER_EMAIL = 'dm@portnimara.com';
const DEFAULT_APPROVER_NAME = 'Abbie May';
const DEFAULT_APPROVER_EMAIL = 'sales@portnimara.com';
const DEFAULT_REDIRECT_URL = 'https://portnimara.com';
function formatAddress(address: EoiContext['client']['address']): string {
if (!address) return '';
return [address.street, address.city, address.country].filter(Boolean).join(', ');
}
function buildMessage(context: EoiContext): string {
const greeting = `Dear ${context.client.fullName},`;
const body = `Thank you for your interest in a berth at ${context.port.name}. Please click the link above to sign your LOI.`;
const onBehalf =
context.owner.type === 'company' && context.company
? `\n\nOn behalf of ${context.company.legalName ?? context.company.name} (representing the yacht's owner).`
: '';
const footer = `\n\nBest Regards,\n${context.port.name} Team`;
return `${greeting}\n\n${body}${onBehalf}${footer}`;
}
export function buildDocumensoPayload(
context: EoiContext,
options: DocumensoPayloadOptions,
): DocumensoTemplatePayload {
return {
title: `${context.client.fullName}-EOI-NDA`,
externalId: `loi-${options.interestId}`,
meta: {
message: buildMessage(context),
subject: 'Your LOI is ready to be signed',
redirectUrl: options.redirectUrl ?? DEFAULT_REDIRECT_URL,
distributionMethod: 'NONE',
},
formValues: {
Name: context.client.fullName,
Email: context.client.primaryEmail ?? '',
Address: formatAddress(context.client.address),
'Yacht Name': context.yacht.name,
Length: context.yacht.lengthFt ?? '',
Width: context.yacht.widthFt ?? '',
Draft: context.yacht.draftFt ?? '',
'Berth Number': context.berth.mooringNumber,
Lease_10: false,
Purchase: true,
},
recipients: [
{
id: options.clientRecipientId,
name: context.client.fullName,
email: context.client.primaryEmail ?? '',
role: 'SIGNER',
signingOrder: 1,
},
{
id: options.developerRecipientId,
name: options.developerName ?? DEFAULT_DEVELOPER_NAME,
email: options.developerEmail ?? DEFAULT_DEVELOPER_EMAIL,
role: 'SIGNER',
signingOrder: 2,
},
{
id: options.approvalRecipientId,
name: options.approverName ?? DEFAULT_APPROVER_NAME,
email: options.approverEmail ?? DEFAULT_APPROVER_EMAIL,
role: 'APPROVER',
signingOrder: 3,
},
],
};
}

View File

@@ -7,6 +7,7 @@ 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 } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff';
@@ -16,7 +17,15 @@ import { minioClient, buildStoragePath } from '@/lib/minio';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { generatePdf } from '@/lib/pdf/generate';
import { createDocument as documensoCreate, sendDocument as documensoSend } from '@/lib/services/documenso-client';
import {
createDocument as documensoCreate,
sendDocument as documensoSend,
generateDocumentFromTemplate as documensoGenerateFromTemplate,
} from '@/lib/services/documenso-client';
import { buildDocumensoPayload } from '@/lib/services/documenso-payload';
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 { sendEmail } from '@/lib/email';
import type {
CreateTemplateInput,
@@ -37,52 +46,7 @@ interface AuditMeta {
// ─── Merge Field Definitions ──────────────────────────────────────────────────
const MERGE_FIELDS: Record<string, Array<{ token: string; label: string; required: boolean }>> = {
client: [
{ token: '{{client.fullName}}', label: 'Client Full Name', required: true },
{ token: '{{client.companyName}}', label: 'Company Name', required: false },
{ token: '{{client.email}}', label: 'Primary Email', required: false },
{ token: '{{client.phone}}', label: 'Primary Phone', required: false },
{ token: '{{client.nationality}}', label: 'Nationality', required: false },
{ token: '{{client.yachtName}}', label: 'Yacht Name', required: false },
{ token: '{{client.yachtLengthFt}}', label: 'Yacht Length (ft)', required: false },
{ token: '{{client.yachtLengthM}}', label: 'Yacht Length (m)', required: false },
{ token: '{{client.yachtWidthFt}}', label: 'Yacht Beam (ft)', required: false },
{ token: '{{client.yachtDraftFt}}', label: 'Yacht Draft (ft)', required: false },
{ token: '{{client.source}}', label: 'Lead Source', required: false },
],
interest: [
{ token: '{{interest.stage}}', label: 'Pipeline Stage', required: false },
{ token: '{{interest.leadCategory}}', label: 'Lead Category', required: false },
{ token: '{{interest.berthNumber}}', label: 'Berth Number', required: false },
{ token: '{{interest.eoiStatus}}', label: 'EOI Status', required: false },
{ token: '{{interest.dateFirstContact}}', label: 'Date First Contact', required: false },
{ token: '{{interest.dateEoiSigned}}', label: 'Date EOI Signed', required: false },
{ token: '{{interest.dateContractSigned}}', label: 'Date Contract Signed', required: false },
{ token: '{{interest.notes}}', label: 'Interest Notes', required: false },
],
berth: [
{ token: '{{berth.mooringNumber}}', label: 'Mooring Number', required: true },
{ token: '{{berth.area}}', label: 'Area', required: false },
{ token: '{{berth.status}}', label: 'Berth Status', required: false },
{ token: '{{berth.price}}', label: 'Price', required: false },
{ token: '{{berth.priceCurrency}}', label: 'Price Currency', required: false },
{ token: '{{berth.lengthFt}}', label: 'Length (ft)', required: false },
{ token: '{{berth.widthFt}}', label: 'Beam (ft)', required: false },
{ token: '{{berth.tenureType}}', label: 'Tenure Type', required: false },
{ token: '{{berth.tenureYears}}', label: 'Tenure Years', required: false },
],
port: [
{ token: '{{port.name}}', label: 'Port Name', required: false },
{ token: '{{port.defaultCurrency}}', label: 'Default Currency', required: false },
],
date: [
{ token: '{{date.today}}', label: "Today's Date", required: false },
{ token: '{{date.year}}', label: 'Current Year', required: false },
],
};
export function getMergeFields(): typeof MERGE_FIELDS {
export function getMergeFields(): MergeFieldCatalog {
return MERGE_FIELDS;
}
@@ -101,10 +65,13 @@ export async function listTemplates(portId: string, query: ListTemplatesInput) {
}
const sortColumn =
sort === 'name' ? documentTemplates.name :
sort === 'templateType' ? documentTemplates.templateType :
sort === 'createdAt' ? documentTemplates.createdAt :
documentTemplates.updatedAt;
sort === 'name'
? documentTemplates.name
: sort === 'templateType'
? documentTemplates.templateType
: sort === 'createdAt'
? documentTemplates.createdAt
: documentTemplates.updatedAt;
return buildListQuery({
table: documentTemplates,
@@ -178,10 +145,7 @@ export async function updateTemplate(
) {
const existing = await getTemplateById(id, portId);
const { diff } = diffEntity(
existing as Record<string, unknown>,
data as Record<string, unknown>,
);
const { diff } = diffEntity(existing as Record<string, unknown>, data as Record<string, unknown>);
const [updated] = await db
.update(documentTemplates)
@@ -261,69 +225,179 @@ export async function resolveTemplate(
tokenMap['{{port.defaultCurrency}}'] = port.defaultCurrency;
}
// Client tokens
// ─── 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
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 (from EoiContext)
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
? eoi.interest.dateFirstContact.toLocaleDateString('en-GB')
: '';
tokenMap['{{interest.notes}}'] = eoi.interest.notes ?? '';
} catch (err) {
// buildEoiContext throws ValidationError when the interest has no yacht
// or berth; non-EOI templates don't need those. Fall through to the
// legacy resolution path below. Re-throw anything else.
if (
!(err instanceof ValidationError) ||
!/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) {
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');
// Always resolve source from the DB — EoiContext doesn't carry it.
if (tokenMap['{{client.source}}'] === undefined) {
tokenMap['{{client.source}}'] = client.source ?? '';
}
tokenMap['{{client.fullName}}'] = client.fullName ?? '';
tokenMap['{{client.companyName}}'] = client.companyName ?? '';
tokenMap['{{client.email}}'] = emailContact?.value ?? '';
tokenMap['{{client.phone}}'] = phoneContact?.value ?? '';
tokenMap['{{client.nationality}}'] = client.nationality ?? '';
tokenMap['{{client.yachtName}}'] = client.yachtName ?? '';
tokenMap['{{client.yachtLengthFt}}'] = client.yachtLengthFt ? String(client.yachtLengthFt) : '';
tokenMap['{{client.yachtLengthM}}'] = client.yachtLengthM ? String(client.yachtLengthM) : '';
tokenMap['{{client.yachtWidthFt}}'] = client.yachtWidthFt ? String(client.yachtWidthFt) : '';
tokenMap['{{client.yachtDraftFt}}'] = client.yachtDraftFt ? String(client.yachtDraftFt) : '';
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.nationality ?? '';
}
}
}
// Interest tokens
// 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) {
tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? '';
tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? '';
if (!eoiContextLoaded) {
tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? '';
tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? '';
tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact
? new Date(interest.dateFirstContact).toLocaleDateString('en-GB')
: '';
tokenMap['{{interest.notes}}'] = interest.notes ?? '';
}
// These are never populated by EoiContext — always fill them in.
tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? '';
tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact
? new Date(interest.dateFirstContact).toLocaleDateString('en-GB')
: '';
tokenMap['{{interest.dateEoiSigned}}'] = interest.dateEoiSigned
? new Date(interest.dateEoiSigned).toLocaleDateString('en-GB')
: '';
tokenMap['{{interest.dateContractSigned}}'] = interest.dateContractSigned
? new Date(interest.dateContractSigned).toLocaleDateString('en-GB')
: '';
tokenMap['{{interest.notes}}'] = interest.notes ?? '';
// Berth number from interest if berthId not separately provided
if (interest.berthId && !context.berthId) {
// Derive berth number from the interest when berthId wasn't passed and
// the EOI path didn't already populate it.
if (!eoiContextLoaded && interest.berthId && !context.berthId) {
const interestBerth = await db.query.berths.findFirst({
where: eq(berths.id, interest.berthId),
});
tokenMap['{{interest.berthNumber}}'] = interestBerth?.mooringNumber ?? '';
tokenMap['{{berth.mooringNumber}}'] = interestBerth?.mooringNumber ?? '';
} else {
tokenMap['{{interest.berthNumber}}'] = context.berthId
? tokenMap['{{berth.mooringNumber}}'] ?? ''
if (interestBerth) {
tokenMap['{{interest.berthNumber}}'] = interestBerth.mooringNumber;
if (!tokenMap['{{berth.mooringNumber}}']) {
tokenMap['{{berth.mooringNumber}}'] = interestBerth.mooringNumber;
}
} else {
tokenMap['{{interest.berthNumber}}'] ??= '';
}
} else if (!eoiContextLoaded) {
tokenMap['{{interest.berthNumber}}'] ??= context.berthId
? (tokenMap['{{berth.mooringNumber}}'] ?? '')
: '';
}
}
}
// Berth tokens
if (context.berthId) {
// 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),
});
@@ -355,9 +429,7 @@ export async function resolveTemplate(
}
if (missing.length > 0) {
throw new ValidationError(
`Missing required merge field values: ${missing.join(', ')}`,
);
throw new ValidationError(`Missing required merge field values: ${missing.join(', ')}`);
}
// Interpolate all tokens
@@ -549,23 +621,175 @@ export async function generateAndSend(
return { document, file };
}
// ─── 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,
): Promise<{ document: DbDocument; file: DbFile }> {
if (!context.interestId) {
throw new ValidationError('interestId is required for EOI template generation');
}
const eoiContext = await buildEoiContext(context.interestId, portId);
const pdfBytes = await generateEoiPdfFromTemplate(eoiContext);
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',
);
await minioClient.putObject(
env.MINIO_BUCKET,
storagePath,
Buffer.from(pdfBytes),
pdfBytes.byteLength,
{ 'Content-Type': 'application/pdf' },
);
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; other template types fall back to the
* HTML→pdfme 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,
) {
if (pathway === 'documenso-template') {
return generateAndSignViaDocumensoTemplate(portId, context, meta);
}
if (!templateId) {
throw new ValidationError('templateId is required for inapp pathway');
}
return generateAndSignViaInApp(templateId, portId, context, signers, meta);
}
async function generateAndSignViaInApp(
templateId: string,
portId: string,
context: GenerateInput,
signers: GenerateAndSignInput['signers'],
meta: AuditMeta,
) {
const { document: documentRecord, file } = await generateFromTemplate(
templateId,
portId,
context,
meta,
) as { document: DbDocument; file: DbFile };
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);
resolvedSigners = [
{
name: eoiCtx.client.fullName,
email: eoiCtx.client.primaryEmail ?? '',
role: 'signer',
signingOrder: 1,
},
{ name: 'David Mizrahi', email: 'dm@portnimara.com', role: 'signer', signingOrder: 2 },
{ name: 'Abbie May', email: 'sales@portnimara.com', 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). 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;
});
// Fetch PDF bytes from MinIO to send to Documenso
const pdfStream = await minioClient.getObject(env.MINIO_BUCKET, file.storagePath);
const chunks: Buffer[] = [];
@@ -578,7 +802,7 @@ export async function generateAndSign(
const documensoDoc = await documensoCreate(
template.name,
pdfBase64,
signers.map((s) => ({
resolvedSigners.map((s) => ({
name: s.name,
email: s.email,
role: s.role,
@@ -606,12 +830,82 @@ export async function generateAndSign(
entityType: 'document',
entityId: documentRecord.id,
newValue: { status: 'sent', documensoId: documensoDoc.id },
metadata: { action: 'generate_and_sign', signerCount: signers.length },
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'] });
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,
) {
if (!context.interestId) {
throw new ValidationError('interestId is required for documenso-template pathway');
}
const eoiContext = await buildEoiContext(context.interestId, portId);
const payload = buildDocumensoPayload(eoiContext, {
interestId: context.interestId,
clientRecipientId: env.DOCUMENSO_CLIENT_RECIPIENT_ID,
developerRecipientId: env.DOCUMENSO_DEVELOPER_RECIPIENT_ID,
approvalRecipientId: env.DOCUMENSO_APPROVAL_RECIPIENT_ID,
redirectUrl: env.APP_URL,
});
const documensoDoc = await documensoGenerateFromTemplate(
env.DOCUMENSO_TEMPLATE_ID_EOI,
payload as unknown as Record<string, unknown>,
);
// 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,
isManualUpload: false,
createdBy: meta.userId,
})
.returning();
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,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:created', { documentId: documentRecord!.id });
return { document: documentRecord!, file: null };
}

View File

@@ -4,7 +4,6 @@ import { db } from '@/lib/db';
import { documents, documentSigners, documentEvents, files } from '@/lib/db/schema/documents';
import { interests } from '@/lib/db/schema/interests';
import { clients } from '@/lib/db/schema/clients';
import { berths } from '@/lib/db/schema/berths';
import { ports } from '@/lib/db/schema/ports';
import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog } from '@/lib/audit';
@@ -14,8 +13,6 @@ import { emitToRoom } from '@/lib/socket/server';
import { minioClient, buildStoragePath } from '@/lib/minio';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { generatePdf } from '@/lib/pdf/generate';
import { eoiTemplate, buildEoiInputs } from '@/lib/pdf/templates/eoi-template';
import { evaluateRule } from '@/lib/services/berth-rules-engine';
import {
createDocument as documensoCreate,
@@ -50,10 +47,13 @@ export async function listDocuments(portId: string, query: ListDocumentsInput) {
if (status) filters.push(eq(documents.status, status));
const sortColumn =
sort === 'title' ? documents.title :
sort === 'status' ? documents.status :
sort === 'documentType' ? documents.documentType :
documents.createdAt;
sort === 'title'
? documents.title
: sort === 'status'
? documents.status
: sort === 'documentType'
? documents.documentType
: documents.createdAt;
return buildListQuery({
table: documents,
@@ -84,11 +84,7 @@ export async function getDocumentById(id: string, portId: string) {
// ─── Create ───────────────────────────────────────────────────────────────────
export async function createDocument(
portId: string,
data: CreateDocumentInput,
meta: AuditMeta,
) {
export async function createDocument(portId: string, data: CreateDocumentInput, meta: AuditMeta) {
const [doc] = await db
.insert(documents)
.values({
@@ -169,9 +165,7 @@ export async function deleteDocument(id: string, portId: string, meta: AuditMeta
throw new ConflictError('Cannot delete a document that is currently in signing process');
}
await db
.delete(documents)
.where(and(eq(documents.id, id), eq(documents.portId, portId)));
await db.delete(documents).where(and(eq(documents.id, id), eq(documents.portId, portId)));
void createAuditLog({
userId: meta.userId,
@@ -187,116 +181,6 @@ export async function deleteDocument(id: string, portId: string, meta: AuditMeta
emitToRoom(`port:${portId}`, 'document:deleted', { documentId: id });
}
// ─── Generate EOI (BR-020) ────────────────────────────────────────────────────
export async function generateEoi(interestId: string, portId: string, meta: AuditMeta) {
// Fetch interest + related data
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
});
if (!interest) throw new NotFoundError('Interest');
const client = await db.query.clients.findFirst({
where: eq(clients.id, interest.clientId),
with: { contacts: true },
});
if (!client) throw new NotFoundError('Client');
// BR-020: Check prerequisites
const missing: Array<{ field: string; message: string }> = [];
if (!client.fullName) missing.push({ field: 'client.fullName', message: 'Client must have a full name' });
const emailContact = (client.contacts as Array<{ channel: string; value: string }> | undefined)?.find(
(c) => c.channel === 'email',
);
if (!emailContact?.value) missing.push({ field: 'client.email', message: 'Client must have an email contact' });
if (!client.yachtLengthFt && !client.yachtLengthM) {
missing.push({ field: 'client.yachtDimensions', message: 'Client must have yacht dimensions' });
}
if (!interest.berthId) missing.push({ field: 'interest.berthId', message: 'Interest must have a berth linked' });
if (missing.length > 0) {
throw new ValidationError('Missing prerequisites for EOI generation', missing);
}
const [berth, port] = await Promise.all([
db.query.berths.findFirst({ where: eq(berths.id, interest.berthId!) }),
db.query.ports.findFirst({ where: eq(ports.id, portId) }),
]);
if (!berth) throw new NotFoundError('Berth');
if (!port) throw new NotFoundError('Port');
// Generate PDF
const inputs = buildEoiInputs(
interest as unknown as Record<string, unknown>,
{ ...client, contacts: client.contacts } as unknown as Record<string, unknown>,
berth as unknown as Record<string, unknown>,
port as unknown as Record<string, unknown>,
);
const pdfBytes = await generatePdf(eoiTemplate, [inputs]);
const pdfBuffer = Buffer.from(pdfBytes);
// Store in MinIO
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(port.slug, 'eoi', interestId, fileId, 'pdf');
await minioClient.putObject(env.MINIO_BUCKET, storagePath, pdfBuffer, pdfBuffer.length, {
'Content-Type': 'application/pdf',
});
// Create files record
const [fileRecord] = await db
.insert(files)
.values({
portId,
clientId: client.id,
filename: `eoi-${interestId}.pdf`,
originalName: `eoi-${interestId}.pdf`,
mimeType: 'application/pdf',
sizeBytes: String(pdfBuffer.length),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'eoi',
uploadedBy: meta.userId,
})
.returning();
// Create document record
const [doc] = await db
.insert(documents)
.values({
portId,
interestId,
clientId: client.id,
documentType: 'eoi',
title: `EOI ${client.fullName} / ${berth.mooringNumber}`,
status: 'draft',
fileId: fileRecord!.id,
createdBy: meta.userId,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'document',
entityId: doc!.id,
newValue: { documentType: 'eoi', interestId },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:created', { documentId: doc!.id, type: 'eoi' });
return doc!;
}
// ─── Send for Signing (BR-021) ────────────────────────────────────────────────
export async function sendForSigning(documentId: string, portId: string, meta: AuditMeta) {
@@ -318,9 +202,9 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
if (!client) throw new ValidationError('Document has no associated client');
const emailContact = (client.contacts as Array<{ channel: string; value: string }> | undefined)?.find(
(c) => c.channel === 'email',
);
const emailContact = (
client.contacts as Array<{ channel: string; value: string }> | undefined
)?.find((c) => c.channel === 'email');
if (!emailContact?.value) throw new ValidationError('Client has no email contact');
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
@@ -373,7 +257,12 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
const documensoDoc = await documensoCreate(doc.title, pdfBase64, [
{ name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 },
{ name: port.name, email: `developer@${port.slug}.com`, role: 'SIGNER', signingOrder: 2 },
{ name: `${port.name} Sales`, email: `sales@${port.slug}.com`, role: 'SIGNER', signingOrder: 3 },
{
name: `${port.name} Sales`,
email: `sales@${port.slug}.com`,
role: 'SIGNER',
signingOrder: 3,
},
]);
await documensoSend(documensoDoc.id);
@@ -432,7 +321,12 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:sent', { documentId, type: doc.documentType, signerCount: 3, documensoId: documensoDoc.id });
emitToRoom(`port:${portId}`, 'document:sent', {
documentId,
type: doc.documentType,
signerCount: 3,
documensoId: documensoDoc.id,
});
return await getDocumentById(documentId, portId);
}
@@ -453,13 +347,9 @@ export async function uploadSignedManually(
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf');
await minioClient.putObject(
env.MINIO_BUCKET,
storagePath,
fileData.buffer,
fileData.size,
{ 'Content-Type': fileData.mimeType },
);
await minioClient.putObject(env.MINIO_BUCKET, storagePath, fileData.buffer, fileData.size, {
'Content-Type': fileData.mimeType,
});
const [fileRecord] = await db
.insert(files)
@@ -612,9 +502,7 @@ export async function handleRecipientSigned(eventData: {
});
}
export async function handleDocumentCompleted(eventData: {
documentId: string;
}) {
export async function handleDocumentCompleted(eventData: { documentId: string }) {
const doc = await db.query.documents.findFirst({
where: eq(documents.documensoId, eventData.documentId),
});
@@ -718,9 +606,7 @@ export async function handleDocumentCompleted(eventData: {
}
}
export async function handleDocumentExpired(eventData: {
documentId: string;
}) {
export async function handleDocumentExpired(eventData: { documentId: string }) {
const doc = await db.query.documents.findFirst({
where: eq(documents.documensoId, eventData.documentId),
});

View File

@@ -66,17 +66,17 @@ async function assertYachtBelongsToClient(
async function resolveLeadCategory(
clientId: string,
leadCategory: string | undefined | null,
yachtId?: string | null,
): Promise<string | undefined> {
if (leadCategory && leadCategory !== 'general_interest') {
return leadCategory;
}
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (client && (client.yachtLengthFt || client.yachtLengthM)) {
return 'specific_qualified';
if (yachtId) {
const yacht = await db.query.yachts.findFirst({ where: eq(yachts.id, yachtId) });
if (yacht && (yacht.lengthFt || yacht.lengthM)) {
return 'specific_qualified';
}
}
return leadCategory ?? undefined;
@@ -275,7 +275,11 @@ export async function createInterest(portId: string, data: CreateInterestInput,
const { tagIds, ...interestData } = data;
// BR-011: auto-promote leadCategory
const resolvedLeadCategory = await resolveLeadCategory(data.clientId, data.leadCategory);
const resolvedLeadCategory = await resolveLeadCategory(
data.clientId,
data.leadCategory,
data.yachtId,
);
const result = await withTransaction(async (tx) => {
const [interest] = await tx
@@ -350,6 +354,7 @@ export async function updateInterest(
resolvedLeadCategory = (await resolveLeadCategory(
existing.clientId,
data.leadCategory,
data.yachtId ?? existing.yachtId,
)) as typeof data.leadCategory;
}

View File

@@ -1,9 +1,11 @@
import { and, desc, eq, inArray } from 'drizzle-orm';
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { berths, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
import { yachts } from '@/lib/db/schema/yachts';
import { companyMemberships } from '@/lib/db/schema/companies';
import { auditLogs } from '@/lib/db/schema/system';
import { ports } from '@/lib/db/schema/ports';
import { NotFoundError } from '@/lib/errors';
@@ -12,10 +14,7 @@ import {
clientSummaryTemplate,
buildClientSummaryInputs,
} from '@/lib/pdf/templates/client-summary-template';
import {
berthSpecTemplate,
buildBerthSpecInputs,
} from '@/lib/pdf/templates/berth-spec-template';
import { berthSpecTemplate, buildBerthSpecInputs } from '@/lib/pdf/templates/berth-spec-template';
import {
interestSummaryTemplate,
buildInterestSummaryInputs,
@@ -63,9 +62,7 @@ export async function exportClientPdf(clientId: string, portId: string): Promise
.limit(20);
// Enrich interests with berth mooring numbers
const berthIds = interestList
.map((i) => i.berthId)
.filter(Boolean) as string[];
const berthIds = interestList.map((i) => i.berthId).filter(Boolean) as string[];
let berthsMap: Record<string, string> = {};
if (berthIds.length > 0) {
@@ -81,7 +78,44 @@ export async function exportClientPdf(clientId: string, portId: string): Promise
berthMooringNumber: i.berthId ? (berthsMap[i.berthId] ?? null) : null,
}));
const inputs = buildClientSummaryInputs(client, contactList, enrichedInterests, activity, port ?? {});
// Yachts owned by the client directly OR by a company they're an active
// member of. Active membership = no end date.
const memberCompanies = await db
.select({ companyId: companyMemberships.companyId })
.from(companyMemberships)
.where(and(eq(companyMemberships.clientId, clientId), isNull(companyMemberships.endDate)));
const companyIds = memberCompanies.map((m) => m.companyId);
const ownerConditions = [
and(eq(yachts.currentOwnerType, 'client'), eq(yachts.currentOwnerId, clientId))!,
];
if (companyIds.length > 0) {
ownerConditions.push(
and(eq(yachts.currentOwnerType, 'company'), inArray(yachts.currentOwnerId, companyIds))!,
);
}
const ownedYachts = await db
.select({
name: yachts.name,
lengthFt: yachts.lengthFt,
widthFt: yachts.widthFt,
draftFt: yachts.draftFt,
lengthM: yachts.lengthM,
widthM: yachts.widthM,
draftM: yachts.draftM,
})
.from(yachts)
.where(and(eq(yachts.portId, portId), isNull(yachts.archivedAt), or(...ownerConditions)));
const inputs = buildClientSummaryInputs(
client,
contactList,
ownedYachts,
enrichedInterests,
activity,
port ?? {},
);
return generatePdf(clientSummaryTemplate, [inputs]);
}
@@ -143,7 +177,13 @@ export async function exportBerthPdf(berthId: string, portId: string): Promise<U
.orderBy(desc(interests.updatedAt))
.limit(20);
const inputs = buildBerthSpecInputs(berth, enrichedWaitingList, maintenance, linkedInterests, port ?? {});
const inputs = buildBerthSpecInputs(
berth,
enrichedWaitingList,
maintenance,
linkedInterests,
port ?? {},
);
return generatePdf(berthSpecTemplate, [inputs]);
}
@@ -169,6 +209,11 @@ export async function exportInterestPdf(interestId: string, portId: string): Pro
berth = await db.query.berths.findFirst({ where: eq(berths.id, interest.berthId) });
}
let yacht = null;
if (interest.yachtId) {
yacht = await db.query.yachts.findFirst({ where: eq(yachts.id, interest.yachtId) });
}
// Audit timeline (last 20 events for this interest)
const timeline = await db
.select()
@@ -183,7 +228,14 @@ export async function exportInterestPdf(interestId: string, portId: string): Pro
.orderBy(desc(auditLogs.createdAt))
.limit(20);
const inputs = buildInterestSummaryInputs(interest, client ?? {}, berth ?? null, timeline, port ?? {});
const inputs = buildInterestSummaryInputs(
interest,
client ?? {},
yacht ?? null,
berth ?? null,
timeline,
port ?? {},
);
return generatePdf(interestSummaryTemplate, [inputs]);
}

View File

@@ -8,7 +8,6 @@ import { redis } from '@/lib/redis';
interface ClientResult {
id: string;
fullName: string;
companyName: string | null;
}
interface InterestResult {
@@ -52,15 +51,15 @@ interface SearchResults {
export async function search(portId: string, query: string): Promise<SearchResults> {
const [clientRows, berthRows, interestRows, yachtRows, companyRows] = await Promise.all([
// Clients: full-text search via tsvector
db.execute<{ id: string; full_name: string; company_name: string | null }>(sql`
SELECT id, full_name, company_name
db.execute<{ id: string; full_name: string }>(sql`
SELECT id, full_name
FROM clients
WHERE port_id = ${portId}
AND archived_at IS NULL
AND to_tsvector('simple', coalesce(full_name, '') || ' ' || coalesce(company_name, ''))
AND to_tsvector('simple', coalesce(full_name, ''))
@@ plainto_tsquery('simple', ${query})
ORDER BY ts_rank(
to_tsvector('simple', coalesce(full_name, '') || ' ' || coalesce(company_name, '')),
to_tsvector('simple', coalesce(full_name, '')),
plainto_tsquery('simple', ${query})
) DESC
LIMIT 10
@@ -157,7 +156,6 @@ export async function search(portId: string, query: string): Promise<SearchResul
clients: Array.from(clientRows).map((r) => ({
id: r.id,
fullName: r.full_name,
companyName: r.company_name ?? null,
})),
berths: Array.from(berthRows).map((r) => ({
id: r.id,

View File

@@ -0,0 +1,80 @@
export interface MergeField {
token: string;
label: string;
required: boolean;
}
export type MergeFieldCatalog = Record<string, MergeField[]>;
export const MERGE_FIELDS: MergeFieldCatalog = {
client: [
{ token: '{{client.fullName}}', label: 'Client Full Name', required: true },
{ token: '{{client.email}}', label: 'Primary Email', required: false },
{ token: '{{client.phone}}', label: 'Primary Phone', required: false },
{ token: '{{client.nationality}}', label: 'Nationality', required: false },
{ token: '{{client.source}}', label: 'Lead Source', required: false },
],
yacht: [
{ token: '{{yacht.name}}', label: 'Yacht Name', required: false },
{ token: '{{yacht.hullNumber}}', label: 'Hull Number', required: false },
{ token: '{{yacht.registration}}', label: 'Registration', required: false },
{ token: '{{yacht.flag}}', label: 'Flag', required: false },
{ token: '{{yacht.yearBuilt}}', label: 'Year Built', required: false },
{ token: '{{yacht.lengthFt}}', label: 'Yacht Length (ft)', required: false },
{ token: '{{yacht.widthFt}}', label: 'Yacht Beam (ft)', required: false },
{ token: '{{yacht.draftFt}}', label: 'Yacht Draft (ft)', required: false },
{ token: '{{yacht.lengthM}}', label: 'Yacht Length (m)', required: false },
{ token: '{{yacht.widthM}}', label: 'Yacht Beam (m)', required: false },
{ token: '{{yacht.draftM}}', label: 'Yacht Draft (m)', required: false },
],
company: [
{ token: '{{company.name}}', label: 'Company Name', required: false },
{ token: '{{company.legalName}}', label: 'Company Legal Name', required: false },
{ token: '{{company.taxId}}', label: 'Company Tax ID', required: false },
{ token: '{{company.billingAddress}}', label: 'Company Billing Address', required: false },
],
owner: [
{ token: '{{owner.type}}', label: 'Yacht Owner Type', required: false },
{ token: '{{owner.name}}', label: 'Yacht Owner Name', required: false },
{ token: '{{owner.legalName}}', label: 'Yacht Owner Legal Name', required: false },
],
interest: [
{ token: '{{interest.stage}}', label: 'Pipeline Stage', required: false },
{ token: '{{interest.leadCategory}}', label: 'Lead Category', required: false },
{ token: '{{interest.berthNumber}}', label: 'Berth Number', required: false },
{ token: '{{interest.eoiStatus}}', label: 'EOI Status', required: false },
{ token: '{{interest.dateFirstContact}}', label: 'Date First Contact', required: false },
{ token: '{{interest.dateEoiSigned}}', label: 'Date EOI Signed', required: false },
{ token: '{{interest.dateContractSigned}}', label: 'Date Contract Signed', required: false },
{ token: '{{interest.notes}}', label: 'Interest Notes', required: false },
],
berth: [
// Non-required so non-EOI templates (welcome letters etc.) don't fail.
// EOI-specific required-field enforcement lives in STANDARD_EOI_MERGE_FIELDS.
{ token: '{{berth.mooringNumber}}', label: 'Mooring Number', required: false },
{ token: '{{berth.area}}', label: 'Area', required: false },
{ token: '{{berth.status}}', label: 'Berth Status', required: false },
{ token: '{{berth.price}}', label: 'Price', required: false },
{ token: '{{berth.priceCurrency}}', label: 'Price Currency', required: false },
{ token: '{{berth.lengthFt}}', label: 'Length (ft)', required: false },
{ token: '{{berth.widthFt}}', label: 'Beam (ft)', required: false },
{ token: '{{berth.tenureType}}', label: 'Tenure Type', required: false },
{ token: '{{berth.tenureYears}}', label: 'Tenure Years', required: false },
],
port: [
{ token: '{{port.name}}', label: 'Port Name', required: false },
{ token: '{{port.defaultCurrency}}', label: 'Default Currency', required: false },
],
date: [
{ token: '{{date.today}}', label: "Today's Date", required: false },
{ token: '{{date.year}}', label: 'Current Year', required: false },
],
};
/**
* Flat set of every valid token from the catalog. Used as the validator
* allow-list so unknown tokens are rejected at template creation time.
*/
export const VALID_MERGE_TOKENS: ReadonlySet<string> = new Set(
Object.values(MERGE_FIELDS).flatMap((scope) => scope.map((field) => field.token)),
);

View File

@@ -1,11 +1,25 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { VALID_MERGE_TOKENS } from '@/lib/templates/merge-fields';
const mergeFieldsSchema = z
.array(z.string())
.optional()
.default([])
.refine(
(tokens) => tokens.every((t) => VALID_MERGE_TOKENS.has(t)),
(tokens) => {
const unknown = tokens?.filter((t) => !VALID_MERGE_TOKENS.has(t)) ?? [];
return { message: `Unknown merge tokens: ${unknown.join(', ')}` };
},
);
export const createTemplateSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(500).optional(),
templateType: z.enum([
'eoi',
'welcome_letter',
'handover_checklist',
'acknowledgment',
@@ -13,7 +27,7 @@ export const createTemplateSchema = z.object({
'custom',
]),
bodyHtml: z.string().min(1),
mergeFields: z.array(z.string()).optional().default([]),
mergeFields: mergeFieldsSchema,
isActive: z.boolean().default(true),
});
@@ -38,6 +52,7 @@ export const generateAndSendSchema = generateSchema.extend({
});
export const generateAndSignSchema = generateSchema.extend({
pathway: z.enum(['inapp', 'documenso-template']).default('inapp'),
signers: z
.array(
z.object({
@@ -47,7 +62,8 @@ export const generateAndSignSchema = generateSchema.extend({
signingOrder: z.number().int().min(1),
}),
)
.min(1),
.optional()
.default([]),
});
export type CreateTemplateInput = z.infer<typeof createTemplateSchema>;

View File

@@ -24,10 +24,6 @@ export const listDocumentsSchema = baseListQuerySchema.extend({
status: z.string().optional(),
});
export const generateEoiSchema = z.object({
interestId: z.string().min(1),
});
export const uploadSignedSchema = z.object({
documentId: z.string().min(1),
});
@@ -35,4 +31,3 @@ export const uploadSignedSchema = z.object({
export type CreateDocumentInput = z.infer<typeof createDocumentSchema>;
export type UpdateDocumentInput = z.infer<typeof updateDocumentSchema>;
export type ListDocumentsInput = z.infer<typeof listDocumentsSchema>;
export type GenerateEoiInput = z.infer<typeof generateEoiSchema>;

View File

@@ -130,17 +130,6 @@ export const publicInterestSchema = z
source: z.literal('website').default('website'),
notes: z.string().max(2000).optional(),
address: addressSchema.optional(),
// ─── Deprecated flat fields ────────────────────────────────────────────
// Kept in the schema so strict parse does not reject submissions from
// legacy callers, but the route IGNORES them in favor of `yacht` / `company`.
// Remove once all inbound integrations have migrated.
yachtName: z.string().optional(),
yachtLengthFt: z.coerce.number().positive().optional(),
yachtWidthFt: z.coerce.number().positive().optional(),
yachtDraftFt: z.coerce.number().positive().optional(),
preferredBerthSize: z.string().optional(),
companyName: z.string().optional(),
})
.refine((data) => data.fullName || (data.firstName && data.lastName), {
message: 'Either fullName or both firstName and lastName are required',

View File

@@ -25,7 +25,6 @@ export interface Client {
id: string;
portId: string;
fullName: string;
companyName?: string | null;
nationality?: string | null;
source?: string | null;
archivedAt?: Date | null;

View File

@@ -592,7 +592,6 @@ export function makeCreateClientInput(overrides?: { fullName?: string; portId?:
return {
fullName: overrides?.fullName ?? 'Test Client',
contacts: [{ channel: 'email' as const, value: 'test@example.com', isPrimary: true }],
isProxy: false,
tagIds: [] as string[],
};
}

View File

@@ -0,0 +1,425 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { db } from '@/lib/db';
import { documentTemplates } from '@/lib/db/schema/documents';
import { clientAddresses, clientContacts, clients as clientsTable } from '@/lib/db/schema/clients';
import { interests as interestsTable } from '@/lib/db/schema/interests';
import { getMergeFields, resolveTemplate } from '@/lib/services/document-templates';
import { makeBerth, makeClient, makeCompany, makePort, makeYacht } from '../helpers/factories';
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function insertTemplate(args: {
portId: string;
bodyHtml: string;
name?: string;
templateType?: string;
}) {
const [row] = await db
.insert(documentTemplates)
.values({
portId: args.portId,
name: args.name ?? `Tmpl ${Math.random().toString(36).slice(2, 8)}`,
templateType: args.templateType ?? 'custom',
bodyHtml: args.bodyHtml,
createdBy: 'test',
})
.returning();
return row!;
}
async function insertInterest(args: {
portId: string;
clientId: string;
yachtId?: string | null;
berthId?: string | null;
pipelineStage?: string;
leadCategory?: string;
notes?: string;
}) {
const [row] = await db
.insert(interestsTable)
.values({
portId: args.portId,
clientId: args.clientId,
yachtId: args.yachtId ?? null,
berthId: args.berthId ?? null,
pipelineStage: args.pipelineStage ?? 'open',
leadCategory: args.leadCategory ?? null,
notes: args.notes ?? null,
})
.returning();
return row!;
}
// ─── MERGE_FIELDS catalog ─────────────────────────────────────────────────────
describe('MERGE_FIELDS catalog', () => {
const catalog = getMergeFields();
it('includes new yacht / company / owner scopes', () => {
expect(catalog.yacht).toBeDefined();
expect(catalog.company).toBeDefined();
expect(catalog.owner).toBeDefined();
const yachtTokens = catalog.yacht!.map((f) => f.token);
expect(yachtTokens).toContain('{{yacht.name}}');
expect(yachtTokens).toContain('{{yacht.hullNumber}}');
expect(yachtTokens).toContain('{{yacht.lengthFt}}');
expect(yachtTokens).toContain('{{yacht.lengthM}}');
const companyTokens = catalog.company!.map((f) => f.token);
expect(companyTokens).toContain('{{company.name}}');
expect(companyTokens).toContain('{{company.legalName}}');
expect(companyTokens).toContain('{{company.taxId}}');
expect(companyTokens).toContain('{{company.billingAddress}}');
const ownerTokens = catalog.owner!.map((f) => f.token);
expect(ownerTokens).toContain('{{owner.type}}');
expect(ownerTokens).toContain('{{owner.name}}');
expect(ownerTokens).toContain('{{owner.legalName}}');
});
it('removes deprecated client.yacht* and client.companyName tokens', () => {
const clientTokens = catalog.client!.map((f) => f.token);
expect(clientTokens).not.toContain('{{client.companyName}}');
expect(clientTokens).not.toContain('{{client.yachtName}}');
expect(clientTokens).not.toContain('{{client.yachtLengthFt}}');
expect(clientTokens).not.toContain('{{client.yachtLengthM}}');
expect(clientTokens).not.toContain('{{client.yachtWidthFt}}');
expect(clientTokens).not.toContain('{{client.yachtDraftFt}}');
});
it('keeps client.fullName as required but drops berth.mooringNumber requirement', () => {
const fullName = catalog.client!.find((f) => f.token === '{{client.fullName}}');
expect(fullName?.required).toBe(true);
const mooring = catalog.berth!.find((f) => f.token === '{{berth.mooringNumber}}');
expect(mooring?.required).toBe(false);
});
});
// ─── resolveTemplate — EOI scope tokens ───────────────────────────────────────
describe('resolveTemplate — EOI scope tokens', () => {
const EOI_TEMPLATE_BODY = [
'Client: {{client.fullName}} / {{client.email}} / {{client.phone}}',
'Yacht: {{yacht.name}} HN={{yacht.hullNumber}} LenFt={{yacht.lengthFt}} LenM={{yacht.lengthM}} YB={{yacht.yearBuilt}}',
'Owner: type={{owner.type}} name={{owner.name}} legal={{owner.legalName}}',
'Company: name={{company.name}} legal={{company.legalName}} tax={{company.taxId}} addr={{company.billingAddress}}',
'Berth: mooring={{berth.mooringNumber}} area={{berth.area}}',
'Interest: stage={{interest.stage}} cat={{interest.leadCategory}} notes={{interest.notes}}',
'Port: {{port.name}}',
].join('\n');
let setup: {
portId: string;
clientId: string;
yachtId: string;
berthId: string;
interestId: string;
templateId: string;
};
beforeAll(async () => {
const port = await makePort();
const client = await makeClient({
portId: port.id,
overrides: { fullName: 'Alice Client', nationality: 'US', source: 'referral' },
});
await db.insert(clientContacts).values([
{ clientId: client.id, channel: 'email', value: 'alice@example.com', isPrimary: true },
{ clientId: client.id, channel: 'phone', value: '+1-555-0000', isPrimary: true },
]);
await db.insert(clientAddresses).values({
clientId: client.id,
portId: port.id,
streetAddress: '1 Main St',
city: 'Town',
country: 'US',
isPrimary: true,
});
const yacht = await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: client.id,
name: 'Sea Breeze',
hullNumber: 'HN-100',
overrides: {
flag: 'US',
yearBuilt: 2020,
lengthFt: '60',
widthFt: '20',
draftFt: '8',
lengthM: '18.3',
widthM: '6.1',
draftM: '2.4',
},
});
const berth = await makeBerth({
portId: port.id,
overrides: { mooringNumber: 'M-42', area: 'North', lengthFt: '70', price: '100000' },
});
const interest = await insertInterest({
portId: port.id,
clientId: client.id,
yachtId: yacht.id,
berthId: berth.id,
pipelineStage: 'in_communication',
leadCategory: 'tour',
notes: 'Eager buyer',
});
const tmpl = await insertTemplate({
portId: port.id,
bodyHtml: EOI_TEMPLATE_BODY,
});
setup = {
portId: port.id,
clientId: client.id,
yachtId: yacht.id,
berthId: berth.id,
interestId: interest.id,
templateId: tmpl.id,
};
});
it('populates yacht.* tokens from EoiContext when interestId provided', async () => {
const resolved = await resolveTemplate(setup.templateId, {
interestId: setup.interestId,
clientId: setup.clientId,
portId: setup.portId,
});
expect(resolved).toContain('Yacht: Sea Breeze HN=HN-100');
expect(resolved).toContain('LenFt=60');
expect(resolved).toContain('LenM=18.3');
expect(resolved).toContain('YB=2020');
});
it('populates owner.type and owner.name for client-owned yacht', async () => {
const resolved = await resolveTemplate(setup.templateId, {
interestId: setup.interestId,
clientId: setup.clientId,
portId: setup.portId,
});
expect(resolved).toContain('Owner: type=client name=Alice Client');
});
it('leaves company.* tokens empty for client-owned yachts', async () => {
const resolved = await resolveTemplate(setup.templateId, {
interestId: setup.interestId,
clientId: setup.clientId,
portId: setup.portId,
});
expect(resolved).toContain('Company: name= legal= tax= addr=');
});
it('populates berth.mooringNumber from EoiContext', async () => {
const resolved = await resolveTemplate(setup.templateId, {
interestId: setup.interestId,
clientId: setup.clientId,
portId: setup.portId,
});
expect(resolved).toContain('Berth: mooring=M-42 area=North');
});
it('populates interest.* tokens', async () => {
const resolved = await resolveTemplate(setup.templateId, {
interestId: setup.interestId,
clientId: setup.clientId,
portId: setup.portId,
});
expect(resolved).toContain('Interest: stage=in_communication cat=tour notes=Eager buyer');
});
it('populates client.* tokens from EoiContext', async () => {
const resolved = await resolveTemplate(setup.templateId, {
interestId: setup.interestId,
clientId: setup.clientId,
portId: setup.portId,
});
expect(resolved).toContain('Client: Alice Client / alice@example.com / +1-555-0000');
});
});
describe('resolveTemplate — company-owned yacht', () => {
it('populates company.* tokens and owner.legalName for company-owned yachts', async () => {
const port = await makePort();
const company = await makeCompany({
portId: port.id,
overrides: {
name: 'Acme Yachts',
legalName: 'Acme Yachts Ltd.',
taxId: 'TAX-123',
},
});
const client = await makeClient({
portId: port.id,
overrides: { fullName: 'Bob Contact' },
});
const yacht = await makeYacht({
portId: port.id,
ownerType: 'company',
ownerId: company.id,
name: 'Acme Runner',
});
const berth = await makeBerth({
portId: port.id,
overrides: { mooringNumber: 'B-7' },
});
const [interest] = await db
.insert(interestsTable)
.values({
portId: port.id,
clientId: client.id,
yachtId: yacht.id,
berthId: berth.id,
pipelineStage: 'open',
})
.returning();
const [tmpl] = await db
.insert(documentTemplates)
.values({
portId: port.id,
name: 'company tmpl',
templateType: 'custom',
bodyHtml: [
'Owner={{owner.type}}/{{owner.name}}/{{owner.legalName}}',
'Company={{company.name}}/{{company.legalName}}/{{company.taxId}}',
].join(' | '),
createdBy: 'test',
})
.returning();
const resolved = await resolveTemplate(tmpl!.id, {
interestId: interest!.id,
clientId: client.id,
portId: port.id,
});
expect(resolved).toContain('Owner=company/Acme Yachts/Acme Yachts Ltd.');
expect(resolved).toContain('Company=Acme Yachts/Acme Yachts Ltd./TAX-123');
});
});
// ─── resolveTemplate — legacy fallback path ───────────────────────────────────
describe('resolveTemplate — legacy fallback (no interestId)', () => {
it('falls back to direct client lookup when no interestId is provided', async () => {
const port = await makePort();
const client = await makeClient({
portId: port.id,
overrides: { fullName: 'Carol NoInterest', nationality: 'UK', source: 'website' },
});
await db.insert(clientContacts).values({
clientId: client.id,
channel: 'email',
value: 'carol@example.com',
isPrimary: true,
});
const [tmpl] = await db
.insert(documentTemplates)
.values({
portId: port.id,
name: 'welcome',
templateType: 'welcome_letter',
bodyHtml:
'Hello {{client.fullName}} ({{client.email}}) from {{client.nationality}} src={{client.source}}',
createdBy: 'test',
})
.returning();
const resolved = await resolveTemplate(tmpl!.id, {
clientId: client.id,
portId: port.id,
});
expect(resolved).toContain('Hello Carol NoInterest');
expect(resolved).toContain('carol@example.com');
expect(resolved).toContain('from UK');
expect(resolved).toContain('src=website');
});
it('handles an interest that has no yacht without throwing (legacy fallback)', async () => {
const port = await makePort();
const client = await makeClient({
portId: port.id,
overrides: { fullName: 'Dave NoYacht' },
});
const berth = await makeBerth({
portId: port.id,
overrides: { mooringNumber: 'B-LEG' },
});
const [interest] = await db
.insert(interestsTable)
.values({
portId: port.id,
clientId: client.id,
yachtId: null,
berthId: berth.id,
pipelineStage: 'open',
leadCategory: 'casual',
})
.returning();
const [tmpl] = await db
.insert(documentTemplates)
.values({
portId: port.id,
name: 'partial',
templateType: 'correspondence',
bodyHtml:
'Client={{client.fullName}} Stage={{interest.stage}} Cat={{interest.leadCategory}} Mooring={{berth.mooringNumber}}',
createdBy: 'test',
})
.returning();
const resolved = await resolveTemplate(tmpl!.id, {
clientId: client.id,
interestId: interest!.id,
portId: port.id,
});
expect(resolved).toContain('Client=Dave NoYacht');
expect(resolved).toContain('Stage=open');
expect(resolved).toContain('Cat=casual');
expect(resolved).toContain('Mooring=B-LEG');
});
it('raises ValidationError when required client.fullName has no value', async () => {
const port = await makePort();
const [tmpl] = await db
.insert(documentTemplates)
.values({
portId: port.id,
name: 'no client',
templateType: 'custom',
bodyHtml: 'Hello {{client.fullName}}',
createdBy: 'test',
})
.returning();
// Insert a client row with empty-string fullName to trigger the required check.
const [client] = await db
.insert(clientsTable)
.values({ portId: port.id, fullName: '' })
.returning();
await expect(
resolveTemplate(tmpl!.id, { clientId: client!.id, portId: port.id }),
).rejects.toThrow(/Missing required merge field/i);
});
});

View File

@@ -0,0 +1,414 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documents, documentTemplates } from '@/lib/db/schema/documents';
import { clientAddresses, clientContacts } from '@/lib/db/schema/clients';
import { interests as interestsTable } from '@/lib/db/schema/interests';
import { ValidationError } from '@/lib/errors';
import { makeBerth, makeClient, makePort, makeYacht } from '../helpers/factories';
// ─── Mocks ────────────────────────────────────────────────────────────────────
vi.mock('@/lib/services/documenso-client', () => ({
createDocument: vi.fn(),
sendDocument: vi.fn(),
generateDocumentFromTemplate: vi.fn(),
}));
vi.mock('@/lib/minio', async () => {
const actual = await vi.importActual<typeof import('@/lib/minio')>('@/lib/minio');
return {
...actual,
minioClient: {
putObject: vi.fn().mockResolvedValue(undefined),
getObject: vi.fn().mockImplementation(async () => {
async function* gen() {
yield Buffer.from('fake-pdf');
}
return gen();
}),
},
};
});
vi.mock('@/lib/socket/server', () => ({
emitToRoom: vi.fn(),
}));
vi.mock('@/lib/pdf/generate', () => ({
generatePdf: vi.fn().mockResolvedValue(new Uint8Array(Buffer.from('fake-pdf'))),
}));
vi.mock('@/lib/pdf/fill-eoi-form', () => ({
generateEoiPdfFromTemplate: vi
.fn()
.mockResolvedValue(new Uint8Array(Buffer.from('fake-eoi-pdf'))),
loadEoiTemplatePdf: vi.fn(),
fillEoiFormFields: vi.fn(),
}));
vi.mock('@/lib/audit', () => ({
createAuditLog: vi.fn().mockResolvedValue(undefined),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function insertInApptemplate(portId: string) {
const [row] = await db
.insert(documentTemplates)
.values({
portId,
name: 'Test EOI',
templateType: 'eoi',
bodyHtml: '<p>Hello {{client.fullName}} for berth {{berth.mooringNumber}}</p>',
createdBy: 'test',
})
.returning();
return row!;
}
async function insertInterest(args: {
portId: string;
clientId: string;
yachtId: string;
berthId: string;
}) {
const [row] = await db
.insert(interestsTable)
.values({
portId: args.portId,
clientId: args.clientId,
yachtId: args.yachtId,
berthId: args.berthId,
pipelineStage: 'open',
})
.returning();
return row!;
}
// ─── Setup ────────────────────────────────────────────────────────────────────
let setup: {
portId: string;
clientId: string;
yachtId: string;
berthId: string;
interestId: string;
inAppTemplateId: string;
};
let generateAndSign: typeof import('@/lib/services/document-templates').generateAndSign;
beforeAll(async () => {
({ generateAndSign } = await import('@/lib/services/document-templates'));
const port = await makePort();
const client = await makeClient({
portId: port.id,
overrides: { fullName: 'Dual Path Client', nationality: 'US' },
});
await db.insert(clientContacts).values({
clientId: client.id,
channel: 'email',
value: 'client@example.com',
isPrimary: true,
});
await db.insert(clientAddresses).values({
clientId: client.id,
portId: port.id,
streetAddress: '1 Wharf Rd',
city: 'Harbor',
country: 'US',
isPrimary: true,
});
const yacht = await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: client.id,
name: 'Dual Path Yacht',
overrides: { lengthFt: '45', widthFt: '14', draftFt: '6' },
});
const berth = await makeBerth({
portId: port.id,
overrides: { mooringNumber: 'DP-1' },
});
const interest = await insertInterest({
portId: port.id,
clientId: client.id,
yachtId: yacht.id,
berthId: berth.id,
});
const template = await insertInApptemplate(port.id);
setup = {
portId: port.id,
clientId: client.id,
yachtId: yacht.id,
berthId: berth.id,
interestId: interest.id,
inAppTemplateId: template.id,
};
});
beforeEach(() => {
vi.clearAllMocks();
});
const meta = {
userId: 'test-user',
portId: '',
ipAddress: '127.0.0.1',
userAgent: 'vitest',
};
// ─── Pathway: inapp ───────────────────────────────────────────────────────────
describe('generateAndSign — inapp pathway', () => {
it('generates PDF via pdfme, uploads to MinIO, and sends to Documenso', async () => {
const client = await import('@/lib/services/documenso-client');
vi.mocked(client.createDocument).mockResolvedValue({
id: 'documenso-inapp-123',
status: 'PENDING',
recipients: [],
});
vi.mocked(client.sendDocument).mockResolvedValue({
id: 'documenso-inapp-123',
status: 'PENDING',
recipients: [],
});
const result = await generateAndSign(
setup.inAppTemplateId,
setup.portId,
{ clientId: setup.clientId, interestId: setup.interestId },
[{ name: 'Client', email: 'client@example.com', role: 'signer', signingOrder: 1 }],
'inapp',
{ ...meta, portId: setup.portId },
);
expect(result.file).not.toBeNull();
expect(client.createDocument).toHaveBeenCalledOnce();
expect(client.sendDocument).toHaveBeenCalledWith('documenso-inapp-123');
const [docRow] = await db
.select()
.from(documents)
.where(eq(documents.id, (result.document as { id: string }).id));
expect(docRow?.documensoId).toBe('documenso-inapp-123');
expect(docRow?.status).toBe('sent');
expect(docRow?.fileId).toBeTruthy();
});
it('auto-derives signers for EOI templates when none are provided', async () => {
const client = await import('@/lib/services/documenso-client');
vi.mocked(client.createDocument).mockResolvedValue({
id: 'doc-auto-signers',
status: 'PENDING',
recipients: [],
});
vi.mocked(client.sendDocument).mockResolvedValue({
id: 'doc-auto-signers',
status: 'PENDING',
recipients: [],
});
await generateAndSign(
setup.inAppTemplateId,
setup.portId,
{ clientId: setup.clientId, interestId: setup.interestId },
[],
'inapp',
{ ...meta, portId: setup.portId },
);
expect(client.createDocument).toHaveBeenCalledOnce();
const recipients = vi.mocked(client.createDocument).mock.calls[0]![2];
expect(recipients).toHaveLength(3);
expect(recipients[0]?.name).toBe('Dual Path Client');
expect(recipients[1]?.name).toBe('David Mizrahi');
expect(recipients[2]?.role).toBe('approver');
});
it('throws ValidationError when non-EOI template has no signers', async () => {
const [other] = await db
.insert(documentTemplates)
.values({
portId: setup.portId,
name: 'Plain Letter',
templateType: 'welcome_letter',
bodyHtml: '<p>x</p>',
createdBy: 'test',
})
.returning();
await expect(
generateAndSign(other!.id, setup.portId, { clientId: setup.clientId }, [], 'inapp', {
...meta,
portId: setup.portId,
}),
).rejects.toThrow(ValidationError);
});
it('throws ValidationError when templateId is null', async () => {
await expect(
generateAndSign(
null,
setup.portId,
{ clientId: setup.clientId, interestId: setup.interestId },
[{ name: 'X', email: 'x@x.com', role: 'signer', signingOrder: 1 }],
'inapp',
{ ...meta, portId: setup.portId },
),
).rejects.toThrow(ValidationError);
});
it('uses the EOI source-PDF path (not pdfme HTML) for templateType=eoi', async () => {
const fillModule = await import('@/lib/pdf/fill-eoi-form');
const pdfModule = await import('@/lib/pdf/generate');
const client = await import('@/lib/services/documenso-client');
vi.mocked(client.createDocument).mockResolvedValue({
id: 'doc-eoi-pdf',
status: 'PENDING',
recipients: [],
});
vi.mocked(client.sendDocument).mockResolvedValue({
id: 'doc-eoi-pdf',
status: 'PENDING',
recipients: [],
});
await generateAndSign(
setup.inAppTemplateId,
setup.portId,
{ clientId: setup.clientId, interestId: setup.interestId },
[{ name: 'C', email: 'c@x.com', role: 'signer', signingOrder: 1 }],
'inapp',
{ ...meta, portId: setup.portId },
);
expect(fillModule.generateEoiPdfFromTemplate).toHaveBeenCalled();
expect(pdfModule.generatePdf).not.toHaveBeenCalled();
});
it('falls back to HTML→pdfme for non-EOI template types', async () => {
// Create a non-EOI template inline.
const [other] = await db
.insert(documentTemplates)
.values({
portId: setup.portId,
name: 'Welcome Letter',
templateType: 'welcome_letter',
bodyHtml: '<p>Welcome {{client.fullName}}</p>',
createdBy: 'test',
})
.returning();
const fillModule = await import('@/lib/pdf/fill-eoi-form');
const pdfModule = await import('@/lib/pdf/generate');
const client = await import('@/lib/services/documenso-client');
vi.mocked(client.createDocument).mockResolvedValue({
id: 'doc-welcome',
status: 'PENDING',
recipients: [],
});
vi.mocked(client.sendDocument).mockResolvedValue({
id: 'doc-welcome',
status: 'PENDING',
recipients: [],
});
await generateAndSign(
other!.id,
setup.portId,
{ clientId: setup.clientId },
[{ name: 'C', email: 'c@x.com', role: 'signer', signingOrder: 1 }],
'inapp',
{ ...meta, portId: setup.portId },
);
expect(pdfModule.generatePdf).toHaveBeenCalled();
expect(fillModule.generateEoiPdfFromTemplate).not.toHaveBeenCalled();
});
});
// ─── Pathway: documenso-template ──────────────────────────────────────────────
describe('generateAndSign — documenso-template pathway', () => {
it('calls Documenso template-generate endpoint and records a documents row', async () => {
const client = await import('@/lib/services/documenso-client');
vi.mocked(client.generateDocumentFromTemplate).mockResolvedValue({
id: 'documenso-template-456',
status: 'PENDING',
recipients: [],
});
const result = await generateAndSign(
null,
setup.portId,
{ clientId: setup.clientId, interestId: setup.interestId },
[],
'documenso-template',
{ ...meta, portId: setup.portId },
);
expect(result.file).toBeNull();
expect(client.generateDocumentFromTemplate).toHaveBeenCalledOnce();
const [templateArg, payloadArg] = vi.mocked(client.generateDocumentFromTemplate).mock.calls[0]!;
expect(typeof templateArg).toBe('number');
expect(payloadArg).toMatchObject({
externalId: `loi-${setup.interestId}`,
formValues: {
Name: 'Dual Path Client',
'Yacht Name': 'Dual Path Yacht',
'Berth Number': 'DP-1',
},
});
const [docRow] = await db
.select()
.from(documents)
.where(eq(documents.id, (result.document as { id: string }).id));
expect(docRow?.documensoId).toBe('documenso-template-456');
expect(docRow?.status).toBe('sent');
expect(docRow?.documentType).toBe('eoi');
expect(docRow?.interestId).toBe(setup.interestId);
expect(docRow?.fileId).toBeNull();
});
it('throws ValidationError when interestId is missing', async () => {
await expect(
generateAndSign(null, setup.portId, { clientId: setup.clientId }, [], 'documenso-template', {
...meta,
portId: setup.portId,
}),
).rejects.toThrow(ValidationError);
});
it('does NOT call createDocument / sendDocument / minio for this pathway', async () => {
const client = await import('@/lib/services/documenso-client');
vi.mocked(client.generateDocumentFromTemplate).mockResolvedValue({
id: 'documenso-template-789',
status: 'PENDING',
recipients: [],
});
await generateAndSign(
null,
setup.portId,
{ clientId: setup.clientId, interestId: setup.interestId },
[],
'documenso-template',
{ ...meta, portId: setup.portId },
);
expect(client.createDocument).not.toHaveBeenCalled();
expect(client.sendDocument).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,176 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PDFDocument } from 'pdf-lib';
import { fillEoiFormFields, loadEoiTemplatePdf } from '@/lib/pdf/fill-eoi-form';
import type { EoiContext } from '@/lib/services/eoi-context';
// ─── Test PDF builder (synthetic source PDF with the same field names) ───────
async function buildSyntheticEoiPdf(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([600, 800]);
const form = doc.getForm();
const textFieldNames = [
'Name',
'Email',
'Address',
'Yacht Name',
'Length',
'Width',
'Draft',
'Berth Number',
];
textFieldNames.forEach((name, i) => {
const f = form.createTextField(name);
f.addToPage(page, { x: 50, y: 700 - i * 40, width: 300, height: 24 });
});
for (const name of ['Lease_10', 'Purchase']) {
const cb = form.createCheckBox(name);
cb.addToPage(page, { x: 400, y: 700 - (name === 'Purchase' ? 0 : 40), width: 12, height: 12 });
}
return doc.save();
}
function makeContext(overrides: Partial<EoiContext> = {}): EoiContext {
return {
client: {
fullName: 'Alice Smith',
nationality: 'US',
primaryEmail: 'alice@example.com',
primaryPhone: '+1-555-0100',
address: { street: '123 Main St', city: 'Austin', country: 'USA' },
},
yacht: {
name: 'Sea Breeze',
lengthFt: '45',
widthFt: '14',
draftFt: '6',
lengthM: null,
widthM: null,
draftM: null,
hullNumber: 'HN-1',
flag: 'US',
yearBuilt: 2020,
},
company: null,
owner: { type: 'client', name: 'Alice Smith' },
berth: {
mooringNumber: 'A-12',
area: 'North',
lengthFt: '50',
price: '1000',
priceCurrency: 'USD',
tenureType: 'permanent',
},
interest: { stage: 'open', leadCategory: null, dateFirstContact: null, notes: null },
port: { name: 'Port Nimara', defaultCurrency: 'USD' },
date: { today: '2026-04-26', year: '2026' },
...overrides,
};
}
// ─── fillEoiFormFields ────────────────────────────────────────────────────────
describe('fillEoiFormFields', () => {
it('populates every text field and checkbox using EoiContext', async () => {
const sourcePdf = await buildSyntheticEoiPdf();
const filled = await fillEoiFormFields(sourcePdf, makeContext());
const out = await PDFDocument.load(filled);
const form = out.getForm();
expect(form.getTextField('Name').getText()).toBe('Alice Smith');
expect(form.getTextField('Email').getText()).toBe('alice@example.com');
expect(form.getTextField('Address').getText()).toBe('123 Main St, Austin, USA');
expect(form.getTextField('Yacht Name').getText()).toBe('Sea Breeze');
expect(form.getTextField('Length').getText()).toBe('45');
expect(form.getTextField('Width').getText()).toBe('14');
expect(form.getTextField('Draft').getText()).toBe('6');
expect(form.getTextField('Berth Number').getText()).toBe('A-12');
expect(form.getCheckBox('Purchase').isChecked()).toBe(true);
expect(form.getCheckBox('Lease_10').isChecked()).toBe(false);
});
it('handles null primary email and null address gracefully', async () => {
const sourcePdf = await buildSyntheticEoiPdf();
const filled = await fillEoiFormFields(
sourcePdf,
makeContext({
client: {
fullName: 'Bob',
nationality: null,
primaryEmail: null,
primaryPhone: null,
address: null,
},
}),
);
const out = await PDFDocument.load(filled);
const form = out.getForm();
expect(form.getTextField('Email').getText()).toBe(undefined);
expect(form.getTextField('Address').getText()).toBe(undefined);
expect(form.getTextField('Name').getText()).toBe('Bob');
});
it('leaves the form interactive (not flattened) so values can be edited', async () => {
const sourcePdf = await buildSyntheticEoiPdf();
const filled = await fillEoiFormFields(sourcePdf, makeContext());
const out = await PDFDocument.load(filled);
// Field still present and reachable as a TextField → not flattened.
expect(() => out.getForm().getTextField('Name')).not.toThrow();
});
it('skips fields silently if the source PDF lacks them', async () => {
// Build a PDF with only a subset of fields and ensure no error.
const doc = await PDFDocument.create();
const page = doc.addPage([600, 800]);
const form = doc.getForm();
const f = form.createTextField('Name');
f.addToPage(page, { x: 50, y: 700, width: 300, height: 24 });
const sparse = await doc.save();
await expect(fillEoiFormFields(sparse, makeContext())).resolves.toBeInstanceOf(Uint8Array);
});
});
// ─── loadEoiTemplatePdf ───────────────────────────────────────────────────────
describe('loadEoiTemplatePdf', () => {
let tmpFile: string;
const originalEnv = process.env.EOI_TEMPLATE_PDF_PATH;
beforeAll(async () => {
const sourcePdf = await buildSyntheticEoiPdf();
tmpFile = path.join(os.tmpdir(), `eoi-template-${Date.now()}.pdf`);
await fs.writeFile(tmpFile, sourcePdf);
});
afterAll(async () => {
if (originalEnv === undefined) delete process.env.EOI_TEMPLATE_PDF_PATH;
else process.env.EOI_TEMPLATE_PDF_PATH = originalEnv;
await fs.unlink(tmpFile).catch(() => undefined);
});
it('reads the PDF from EOI_TEMPLATE_PDF_PATH override', async () => {
process.env.EOI_TEMPLATE_PDF_PATH = tmpFile;
const bytes = await loadEoiTemplatePdf();
expect(bytes.byteLength).toBeGreaterThan(100);
// Round-trip: should re-load as a valid PDF.
await expect(PDFDocument.load(bytes)).resolves.toBeInstanceOf(PDFDocument);
});
it('throws a clear error with instructions when the file is missing', async () => {
process.env.EOI_TEMPLATE_PDF_PATH = '/nope/does-not-exist.pdf';
await expect(loadEoiTemplatePdf()).rejects.toThrow(/EOI source PDF not found/);
});
});

View File

@@ -0,0 +1,210 @@
import { describe, it, expect } from 'vitest';
import { buildDocumensoPayload } from '@/lib/services/documenso-payload';
import type { EoiContext } from '@/lib/services/eoi-context';
function makeContext(overrides?: Partial<EoiContext>): EoiContext {
return {
client: {
fullName: 'Alice Smith',
nationality: 'US',
primaryEmail: 'alice@example.com',
primaryPhone: '+1-555-0100',
address: { street: '123 Main St', city: 'Austin', country: 'USA' },
},
yacht: {
name: 'Sea Breeze',
lengthFt: '45',
widthFt: '14',
draftFt: '6',
lengthM: null,
widthM: null,
draftM: null,
hullNumber: 'ABC-123',
flag: 'US',
yearBuilt: 2020,
},
company: null,
owner: { type: 'client', name: 'Alice Smith' },
berth: {
mooringNumber: 'A-12',
area: 'North Dock',
lengthFt: '50',
price: '1200',
priceCurrency: 'USD',
tenureType: 'permanent',
},
interest: {
stage: 'open',
leadCategory: null,
dateFirstContact: null,
notes: null,
},
port: {
name: 'Port Nimara',
defaultCurrency: 'USD',
},
date: { today: '2026-04-23', year: '2026' },
...overrides,
};
}
const OPTIONS = {
interestId: 'int-123',
clientRecipientId: 192,
developerRecipientId: 193,
approvalRecipientId: 194,
};
describe('buildDocumensoPayload', () => {
it('builds title as "{fullName}-EOI-NDA"', () => {
const payload = buildDocumensoPayload(makeContext(), OPTIONS);
expect(payload.title).toBe('Alice Smith-EOI-NDA');
});
it('builds externalId as "loi-{interestId}"', () => {
const payload = buildDocumensoPayload(makeContext(), OPTIONS);
expect(payload.externalId).toBe('loi-int-123');
});
it('formats formValues with all EoiContext fields', () => {
const payload = buildDocumensoPayload(makeContext(), OPTIONS);
expect(payload.formValues).toEqual({
Name: 'Alice Smith',
Email: 'alice@example.com',
Address: '123 Main St, Austin, USA',
'Yacht Name': 'Sea Breeze',
Length: '45',
Width: '14',
Draft: '6',
'Berth Number': 'A-12',
Lease_10: false,
Purchase: true,
});
});
it('defaults missing primaryEmail to empty string', () => {
const ctx = makeContext({ client: { ...makeContext().client, primaryEmail: null } });
const payload = buildDocumensoPayload(ctx, OPTIONS);
expect(payload.formValues.Email).toBe('');
expect(payload.recipients[0]!.email).toBe('');
});
it('defaults missing yacht dimensions to empty strings', () => {
const ctx = makeContext({
yacht: { ...makeContext().yacht, lengthFt: null, widthFt: null, draftFt: null },
});
const payload = buildDocumensoPayload(ctx, OPTIONS);
expect(payload.formValues.Length).toBe('');
expect(payload.formValues.Width).toBe('');
expect(payload.formValues.Draft).toBe('');
});
it('formats empty address when client has no address', () => {
const ctx = makeContext({ client: { ...makeContext().client, address: null } });
const payload = buildDocumensoPayload(ctx, OPTIONS);
expect(payload.formValues.Address).toBe('');
});
it('skips null parts in address', () => {
const ctx = makeContext({
client: {
...makeContext().client,
address: { street: '', city: 'Austin', country: 'USA' },
},
});
const payload = buildDocumensoPayload(ctx, OPTIONS);
expect(payload.formValues.Address).toBe('Austin, USA');
});
it('sets Lease_10=false and Purchase=true (hardcoded)', () => {
const payload = buildDocumensoPayload(makeContext(), OPTIONS);
expect(payload.formValues.Lease_10).toBe(false);
expect(payload.formValues.Purchase).toBe(true);
});
it('includes client, developer, and approver recipients in signing order', () => {
const payload = buildDocumensoPayload(makeContext(), OPTIONS);
expect(payload.recipients).toHaveLength(3);
expect(payload.recipients[0]).toEqual({
id: 192,
name: 'Alice Smith',
email: 'alice@example.com',
role: 'SIGNER',
signingOrder: 1,
});
expect(payload.recipients[1]).toEqual({
id: 193,
name: 'David Mizrahi',
email: 'dm@portnimara.com',
role: 'SIGNER',
signingOrder: 2,
});
expect(payload.recipients[2]).toEqual({
id: 194,
name: 'Abbie May',
email: 'sales@portnimara.com',
role: 'APPROVER',
signingOrder: 3,
});
});
it('allows overriding developer/approver recipient names', () => {
const payload = buildDocumensoPayload(makeContext(), {
...OPTIONS,
developerName: 'Custom Dev',
developerEmail: 'dev@custom.com',
approverName: 'Custom Approver',
approverEmail: 'approve@custom.com',
});
expect(payload.recipients[1]!.name).toBe('Custom Dev');
expect(payload.recipients[1]!.email).toBe('dev@custom.com');
expect(payload.recipients[2]!.name).toBe('Custom Approver');
expect(payload.recipients[2]!.email).toBe('approve@custom.com');
});
it('builds message with port name and greeting', () => {
const payload = buildDocumensoPayload(makeContext(), OPTIONS);
expect(payload.meta.message).toContain('Dear Alice Smith');
expect(payload.meta.message).toContain('Port Nimara');
expect(payload.meta.message).toContain('Best Regards');
// No company on-behalf block for client-owned yachts
expect(payload.meta.message).not.toContain('On behalf of');
});
it('adds company on-behalf block for company-owned yachts', () => {
const ctx = makeContext({
company: {
name: 'Aegean Holdings',
legalName: 'Aegean Holdings SA',
taxId: null,
billingAddress: null,
},
owner: { type: 'company', name: 'Aegean Holdings', legalName: 'Aegean Holdings SA' },
});
const payload = buildDocumensoPayload(ctx, OPTIONS);
expect(payload.meta.message).toContain('On behalf of Aegean Holdings SA');
});
it('uses company name when legalName is missing in on-behalf block', () => {
const ctx = makeContext({
company: { name: 'Blue Seas', legalName: null, taxId: null, billingAddress: null },
owner: { type: 'company', name: 'Blue Seas' },
});
const payload = buildDocumensoPayload(ctx, OPTIONS);
expect(payload.meta.message).toContain('On behalf of Blue Seas');
});
it('uses default redirect URL when not provided', () => {
const payload = buildDocumensoPayload(makeContext(), OPTIONS);
expect(payload.meta.redirectUrl).toBe('https://portnimara.com');
});
it('uses custom redirect URL when provided', () => {
const payload = buildDocumensoPayload(makeContext(), {
...OPTIONS,
redirectUrl: 'https://custom.example.com',
});
expect(payload.meta.redirectUrl).toBe('https://custom.example.com');
});
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { createTemplateSchema } from '@/lib/validators/document-templates';
const baseInput = {
name: 'Tmpl',
templateType: 'custom' as const,
bodyHtml: '<p>x</p>',
};
describe('createTemplateSchema — mergeFields allow-list', () => {
it('accepts valid tokens from the catalog', () => {
const parsed = createTemplateSchema.parse({
...baseInput,
mergeFields: ['{{client.fullName}}', '{{yacht.name}}', '{{berth.mooringNumber}}'],
});
expect(parsed.mergeFields).toEqual([
'{{client.fullName}}',
'{{yacht.name}}',
'{{berth.mooringNumber}}',
]);
});
it('rejects deprecated tokens that lived on `clients` before the refactor', () => {
const result = createTemplateSchema.safeParse({
...baseInput,
mergeFields: ['{{client.yachtName}}'],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toMatch(/Unknown merge tokens.*client\.yachtName/);
}
});
it('rejects unknown tokens with a helpful message listing them', () => {
const result = createTemplateSchema.safeParse({
...baseInput,
mergeFields: ['{{client.fullName}}', '{{not.a.token}}', '{{also.bogus}}'],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toContain('not.a.token');
expect(result.error.issues[0]?.message).toContain('also.bogus');
}
});
it('defaults mergeFields to an empty array when omitted', () => {
const parsed = createTemplateSchema.parse(baseInput);
expect(parsed.mergeFields).toEqual([]);
});
it('accepts an empty mergeFields array', () => {
const parsed = createTemplateSchema.parse({ ...baseInput, mergeFields: [] });
expect(parsed.mergeFields).toEqual([]);
});
it('allows the new `eoi` templateType', () => {
const parsed = createTemplateSchema.parse({ ...baseInput, templateType: 'eoi' });
expect(parsed.templateType).toBe('eoi');
});
});