Compare commits
10 Commits
9d7decfc5b
...
0ed401d083
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ed401d083 | ||
|
|
456d399ee2 | ||
|
|
f4ec51002c | ||
|
|
2ff24a7132 | ||
|
|
f8255cedb8 | ||
|
|
13d07e3906 | ||
|
|
7ef7b9bb5f | ||
|
|
7200c31486 | ||
|
|
db74c9394b | ||
|
|
d133d6d656 |
48
assets/README.md
Normal file
48
assets/README.md
Normal 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`.
|
||||||
76
docs/eoi-documenso-field-mapping.md
Normal file
76
docs/eoi-documenso-field-mapping.md
Normal 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. |
|
||||||
@@ -18,6 +18,12 @@ const nextConfig: NextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
typedRoutes: true,
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"next-themes": "^0.4.0",
|
"next-themes": "^0.4.0",
|
||||||
"nodemailer": "^6.9.0",
|
"nodemailer": "^6.9.0",
|
||||||
"openai": "^6.27.0",
|
"openai": "^6.27.0",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"pino": "^9.5.0",
|
"pino": "^9.5.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"postgres": "^3.4.0",
|
"postgres": "^3.4.0",
|
||||||
@@ -91,9 +92,9 @@
|
|||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitest/coverage-v8": "^4.1.0",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"drizzle-kit": "^0.30.0",
|
"drizzle-kit": "^0.30.0",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-next": "15.1.0",
|
"eslint-config-next": "15.1.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
|||||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -152,6 +152,9 @@ importers:
|
|||||||
openai:
|
openai:
|
||||||
specifier: ^6.27.0
|
specifier: ^6.27.0
|
||||||
version: 6.27.0(ws@8.18.3)(zod@3.25.76)
|
version: 6.27.0(ws@8.18.3)(zod@3.25.76)
|
||||||
|
pdf-lib:
|
||||||
|
specifier: ^1.17.1
|
||||||
|
version: 1.17.1
|
||||||
pino:
|
pino:
|
||||||
specifier: ^9.5.0
|
specifier: ^9.5.0
|
||||||
version: 9.14.0
|
version: 9.14.0
|
||||||
@@ -4417,6 +4420,9 @@ packages:
|
|||||||
pathe@2.0.3:
|
pathe@2.0.3:
|
||||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
|
pdf-lib@1.17.1:
|
||||||
|
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
|
||||||
|
|
||||||
peberminta@0.9.0:
|
peberminta@0.9.0:
|
||||||
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
||||||
|
|
||||||
@@ -5375,6 +5381,9 @@ packages:
|
|||||||
tsconfig-paths@3.15.0:
|
tsconfig-paths@3.15.0:
|
||||||
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
||||||
|
|
||||||
|
tslib@1.14.1:
|
||||||
|
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -9668,6 +9677,13 @@ snapshots:
|
|||||||
|
|
||||||
pathe@2.0.3: {}
|
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: {}
|
peberminta@0.9.0: {}
|
||||||
|
|
||||||
performance-now@2.1.0: {}
|
performance-now@2.1.0: {}
|
||||||
@@ -10843,6 +10859,8 @@ snapshots:
|
|||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
strip-bom: 3.0.0
|
strip-bom: 3.0.0
|
||||||
|
|
||||||
|
tslib@1.14.1: {}
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
tsx@4.21.0:
|
tsx@4.21.0:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { InvoiceLineItems } from '@/components/invoices/invoice-line-items';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { createInvoiceSchema, type CreateInvoiceInput } from '@/lib/validators/invoices';
|
import { createInvoiceSchema, type CreateInvoiceInput } from '@/lib/validators/invoices';
|
||||||
@@ -156,27 +157,18 @@ export default function NewInvoicePage() {
|
|||||||
<CardTitle className="text-base">Client Information</CardTitle>
|
<CardTitle className="text-base">Client Information</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="billingEntityType">
|
<Label>
|
||||||
Billing Entity <span className="text-destructive">*</span>
|
Billing entity <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<OwnerPicker
|
||||||
<Select
|
value={watchedValues.billingEntity ?? null}
|
||||||
defaultValue="client"
|
onChange={(ref) => {
|
||||||
onValueChange={(v) =>
|
if (ref) {
|
||||||
setValue('billingEntity.type', v as 'client' | 'company')
|
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 && (
|
{errors.billingEntity && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-destructive">
|
||||||
{errors.billingEntity.message ??
|
{errors.billingEntity.message ??
|
||||||
@@ -185,7 +177,8 @@ export default function NewInvoicePage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -299,7 +292,16 @@ export default function NewInvoicePage() {
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Billing Entity</span>
|
<span className="text-muted-foreground">Billing Entity</span>
|
||||||
<p className="font-medium mt-0.5">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const POST = withAuth(
|
|||||||
try {
|
try {
|
||||||
const body = await parseBody(req, generateAndSignSchema);
|
const body = await parseBody(req, generateAndSignSchema);
|
||||||
const result = await generateAndSign(
|
const result = await generateAndSign(
|
||||||
params.id!,
|
params.id === 'documenso-template' ? null : params.id!,
|
||||||
ctx.portId,
|
ctx.portId,
|
||||||
{
|
{
|
||||||
clientId: body.clientId,
|
clientId: body.clientId,
|
||||||
@@ -19,6 +19,7 @@ export const POST = withAuth(
|
|||||||
berthId: body.berthId,
|
berthId: body.berthId,
|
||||||
},
|
},
|
||||||
body.signers,
|
body.signers,
|
||||||
|
body.pathway,
|
||||||
{
|
{
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
portId: ctx.portId,
|
portId: ctx.portId,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -15,13 +15,7 @@ interface ClientDetailHeaderProps {
|
|||||||
client: {
|
client: {
|
||||||
id: string;
|
id: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
companyName?: string | null;
|
|
||||||
nationality?: string | null;
|
nationality?: string | null;
|
||||||
isProxy?: boolean;
|
|
||||||
proxyType?: string | null;
|
|
||||||
actualOwnerName?: string | null;
|
|
||||||
yachtName?: string | null;
|
|
||||||
berthSizeDesired?: string | null;
|
|
||||||
preferredContactMethod?: string | null;
|
preferredContactMethod?: string | null;
|
||||||
preferredLanguage?: string | null;
|
preferredLanguage?: string | null;
|
||||||
timezone?: string | null;
|
timezone?: string | null;
|
||||||
@@ -36,13 +30,7 @@ interface ClientDetailHeaderProps {
|
|||||||
type ClientFormClient = {
|
type ClientFormClient = {
|
||||||
id: string;
|
id: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
companyName?: string | null;
|
|
||||||
nationality?: string | null;
|
nationality?: string | null;
|
||||||
isProxy?: boolean;
|
|
||||||
proxyType?: string | null;
|
|
||||||
actualOwnerName?: string | null;
|
|
||||||
yachtName?: string | null;
|
|
||||||
berthSizeDesired?: string | null;
|
|
||||||
preferredContactMethod?: string | null;
|
preferredContactMethod?: string | null;
|
||||||
preferredLanguage?: string | null;
|
preferredLanguage?: string | null;
|
||||||
timezone?: string | null;
|
timezone?: string | null;
|
||||||
@@ -67,8 +55,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
const isArchived = !!client.archivedAt;
|
const isArchived = !!client.archivedAt;
|
||||||
|
|
||||||
const archiveMutation = useMutation({
|
const archiveMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () => apiFetch(`/api/v1/clients/${client.id}`, { method: 'DELETE' }),
|
||||||
apiFetch(`/api/v1/clients/${client.id}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
|
queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||||
@@ -77,8 +64,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const restoreMutation = useMutation({
|
const restoreMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () => apiFetch(`/api/v1/clients/${client.id}/restore`, { method: 'POST' }),
|
||||||
apiFetch(`/api/v1/clients/${client.id}/restore`, { method: 'POST' }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
|
queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||||
@@ -86,10 +72,12 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const primaryEmail = client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)
|
const primaryEmail =
|
||||||
?? client.contacts?.find((c) => c.channel === 'email');
|
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary) ??
|
||||||
const primaryPhone = client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary)
|
client.contacts?.find((c) => c.channel === 'email');
|
||||||
?? client.contacts?.find((c) => c.channel === 'phone');
|
const primaryPhone =
|
||||||
|
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
|
||||||
|
client.contacts?.find((c) => c.channel === 'phone');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -97,23 +85,14 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
<div className="flex items-start gap-3 flex-wrap">
|
<div className="flex items-start gap-3 flex-wrap">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h1 className="text-2xl font-bold text-foreground truncate">
|
<h1 className="text-2xl font-bold text-foreground truncate">{client.fullName}</h1>
|
||||||
{client.fullName}
|
|
||||||
</h1>
|
|
||||||
{isArchived && (
|
{isArchived && (
|
||||||
<Badge variant="secondary" className="text-xs">Archived</Badge>
|
<Badge variant="secondary" className="text-xs">
|
||||||
)}
|
Archived
|
||||||
{client.isProxy && (
|
|
||||||
<Badge variant="outline" className="text-xs capitalize">
|
|
||||||
Proxy {client.proxyType ? `(${client.proxyType.replace('_', ' ')})` : ''}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
|
||||||
{client.source && (
|
{client.source && (
|
||||||
<span>
|
<span>
|
||||||
@@ -148,11 +127,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setEditOpen(true)}
|
|
||||||
>
|
|
||||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -12,12 +12,19 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
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';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
interface EoiPrerequisites {
|
interface EoiPrerequisites {
|
||||||
hasName: boolean;
|
hasName: boolean;
|
||||||
hasEmail: boolean;
|
hasYacht: boolean;
|
||||||
hasYachtDims: boolean;
|
|
||||||
hasBerth: boolean;
|
hasBerth: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,11 +37,23 @@ interface EoiGenerateDialogProps {
|
|||||||
|
|
||||||
const PREREQUISITE_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
const PREREQUISITE_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
||||||
{ key: 'hasName', label: 'Client has full name' },
|
{ key: 'hasName', label: 'Client has full name' },
|
||||||
{ key: 'hasEmail', label: 'Client has email address' },
|
{ key: 'hasYacht', label: 'Yacht linked to interest' },
|
||||||
{ key: 'hasYachtDims', label: 'Yacht dimensions set' },
|
|
||||||
{ key: 'hasBerth', label: 'Berth 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({
|
export function EoiGenerateDialog({
|
||||||
interestId,
|
interestId,
|
||||||
open,
|
open,
|
||||||
@@ -44,9 +63,21 @@ export function EoiGenerateDialog({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE);
|
||||||
|
|
||||||
const allMet = Object.values(prerequisites).every(Boolean);
|
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 () => {
|
const handleGenerate = async () => {
|
||||||
if (!allMet) return;
|
if (!allMet) return;
|
||||||
|
|
||||||
@@ -54,9 +85,17 @@ export function EoiGenerateDialog({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
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',
|
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 }] });
|
queryClient.invalidateQueries({ queryKey: ['documents', { interestId }] });
|
||||||
@@ -74,18 +113,38 @@ export function EoiGenerateDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Generate Expression of Interest</DialogTitle>
|
<DialogTitle>Generate Expression of Interest</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-2 py-2">
|
<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 }) => (
|
{PREREQUISITE_LABELS.map(({ key, label }) => (
|
||||||
<div key={key} className="flex items-center gap-3">
|
<div key={key} className="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
|
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
|
||||||
prerequisites[key]
|
prerequisites[key] ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-red-100 text-red-700'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{prerequisites[key] ? '✓' : '✗'}
|
{prerequisites[key] ? '✓' : '✗'}
|
||||||
@@ -96,17 +155,16 @@ export function EoiGenerateDialog({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleGenerate} disabled={!allMet || isGenerating}>
|
<Button onClick={handleGenerate} disabled={!allMet || isGenerating}>
|
||||||
{isGenerating ? 'Generating...' : 'Generate EOI'}
|
{isGenerating ? 'Generating…' : 'Generate EOI'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -14,13 +14,9 @@ interface InterestDocumentsTabProps {
|
|||||||
|
|
||||||
interface InterestData {
|
interface InterestData {
|
||||||
id: string;
|
id: string;
|
||||||
|
yachtId?: string | null;
|
||||||
berthId?: string | null;
|
berthId?: string | null;
|
||||||
client?: {
|
clientName?: string | null;
|
||||||
fullName?: string | null;
|
|
||||||
yachtLengthFt?: string | null;
|
|
||||||
yachtLengthM?: string | null;
|
|
||||||
contacts?: Array<{ channel: string; value: string }>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
|
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
|
||||||
@@ -28,20 +24,14 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
|
|||||||
|
|
||||||
const { data: interestRes } = useQuery({
|
const { data: interestRes } = useQuery({
|
||||||
queryKey: ['interests', interestId],
|
queryKey: ['interests', interestId],
|
||||||
queryFn: () =>
|
queryFn: () => apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`),
|
||||||
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const interest = interestRes?.data;
|
const interest = interestRes?.data;
|
||||||
|
|
||||||
const prerequisites = {
|
const prerequisites = {
|
||||||
hasName: Boolean(interest?.client?.fullName),
|
hasName: Boolean(interest?.clientName),
|
||||||
hasEmail: Boolean(
|
hasYacht: Boolean(interest?.yachtId),
|
||||||
interest?.client?.contacts?.some((c) => c.channel === 'email' && c.value),
|
|
||||||
),
|
|
||||||
hasYachtDims: Boolean(
|
|
||||||
interest?.client?.yachtLengthFt || interest?.client?.yachtLengthM,
|
|
||||||
),
|
|
||||||
hasBerth: Boolean(interest?.berthId),
|
hasBerth: Boolean(interest?.berthId),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export function CommandSearch() {
|
|||||||
id: c.id,
|
id: c.id,
|
||||||
icon: 'client',
|
icon: 'client',
|
||||||
label: c.fullName,
|
label: c.fullName,
|
||||||
sub: c.companyName,
|
sub: null,
|
||||||
}))}
|
}))}
|
||||||
iconMap={iconMap}
|
iconMap={iconMap}
|
||||||
onSelect={(id) => navigate(`/${portSlug}/clients/${id}`)}
|
onSelect={(id) => navigate(`/${portSlug}/clients/${id}`)}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { CommandItem } from '@/components/ui/command';
|
|||||||
interface ClientItem {
|
interface ClientItem {
|
||||||
id: string;
|
id: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
companyName: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InterestItem {
|
interface InterestItem {
|
||||||
@@ -54,12 +53,7 @@ export function SearchResultItem({ type, item, onSelect }: SearchResultItemProps
|
|||||||
return (
|
return (
|
||||||
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
|
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
|
||||||
<User className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<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>
|
<span className="text-sm font-medium">{item.fullName}</span>
|
||||||
{item.companyName && (
|
|
||||||
<span className="text-xs text-muted-foreground">{item.companyName}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { cn } from '@/lib/utils';
|
|||||||
interface ClientOption {
|
interface ClientOption {
|
||||||
id: string;
|
id: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
companyName?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientPickerProps {
|
interface ClientPickerProps {
|
||||||
@@ -89,12 +88,7 @@ export function ClientPicker({
|
|||||||
<Check
|
<Check
|
||||||
className={cn('mr-2 h-4 w-4', value === c.id ? 'opacity-100' : 'opacity-0')}
|
className={cn('mr-2 h-4 w-4', value === c.id ? 'opacity-100' : 'opacity-0')}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>{c.fullName}</span>
|
||||||
{c.fullName}
|
|
||||||
{c.companyName ? (
|
|
||||||
<span className="ml-2 text-xs opacity-60">{c.companyName}</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useDebounce } from '@/hooks/use-debounce';
|
|||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface SearchResults {
|
interface SearchResults {
|
||||||
clients: Array<{ id: string; fullName: string; companyName: string | null }>;
|
clients: Array<{ id: string; fullName: string }>;
|
||||||
interests: Array<{
|
interests: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
|
|||||||
13
src/lib/db/migrations/0008_loud_ikaris.sql
Normal file
13
src/lib/db/migrations/0008_loud_ikaris.sql
Normal 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";
|
||||||
8530
src/lib/db/migrations/meta/0008_snapshot.json
Normal file
8530
src/lib/db/migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
|||||||
"when": 1776959993173,
|
"when": 1776959993173,
|
||||||
"tag": "0007_brainy_felicia_hardy",
|
"tag": "0007_brainy_felicia_hardy",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777204563579,
|
||||||
|
"tag": "0008_loud_ikaris",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
text,
|
text,
|
||||||
boolean,
|
boolean,
|
||||||
timestamp,
|
timestamp,
|
||||||
numeric,
|
|
||||||
jsonb,
|
jsonb,
|
||||||
index,
|
index,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
@@ -22,20 +21,7 @@ export const clients = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => ports.id),
|
.references(() => ports.id),
|
||||||
fullName: text('full_name').notNull(),
|
fullName: text('full_name').notNull(),
|
||||||
companyName: text('company_name'),
|
|
||||||
nationality: text('nationality'),
|
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
|
preferredContactMethod: text('preferred_contact_method'), // email, phone, whatsapp
|
||||||
preferredLanguage: text('preferred_language'),
|
preferredLanguage: text('preferred_language'),
|
||||||
timezone: text('timezone'),
|
timezone: text('timezone'),
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ import {
|
|||||||
berths,
|
berths,
|
||||||
berthReservations,
|
berthReservations,
|
||||||
interests,
|
interests,
|
||||||
|
documentTemplates,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
import {
|
||||||
|
getStandardEoiTemplateHtml,
|
||||||
|
STANDARD_EOI_MERGE_FIELDS,
|
||||||
|
} from '@/lib/pdf/templates/eoi-standard-inapp';
|
||||||
|
|
||||||
// ─── Tunables ────────────────────────────────────────────────────────────────
|
// ─── Tunables ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -840,6 +845,21 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
|||||||
.where(eq(yachts.id, yachtId));
|
.where(eq(yachts.id, yachtId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 6b. Standard EOI Template (in-app PDF path) ────────────────────────
|
||||||
|
// One row per port. Used by the in-app pdfme renderer when the port opts
|
||||||
|
// for in-app PDF generation over the Documenso template flow.
|
||||||
|
await tx.insert(documentTemplates).values({
|
||||||
|
portId,
|
||||||
|
name: 'Standard EOI (in-app)',
|
||||||
|
description:
|
||||||
|
'Default Expression of Interest / Letter of Intent template, rendered in-app via pdfme. Use for ports that prefer in-app PDF generation over the Documenso template path.',
|
||||||
|
templateType: 'eoi',
|
||||||
|
bodyHtml: getStandardEoiTemplateHtml(),
|
||||||
|
mergeFields: STANDARD_EOI_MERGE_FIELDS,
|
||||||
|
isActive: true,
|
||||||
|
createdBy: SEED_USER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
// ── 7. Interests (15) ──────────────────────────────────────────────────
|
// ── 7. Interests (15) ──────────────────────────────────────────────────
|
||||||
// Spread across pipeline stages.
|
// Spread across pipeline stages.
|
||||||
// Valid stages (from interests schema comment):
|
// Valid stages (from interests schema comment):
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ const envSchema = z.object({
|
|||||||
DOCUMENSO_API_URL: z.string().url(),
|
DOCUMENSO_API_URL: z.string().url(),
|
||||||
DOCUMENSO_API_KEY: z.string().min(1),
|
DOCUMENSO_API_KEY: z.string().min(1),
|
||||||
DOCUMENSO_WEBHOOK_SECRET: z.string().min(16),
|
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
|
// Email
|
||||||
SMTP_HOST: z.string().min(1),
|
SMTP_HOST: z.string().min(1),
|
||||||
|
|||||||
101
src/lib/pdf/fill-eoi-form.ts
Normal file
101
src/lib/pdf/fill-eoi-form.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -4,37 +4,103 @@ export const clientSummaryTemplate: Template = {
|
|||||||
basePdf: 'BLANK_PDF' as unknown as string,
|
basePdf: 'BLANK_PDF' as unknown as string,
|
||||||
schemas: [
|
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: 'portName',
|
||||||
{ name: 'clientInfo', type: 'text', position: { x: 20, y: 45 }, width: 80, height: 40, fontSize: 9 },
|
type: 'text',
|
||||||
{ name: 'contacts', type: 'text', position: { x: 110, y: 45 }, width: 80, height: 40, fontSize: 9 },
|
position: { x: 20, y: 15 },
|
||||||
{ name: 'vesselInfo', type: 'text', position: { x: 20, y: 90 }, width: 170, height: 20, fontSize: 9 },
|
width: 100,
|
||||||
{ name: 'interests', type: 'text', position: { x: 20, y: 115 }, width: 170, height: 80, fontSize: 8 },
|
height: 10,
|
||||||
{ name: 'recentActivity', type: 'text', position: { x: 20, y: 200 }, width: 170, height: 60, fontSize: 8 },
|
fontSize: 16,
|
||||||
{ name: 'generatedAt', type: 'text', position: { x: 20, y: 275 }, width: 170, height: 6, fontSize: 7 },
|
},
|
||||||
|
{
|
||||||
|
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(
|
export function buildClientSummaryInputs(
|
||||||
client: Record<string, unknown>,
|
client: Record<string, unknown>,
|
||||||
contacts: Record<string, unknown>[],
|
contacts: Record<string, unknown>[],
|
||||||
|
yachtList: YachtSummary[],
|
||||||
interestList: Record<string, unknown>[],
|
interestList: Record<string, unknown>[],
|
||||||
activity: Record<string, unknown>[],
|
activity: Record<string, unknown>[],
|
||||||
port: Record<string, unknown>,
|
port: Record<string, unknown>,
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const clientInfo = [
|
const clientInfo = [
|
||||||
`Name: ${client.fullName ?? 'N/A'}`,
|
`Name: ${client.fullName ?? 'N/A'}`,
|
||||||
client.companyName ? `Company: ${client.companyName}` : null,
|
|
||||||
client.nationality ? `Nationality: ${client.nationality}` : null,
|
client.nationality ? `Nationality: ${client.nationality}` : null,
|
||||||
client.source ? `Source: ${client.source}` : 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')}`,
|
`Added: ${new Date(client.createdAt as string | Date).toLocaleDateString('en-GB')}`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
const contactsText = contacts.length > 0
|
const contactsText =
|
||||||
|
contacts.length > 0
|
||||||
? contacts
|
? contacts
|
||||||
.map(
|
.map(
|
||||||
(c) =>
|
(c) =>
|
||||||
@@ -43,21 +109,21 @@ export function buildClientSummaryInputs(
|
|||||||
.join('\n')
|
.join('\n')
|
||||||
: 'No contacts on file';
|
: 'No contacts on file';
|
||||||
|
|
||||||
const vesselInfo = [
|
const yachtsText =
|
||||||
client.yachtName ? `Yacht: ${client.yachtName}` : null,
|
yachtList.length > 0
|
||||||
client.yachtLengthFt
|
? `Owned/Linked Yachts:\n${yachtList
|
||||||
? `Length: ${client.yachtLengthFt}ft${client.yachtLengthM ? ` / ${client.yachtLengthM}m` : ''}`
|
.map((y) => {
|
||||||
: null,
|
const dims = [
|
||||||
client.yachtWidthFt
|
y.lengthFt ? `${y.lengthFt}ft` : y.lengthM ? `${y.lengthM}m` : null,
|
||||||
? `Beam: ${client.yachtWidthFt}ft${client.yachtWidthM ? ` / ${client.yachtWidthM}m` : ''}`
|
y.widthFt ? `${y.widthFt}ft beam` : null,
|
||||||
: null,
|
y.draftFt ? `${y.draftFt}ft draft` : null,
|
||||||
client.yachtDraftFt
|
|
||||||
? `Draft: ${client.yachtDraftFt}ft${client.yachtDraftM ? ` / ${client.yachtDraftM}m` : ''}`
|
|
||||||
: null,
|
|
||||||
client.berthSizeDesired ? `Desired berth size: ${client.berthSizeDesired}` : null,
|
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' | ') || 'No vessel information on file';
|
.join(' · ');
|
||||||
|
return `• ${y.name}${dims ? ` (${dims})` : ''}`;
|
||||||
|
})
|
||||||
|
.join('\n')}`
|
||||||
|
: 'No yachts linked to this client';
|
||||||
|
|
||||||
const interestsText =
|
const interestsText =
|
||||||
interestList.length > 0
|
interestList.length > 0
|
||||||
@@ -84,7 +150,7 @@ export function buildClientSummaryInputs(
|
|||||||
title: `Client Summary — ${client.fullName ?? ''}`,
|
title: `Client Summary — ${client.fullName ?? ''}`,
|
||||||
clientInfo,
|
clientInfo,
|
||||||
contacts: contactsText,
|
contacts: contactsText,
|
||||||
vesselInfo,
|
yachts: yachtsText,
|
||||||
interests: `Pipeline Interests:\n${interestsText}`,
|
interests: `Pipeline Interests:\n${interestsText}`,
|
||||||
recentActivity: `Recent Activity:\n${activityText}`,
|
recentActivity: `Recent Activity:\n${activityText}`,
|
||||||
generatedAt: `Generated: ${new Date().toLocaleString('en-GB')}`,
|
generatedAt: `Generated: ${new Date().toLocaleString('en-GB')}`,
|
||||||
|
|||||||
337
src/lib/pdf/templates/eoi-standard-inapp.ts
Normal file
337
src/lib/pdf/templates/eoi-standard-inapp.ts
Normal 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 “EOI”) is entered into between
|
||||||
|
<strong>{{port.name}}</strong> and the Applicant named below, and records the
|
||||||
|
Applicant’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 / {{yacht.lengthM}} m</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Beam (ft / m)</td>
|
||||||
|
<td>{{yacht.widthFt}} ft / {{yacht.widthM}} m</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Draft (ft / m)</td>
|
||||||
|
<td>{{yacht.draftFt}} ft / {{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}} · Expression of Interest · {{date.year}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -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.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -4,15 +4,78 @@ export const interestSummaryTemplate: Template = {
|
|||||||
basePdf: 'BLANK_PDF' as unknown as string,
|
basePdf: 'BLANK_PDF' as unknown as string,
|
||||||
schemas: [
|
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: 'portName',
|
||||||
{ name: 'clientInfo', type: 'text', position: { x: 20, y: 45 }, width: 80, height: 30, fontSize: 9 },
|
type: 'text',
|
||||||
{ name: 'berthInfo', type: 'text', position: { x: 110, y: 45 }, width: 80, height: 30, fontSize: 9 },
|
position: { x: 20, y: 15 },
|
||||||
{ name: 'stageAndCategory', type: 'text', position: { x: 20, y: 80 }, width: 170, height: 15, fontSize: 9 },
|
width: 100,
|
||||||
{ name: 'milestones', type: 'text', position: { x: 20, y: 100 }, width: 170, height: 40, fontSize: 8 },
|
height: 10,
|
||||||
{ name: 'notes', type: 'text', position: { x: 20, y: 145 }, width: 170, height: 30, fontSize: 9 },
|
fontSize: 16,
|
||||||
{ 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: '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(
|
export function buildInterestSummaryInputs(
|
||||||
interest: Record<string, unknown>,
|
interest: Record<string, unknown>,
|
||||||
client: Record<string, unknown>,
|
client: Record<string, unknown>,
|
||||||
|
yacht: Record<string, unknown> | null,
|
||||||
berth: Record<string, unknown> | null,
|
berth: Record<string, unknown> | null,
|
||||||
timeline: Record<string, unknown>[],
|
timeline: Record<string, unknown>[],
|
||||||
port: Record<string, unknown>,
|
port: Record<string, unknown>,
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const clientInfo = [
|
const clientInfo = [
|
||||||
`Name: ${client?.fullName ?? 'N/A'}`,
|
`Name: ${client?.fullName ?? 'N/A'}`,
|
||||||
client?.companyName ? `Company: ${client.companyName}` : null,
|
yacht?.name ? `Yacht: ${yacht.name}` : null,
|
||||||
client?.yachtName ? `Yacht: ${client.yachtName}` : null,
|
yacht?.lengthFt
|
||||||
client?.yachtLengthFt
|
? `Length: ${yacht.lengthFt}ft${yacht.lengthM ? ` / ${yacht.lengthM}m` : ''}`
|
||||||
? `Length: ${client.yachtLengthFt}ft${client.yachtLengthM ? ` / ${client.yachtLengthM}m` : ''}`
|
|
||||||
: null,
|
: null,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -45,7 +108,9 @@ export function buildInterestSummaryInputs(
|
|||||||
`Mooring: ${berth.mooringNumber}`,
|
`Mooring: ${berth.mooringNumber}`,
|
||||||
berth.area ? `Area: ${berth.area}` : null,
|
berth.area ? `Area: ${berth.area}` : null,
|
||||||
berth.lengthFt ? `Length: ${berth.lengthFt}ft` : 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'}`,
|
`Status: ${berth.status ?? 'available'}`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -73,9 +138,7 @@ export function buildInterestSummaryInputs(
|
|||||||
`Deposit received: ${formatDate(interest.dateDepositReceived as Date | string | null | undefined)}`,
|
`Deposit received: ${formatDate(interest.dateDepositReceived as Date | string | null | undefined)}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const notesText = interest.notes
|
const notesText = interest.notes ? `Notes:\n${interest.notes}` : 'No notes';
|
||||||
? `Notes:\n${interest.notes}`
|
|
||||||
: 'No notes';
|
|
||||||
|
|
||||||
const timelineText =
|
const timelineText =
|
||||||
timeline.length > 0
|
timeline.length > 0
|
||||||
|
|||||||
@@ -76,7 +76,12 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
|
|||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
// Fallback: template-based draft
|
// 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
|
// Build prompt
|
||||||
@@ -91,8 +96,6 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
|
|||||||
`Write ${contextDescriptions[context] ?? 'an email'} to a marina berth client.`,
|
`Write ${contextDescriptions[context] ?? 'an email'} to a marina berth client.`,
|
||||||
'',
|
'',
|
||||||
`Client name: ${client.fullName}`,
|
`Client name: ${client.fullName}`,
|
||||||
client.companyName ? `Company: ${client.companyName}` : null,
|
|
||||||
client.yachtName ? `Yacht: ${client.yachtName}` : null,
|
|
||||||
berthMooring ? `Berth: ${berthMooring}` : 'Berth: not yet assigned',
|
berthMooring ? `Berth: ${berthMooring}` : 'Berth: not yet assigned',
|
||||||
`Pipeline stage: ${interest.pipelineStage}`,
|
`Pipeline stage: ${interest.pipelineStage}`,
|
||||||
'',
|
'',
|
||||||
@@ -164,7 +167,12 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
logger.warn({ err, interestId }, 'OpenAI call failed, falling back to template draft');
|
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() };
|
return { subject, body, generatedAt: new Date().toISOString() };
|
||||||
|
|||||||
@@ -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 { db } from '@/lib/db';
|
||||||
import { clients, clientContacts, clientRelationships, clientTags } from '@/lib/db/schema/clients';
|
import { clients, clientContacts, clientRelationships, clientTags } from '@/lib/db/schema/clients';
|
||||||
@@ -65,7 +65,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
|||||||
portId,
|
portId,
|
||||||
idColumn: clients.id,
|
idColumn: clients.id,
|
||||||
updatedAtColumn: clients.updatedAt,
|
updatedAtColumn: clients.updatedAt,
|
||||||
searchColumns: [clients.fullName, clients.companyName],
|
searchColumns: [clients.fullName],
|
||||||
searchTerm: search,
|
searchTerm: search,
|
||||||
filters,
|
filters,
|
||||||
sort: sort ? { column: sortColumn, direction: order } : undefined,
|
sort: sort ? { column: sortColumn, direction: order } : undefined,
|
||||||
@@ -197,7 +197,7 @@ export async function createClient(portId: string, data: CreateClientInput, meta
|
|||||||
action: 'create',
|
action: 'create',
|
||||||
entityType: 'client',
|
entityType: 'client',
|
||||||
entityId: result.id,
|
entityId: result.id,
|
||||||
newValue: { fullName: result.fullName, companyName: result.companyName },
|
newValue: { fullName: result.fullName },
|
||||||
ipAddress: meta.ipAddress,
|
ipAddress: meta.ipAddress,
|
||||||
userAgent: meta.userAgent,
|
userAgent: meta.userAgent,
|
||||||
});
|
});
|
||||||
@@ -532,9 +532,7 @@ export async function findDuplicates(portId: string, fullName: string) {
|
|||||||
export async function listClientOptions(portId: string, search?: string) {
|
export async function listClientOptions(portId: string, search?: string) {
|
||||||
const conditions = [eq(clients.portId, portId)];
|
const conditions = [eq(clients.portId, portId)];
|
||||||
if (search) {
|
if (search) {
|
||||||
conditions.push(
|
conditions.push(ilike(clients.fullName, `%${search}%`));
|
||||||
or(ilike(clients.fullName, `%${search}%`), ilike(clients.companyName, `%${search}%`))!,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return db
|
return db
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ export async function createDocument(
|
|||||||
}) as Promise<DocumensoDocument>;
|
}) 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> {
|
export async function sendDocument(docId: string): Promise<DocumensoDocument> {
|
||||||
return documensoFetch(`/api/v1/documents/${docId}/send`, {
|
return documensoFetch(`/api/v1/documents/${docId}/send`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
120
src/lib/services/documenso-payload.ts
Normal file
120
src/lib/services/documenso-payload.ts
Normal 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { clients, clientContacts } from '@/lib/db/schema/clients';
|
|||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
import { berths } from '@/lib/db/schema/berths';
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
import { buildListQuery } from '@/lib/db/query-builder';
|
||||||
import { createAuditLog } from '@/lib/audit';
|
import { createAuditLog } from '@/lib/audit';
|
||||||
import { diffEntity } from '@/lib/entity-diff';
|
import { diffEntity } from '@/lib/entity-diff';
|
||||||
@@ -16,7 +17,15 @@ import { minioClient, buildStoragePath } from '@/lib/minio';
|
|||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { generatePdf } from '@/lib/pdf/generate';
|
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 { sendEmail } from '@/lib/email';
|
||||||
import type {
|
import type {
|
||||||
CreateTemplateInput,
|
CreateTemplateInput,
|
||||||
@@ -37,52 +46,7 @@ interface AuditMeta {
|
|||||||
|
|
||||||
// ─── Merge Field Definitions ──────────────────────────────────────────────────
|
// ─── Merge Field Definitions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const MERGE_FIELDS: Record<string, Array<{ token: string; label: string; required: boolean }>> = {
|
export function getMergeFields(): MergeFieldCatalog {
|
||||||
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 {
|
|
||||||
return MERGE_FIELDS;
|
return MERGE_FIELDS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,10 +65,13 @@ export async function listTemplates(portId: string, query: ListTemplatesInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sortColumn =
|
const sortColumn =
|
||||||
sort === 'name' ? documentTemplates.name :
|
sort === 'name'
|
||||||
sort === 'templateType' ? documentTemplates.templateType :
|
? documentTemplates.name
|
||||||
sort === 'createdAt' ? documentTemplates.createdAt :
|
: sort === 'templateType'
|
||||||
documentTemplates.updatedAt;
|
? documentTemplates.templateType
|
||||||
|
: sort === 'createdAt'
|
||||||
|
? documentTemplates.createdAt
|
||||||
|
: documentTemplates.updatedAt;
|
||||||
|
|
||||||
return buildListQuery({
|
return buildListQuery({
|
||||||
table: documentTemplates,
|
table: documentTemplates,
|
||||||
@@ -178,10 +145,7 @@ export async function updateTemplate(
|
|||||||
) {
|
) {
|
||||||
const existing = await getTemplateById(id, portId);
|
const existing = await getTemplateById(id, portId);
|
||||||
|
|
||||||
const { diff } = diffEntity(
|
const { diff } = diffEntity(existing as Record<string, unknown>, data as Record<string, unknown>);
|
||||||
existing as Record<string, unknown>,
|
|
||||||
data as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(documentTemplates)
|
.update(documentTemplates)
|
||||||
@@ -261,69 +225,179 @@ export async function resolveTemplate(
|
|||||||
tokenMap['{{port.defaultCurrency}}'] = port.defaultCurrency;
|
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) {
|
if (context.clientId) {
|
||||||
const client = await db.query.clients.findFirst({
|
const client = await db.query.clients.findFirst({
|
||||||
where: eq(clients.id, context.clientId),
|
where: eq(clients.id, context.clientId),
|
||||||
});
|
});
|
||||||
if (client && client.portId === context.portId) {
|
if (client && client.portId === context.portId) {
|
||||||
|
// Always resolve source from the DB — EoiContext doesn't carry it.
|
||||||
|
if (tokenMap['{{client.source}}'] === undefined) {
|
||||||
|
tokenMap['{{client.source}}'] = client.source ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fill client.* tokens if the EOI path didn't already populate them.
|
||||||
|
if (!eoiContextLoaded) {
|
||||||
const contactList = await db.query.clientContacts.findMany({
|
const contactList = await db.query.clientContacts.findMany({
|
||||||
where: eq(clientContacts.clientId, context.clientId),
|
where: eq(clientContacts.clientId, context.clientId),
|
||||||
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
|
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
|
||||||
});
|
});
|
||||||
const emailContact = contactList.find((c) => c.channel === 'email');
|
const emailContact = contactList.find((c) => c.channel === 'email');
|
||||||
const phoneContact = contactList.find((c) => c.channel === 'phone' || c.channel === 'whatsapp');
|
const phoneContact = contactList.find(
|
||||||
|
(c) => c.channel === 'phone' || c.channel === 'whatsapp',
|
||||||
|
);
|
||||||
|
|
||||||
tokenMap['{{client.fullName}}'] = client.fullName ?? '';
|
tokenMap['{{client.fullName}}'] = client.fullName ?? '';
|
||||||
tokenMap['{{client.companyName}}'] = client.companyName ?? '';
|
|
||||||
tokenMap['{{client.email}}'] = emailContact?.value ?? '';
|
tokenMap['{{client.email}}'] = emailContact?.value ?? '';
|
||||||
tokenMap['{{client.phone}}'] = phoneContact?.value ?? '';
|
tokenMap['{{client.phone}}'] = phoneContact?.value ?? '';
|
||||||
tokenMap['{{client.nationality}}'] = client.nationality ?? '';
|
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 ?? '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
if (context.interestId) {
|
||||||
const interest = await db.query.interests.findFirst({
|
const interest = await db.query.interests.findFirst({
|
||||||
where: eq(interests.id, context.interestId),
|
where: eq(interests.id, context.interestId),
|
||||||
});
|
});
|
||||||
if (interest && interest.portId === context.portId) {
|
if (interest && interest.portId === context.portId) {
|
||||||
|
if (!eoiContextLoaded) {
|
||||||
tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? '';
|
tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? '';
|
||||||
tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? '';
|
tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? '';
|
||||||
tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? '';
|
|
||||||
tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact
|
tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact
|
||||||
? new Date(interest.dateFirstContact).toLocaleDateString('en-GB')
|
? 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.dateEoiSigned}}'] = interest.dateEoiSigned
|
tokenMap['{{interest.dateEoiSigned}}'] = interest.dateEoiSigned
|
||||||
? new Date(interest.dateEoiSigned).toLocaleDateString('en-GB')
|
? new Date(interest.dateEoiSigned).toLocaleDateString('en-GB')
|
||||||
: '';
|
: '';
|
||||||
tokenMap['{{interest.dateContractSigned}}'] = interest.dateContractSigned
|
tokenMap['{{interest.dateContractSigned}}'] = interest.dateContractSigned
|
||||||
? new Date(interest.dateContractSigned).toLocaleDateString('en-GB')
|
? new Date(interest.dateContractSigned).toLocaleDateString('en-GB')
|
||||||
: '';
|
: '';
|
||||||
tokenMap['{{interest.notes}}'] = interest.notes ?? '';
|
// Derive berth number from the interest when berthId wasn't passed and
|
||||||
// Berth number from interest if berthId not separately provided
|
// the EOI path didn't already populate it.
|
||||||
if (interest.berthId && !context.berthId) {
|
if (!eoiContextLoaded && interest.berthId && !context.berthId) {
|
||||||
const interestBerth = await db.query.berths.findFirst({
|
const interestBerth = await db.query.berths.findFirst({
|
||||||
where: eq(berths.id, interest.berthId),
|
where: eq(berths.id, interest.berthId),
|
||||||
});
|
});
|
||||||
tokenMap['{{interest.berthNumber}}'] = interestBerth?.mooringNumber ?? '';
|
if (interestBerth) {
|
||||||
tokenMap['{{berth.mooringNumber}}'] = interestBerth?.mooringNumber ?? '';
|
tokenMap['{{interest.berthNumber}}'] = interestBerth.mooringNumber;
|
||||||
|
if (!tokenMap['{{berth.mooringNumber}}']) {
|
||||||
|
tokenMap['{{berth.mooringNumber}}'] = interestBerth.mooringNumber;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
tokenMap['{{interest.berthNumber}}'] = context.berthId
|
tokenMap['{{interest.berthNumber}}'] ??= '';
|
||||||
? tokenMap['{{berth.mooringNumber}}'] ?? ''
|
}
|
||||||
|
} else if (!eoiContextLoaded) {
|
||||||
|
tokenMap['{{interest.berthNumber}}'] ??= context.berthId
|
||||||
|
? (tokenMap['{{berth.mooringNumber}}'] ?? '')
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Berth tokens
|
// Berth tokens (legacy path — when a berthId is passed directly and EOI
|
||||||
if (context.berthId) {
|
// resolution didn't already populate the berth block).
|
||||||
|
if (context.berthId && !eoiContextLoaded) {
|
||||||
const berth = await db.query.berths.findFirst({
|
const berth = await db.query.berths.findFirst({
|
||||||
where: eq(berths.id, context.berthId),
|
where: eq(berths.id, context.berthId),
|
||||||
});
|
});
|
||||||
@@ -355,9 +429,7 @@ export async function resolveTemplate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(`Missing required merge field values: ${missing.join(', ')}`);
|
||||||
`Missing required merge field values: ${missing.join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interpolate all tokens
|
// Interpolate all tokens
|
||||||
@@ -549,23 +621,175 @@ export async function generateAndSend(
|
|||||||
return { document, file };
|
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 ────────────────────────────────────────────────────────
|
// ─── 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(
|
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,
|
templateId: string,
|
||||||
portId: string,
|
portId: string,
|
||||||
context: GenerateInput,
|
context: GenerateInput,
|
||||||
signers: GenerateAndSignInput['signers'],
|
signers: GenerateAndSignInput['signers'],
|
||||||
meta: AuditMeta,
|
meta: AuditMeta,
|
||||||
) {
|
) {
|
||||||
const { document: documentRecord, file } = await generateFromTemplate(
|
|
||||||
templateId,
|
|
||||||
portId,
|
|
||||||
context,
|
|
||||||
meta,
|
|
||||||
) as { document: DbDocument; file: DbFile };
|
|
||||||
const template = await getTemplateById(templateId, portId);
|
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
|
// Fetch PDF bytes from MinIO to send to Documenso
|
||||||
const pdfStream = await minioClient.getObject(env.MINIO_BUCKET, file.storagePath);
|
const pdfStream = await minioClient.getObject(env.MINIO_BUCKET, file.storagePath);
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
@@ -578,7 +802,7 @@ export async function generateAndSign(
|
|||||||
const documensoDoc = await documensoCreate(
|
const documensoDoc = await documensoCreate(
|
||||||
template.name,
|
template.name,
|
||||||
pdfBase64,
|
pdfBase64,
|
||||||
signers.map((s) => ({
|
resolvedSigners.map((s) => ({
|
||||||
name: s.name,
|
name: s.name,
|
||||||
email: s.email,
|
email: s.email,
|
||||||
role: s.role,
|
role: s.role,
|
||||||
@@ -606,12 +830,82 @@ export async function generateAndSign(
|
|||||||
entityType: 'document',
|
entityType: 'document',
|
||||||
entityId: documentRecord.id,
|
entityId: documentRecord.id,
|
||||||
newValue: { status: 'sent', documensoId: documensoDoc.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,
|
ipAddress: meta.ipAddress,
|
||||||
userAgent: meta.userAgent,
|
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 };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { db } from '@/lib/db';
|
|||||||
import { documents, documentSigners, documentEvents, files } from '@/lib/db/schema/documents';
|
import { documents, documentSigners, documentEvents, files } from '@/lib/db/schema/documents';
|
||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
import { clients } from '@/lib/db/schema/clients';
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
import { berths } from '@/lib/db/schema/berths';
|
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
import { buildListQuery } from '@/lib/db/query-builder';
|
||||||
import { createAuditLog } from '@/lib/audit';
|
import { createAuditLog } from '@/lib/audit';
|
||||||
@@ -14,8 +13,6 @@ import { emitToRoom } from '@/lib/socket/server';
|
|||||||
import { minioClient, buildStoragePath } from '@/lib/minio';
|
import { minioClient, buildStoragePath } from '@/lib/minio';
|
||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { logger } from '@/lib/logger';
|
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 { evaluateRule } from '@/lib/services/berth-rules-engine';
|
||||||
import {
|
import {
|
||||||
createDocument as documensoCreate,
|
createDocument as documensoCreate,
|
||||||
@@ -50,10 +47,13 @@ export async function listDocuments(portId: string, query: ListDocumentsInput) {
|
|||||||
if (status) filters.push(eq(documents.status, status));
|
if (status) filters.push(eq(documents.status, status));
|
||||||
|
|
||||||
const sortColumn =
|
const sortColumn =
|
||||||
sort === 'title' ? documents.title :
|
sort === 'title'
|
||||||
sort === 'status' ? documents.status :
|
? documents.title
|
||||||
sort === 'documentType' ? documents.documentType :
|
: sort === 'status'
|
||||||
documents.createdAt;
|
? documents.status
|
||||||
|
: sort === 'documentType'
|
||||||
|
? documents.documentType
|
||||||
|
: documents.createdAt;
|
||||||
|
|
||||||
return buildListQuery({
|
return buildListQuery({
|
||||||
table: documents,
|
table: documents,
|
||||||
@@ -84,11 +84,7 @@ export async function getDocumentById(id: string, portId: string) {
|
|||||||
|
|
||||||
// ─── Create ───────────────────────────────────────────────────────────────────
|
// ─── Create ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function createDocument(
|
export async function createDocument(portId: string, data: CreateDocumentInput, meta: AuditMeta) {
|
||||||
portId: string,
|
|
||||||
data: CreateDocumentInput,
|
|
||||||
meta: AuditMeta,
|
|
||||||
) {
|
|
||||||
const [doc] = await db
|
const [doc] = await db
|
||||||
.insert(documents)
|
.insert(documents)
|
||||||
.values({
|
.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');
|
throw new ConflictError('Cannot delete a document that is currently in signing process');
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db.delete(documents).where(and(eq(documents.id, id), eq(documents.portId, portId)));
|
||||||
.delete(documents)
|
|
||||||
.where(and(eq(documents.id, id), eq(documents.portId, portId)));
|
|
||||||
|
|
||||||
void createAuditLog({
|
void createAuditLog({
|
||||||
userId: meta.userId,
|
userId: meta.userId,
|
||||||
@@ -187,116 +181,6 @@ export async function deleteDocument(id: string, portId: string, meta: AuditMeta
|
|||||||
emitToRoom(`port:${portId}`, 'document:deleted', { documentId: id });
|
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) ────────────────────────────────────────────────
|
// ─── Send for Signing (BR-021) ────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function sendForSigning(documentId: string, portId: string, meta: AuditMeta) {
|
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');
|
if (!client) throw new ValidationError('Document has no associated client');
|
||||||
|
|
||||||
const emailContact = (client.contacts as Array<{ channel: string; value: string }> | undefined)?.find(
|
const emailContact = (
|
||||||
(c) => c.channel === 'email',
|
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');
|
if (!emailContact?.value) throw new ValidationError('Client has no email contact');
|
||||||
|
|
||||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
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, [
|
const documensoDoc = await documensoCreate(doc.title, pdfBase64, [
|
||||||
{ name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 },
|
{ 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, 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);
|
await documensoSend(documensoDoc.id);
|
||||||
@@ -432,7 +321,12 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
|
|||||||
userAgent: meta.userAgent,
|
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);
|
return await getDocumentById(documentId, portId);
|
||||||
}
|
}
|
||||||
@@ -453,13 +347,9 @@ export async function uploadSignedManually(
|
|||||||
const fileId = crypto.randomUUID();
|
const fileId = crypto.randomUUID();
|
||||||
const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf');
|
const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf');
|
||||||
|
|
||||||
await minioClient.putObject(
|
await minioClient.putObject(env.MINIO_BUCKET, storagePath, fileData.buffer, fileData.size, {
|
||||||
env.MINIO_BUCKET,
|
'Content-Type': fileData.mimeType,
|
||||||
storagePath,
|
});
|
||||||
fileData.buffer,
|
|
||||||
fileData.size,
|
|
||||||
{ 'Content-Type': fileData.mimeType },
|
|
||||||
);
|
|
||||||
|
|
||||||
const [fileRecord] = await db
|
const [fileRecord] = await db
|
||||||
.insert(files)
|
.insert(files)
|
||||||
@@ -612,9 +502,7 @@ export async function handleRecipientSigned(eventData: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleDocumentCompleted(eventData: {
|
export async function handleDocumentCompleted(eventData: { documentId: string }) {
|
||||||
documentId: string;
|
|
||||||
}) {
|
|
||||||
const doc = await db.query.documents.findFirst({
|
const doc = await db.query.documents.findFirst({
|
||||||
where: eq(documents.documensoId, eventData.documentId),
|
where: eq(documents.documensoId, eventData.documentId),
|
||||||
});
|
});
|
||||||
@@ -718,9 +606,7 @@ export async function handleDocumentCompleted(eventData: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleDocumentExpired(eventData: {
|
export async function handleDocumentExpired(eventData: { documentId: string }) {
|
||||||
documentId: string;
|
|
||||||
}) {
|
|
||||||
const doc = await db.query.documents.findFirst({
|
const doc = await db.query.documents.findFirst({
|
||||||
where: eq(documents.documensoId, eventData.documentId),
|
where: eq(documents.documensoId, eventData.documentId),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,18 +66,18 @@ async function assertYachtBelongsToClient(
|
|||||||
async function resolveLeadCategory(
|
async function resolveLeadCategory(
|
||||||
clientId: string,
|
clientId: string,
|
||||||
leadCategory: string | undefined | null,
|
leadCategory: string | undefined | null,
|
||||||
|
yachtId?: string | null,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
if (leadCategory && leadCategory !== 'general_interest') {
|
if (leadCategory && leadCategory !== 'general_interest') {
|
||||||
return leadCategory;
|
return leadCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await db.query.clients.findFirst({
|
if (yachtId) {
|
||||||
where: eq(clients.id, clientId),
|
const yacht = await db.query.yachts.findFirst({ where: eq(yachts.id, yachtId) });
|
||||||
});
|
if (yacht && (yacht.lengthFt || yacht.lengthM)) {
|
||||||
|
|
||||||
if (client && (client.yachtLengthFt || client.yachtLengthM)) {
|
|
||||||
return 'specific_qualified';
|
return 'specific_qualified';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return leadCategory ?? undefined;
|
return leadCategory ?? undefined;
|
||||||
}
|
}
|
||||||
@@ -275,7 +275,11 @@ export async function createInterest(portId: string, data: CreateInterestInput,
|
|||||||
const { tagIds, ...interestData } = data;
|
const { tagIds, ...interestData } = data;
|
||||||
|
|
||||||
// BR-011: auto-promote leadCategory
|
// 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 result = await withTransaction(async (tx) => {
|
||||||
const [interest] = await tx
|
const [interest] = await tx
|
||||||
@@ -350,6 +354,7 @@ export async function updateInterest(
|
|||||||
resolvedLeadCategory = (await resolveLeadCategory(
|
resolvedLeadCategory = (await resolveLeadCategory(
|
||||||
existing.clientId,
|
existing.clientId,
|
||||||
data.leadCategory,
|
data.leadCategory,
|
||||||
|
data.yachtId ?? existing.yachtId,
|
||||||
)) as typeof data.leadCategory;
|
)) as typeof data.leadCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { db } from '@/lib/db';
|
||||||
import { clients, clientContacts } from '@/lib/db/schema/clients';
|
import { clients, clientContacts } from '@/lib/db/schema/clients';
|
||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
import { berths, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
|
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 { auditLogs } from '@/lib/db/schema/system';
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { NotFoundError } from '@/lib/errors';
|
import { NotFoundError } from '@/lib/errors';
|
||||||
@@ -12,10 +14,7 @@ import {
|
|||||||
clientSummaryTemplate,
|
clientSummaryTemplate,
|
||||||
buildClientSummaryInputs,
|
buildClientSummaryInputs,
|
||||||
} from '@/lib/pdf/templates/client-summary-template';
|
} from '@/lib/pdf/templates/client-summary-template';
|
||||||
import {
|
import { berthSpecTemplate, buildBerthSpecInputs } from '@/lib/pdf/templates/berth-spec-template';
|
||||||
berthSpecTemplate,
|
|
||||||
buildBerthSpecInputs,
|
|
||||||
} from '@/lib/pdf/templates/berth-spec-template';
|
|
||||||
import {
|
import {
|
||||||
interestSummaryTemplate,
|
interestSummaryTemplate,
|
||||||
buildInterestSummaryInputs,
|
buildInterestSummaryInputs,
|
||||||
@@ -63,9 +62,7 @@ export async function exportClientPdf(clientId: string, portId: string): Promise
|
|||||||
.limit(20);
|
.limit(20);
|
||||||
|
|
||||||
// Enrich interests with berth mooring numbers
|
// Enrich interests with berth mooring numbers
|
||||||
const berthIds = interestList
|
const berthIds = interestList.map((i) => i.berthId).filter(Boolean) as string[];
|
||||||
.map((i) => i.berthId)
|
|
||||||
.filter(Boolean) as string[];
|
|
||||||
|
|
||||||
let berthsMap: Record<string, string> = {};
|
let berthsMap: Record<string, string> = {};
|
||||||
if (berthIds.length > 0) {
|
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,
|
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]);
|
return generatePdf(clientSummaryTemplate, [inputs]);
|
||||||
}
|
}
|
||||||
@@ -143,7 +177,13 @@ export async function exportBerthPdf(berthId: string, portId: string): Promise<U
|
|||||||
.orderBy(desc(interests.updatedAt))
|
.orderBy(desc(interests.updatedAt))
|
||||||
.limit(20);
|
.limit(20);
|
||||||
|
|
||||||
const inputs = buildBerthSpecInputs(berth, enrichedWaitingList, maintenance, linkedInterests, port ?? {});
|
const inputs = buildBerthSpecInputs(
|
||||||
|
berth,
|
||||||
|
enrichedWaitingList,
|
||||||
|
maintenance,
|
||||||
|
linkedInterests,
|
||||||
|
port ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
return generatePdf(berthSpecTemplate, [inputs]);
|
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) });
|
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)
|
// Audit timeline (last 20 events for this interest)
|
||||||
const timeline = await db
|
const timeline = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -183,7 +228,14 @@ export async function exportInterestPdf(interestId: string, portId: string): Pro
|
|||||||
.orderBy(desc(auditLogs.createdAt))
|
.orderBy(desc(auditLogs.createdAt))
|
||||||
.limit(20);
|
.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]);
|
return generatePdf(interestSummaryTemplate, [inputs]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { redis } from '@/lib/redis';
|
|||||||
interface ClientResult {
|
interface ClientResult {
|
||||||
id: string;
|
id: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
companyName: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InterestResult {
|
interface InterestResult {
|
||||||
@@ -52,15 +51,15 @@ interface SearchResults {
|
|||||||
export async function search(portId: string, query: string): Promise<SearchResults> {
|
export async function search(portId: string, query: string): Promise<SearchResults> {
|
||||||
const [clientRows, berthRows, interestRows, yachtRows, companyRows] = await Promise.all([
|
const [clientRows, berthRows, interestRows, yachtRows, companyRows] = await Promise.all([
|
||||||
// Clients: full-text search via tsvector
|
// Clients: full-text search via tsvector
|
||||||
db.execute<{ id: string; full_name: string; company_name: string | null }>(sql`
|
db.execute<{ id: string; full_name: string }>(sql`
|
||||||
SELECT id, full_name, company_name
|
SELECT id, full_name
|
||||||
FROM clients
|
FROM clients
|
||||||
WHERE port_id = ${portId}
|
WHERE port_id = ${portId}
|
||||||
AND archived_at IS NULL
|
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})
|
@@ plainto_tsquery('simple', ${query})
|
||||||
ORDER BY ts_rank(
|
ORDER BY ts_rank(
|
||||||
to_tsvector('simple', coalesce(full_name, '') || ' ' || coalesce(company_name, '')),
|
to_tsvector('simple', coalesce(full_name, '')),
|
||||||
plainto_tsquery('simple', ${query})
|
plainto_tsquery('simple', ${query})
|
||||||
) DESC
|
) DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
@@ -157,7 +156,6 @@ export async function search(portId: string, query: string): Promise<SearchResul
|
|||||||
clients: Array.from(clientRows).map((r) => ({
|
clients: Array.from(clientRows).map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
fullName: r.full_name,
|
fullName: r.full_name,
|
||||||
companyName: r.company_name ?? null,
|
|
||||||
})),
|
})),
|
||||||
berths: Array.from(berthRows).map((r) => ({
|
berths: Array.from(berthRows).map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|||||||
80
src/lib/templates/merge-fields.ts
Normal file
80
src/lib/templates/merge-fields.ts
Normal 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)),
|
||||||
|
);
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { baseListQuerySchema } from '@/lib/api/route-helpers';
|
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({
|
export const createTemplateSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
templateType: z.enum([
|
templateType: z.enum([
|
||||||
|
'eoi',
|
||||||
'welcome_letter',
|
'welcome_letter',
|
||||||
'handover_checklist',
|
'handover_checklist',
|
||||||
'acknowledgment',
|
'acknowledgment',
|
||||||
@@ -13,7 +27,7 @@ export const createTemplateSchema = z.object({
|
|||||||
'custom',
|
'custom',
|
||||||
]),
|
]),
|
||||||
bodyHtml: z.string().min(1),
|
bodyHtml: z.string().min(1),
|
||||||
mergeFields: z.array(z.string()).optional().default([]),
|
mergeFields: mergeFieldsSchema,
|
||||||
isActive: z.boolean().default(true),
|
isActive: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,6 +52,7 @@ export const generateAndSendSchema = generateSchema.extend({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const generateAndSignSchema = generateSchema.extend({
|
export const generateAndSignSchema = generateSchema.extend({
|
||||||
|
pathway: z.enum(['inapp', 'documenso-template']).default('inapp'),
|
||||||
signers: z
|
signers: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -47,7 +62,8 @@ export const generateAndSignSchema = generateSchema.extend({
|
|||||||
signingOrder: z.number().int().min(1),
|
signingOrder: z.number().int().min(1),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.min(1),
|
.optional()
|
||||||
|
.default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateTemplateInput = z.infer<typeof createTemplateSchema>;
|
export type CreateTemplateInput = z.infer<typeof createTemplateSchema>;
|
||||||
|
|||||||
@@ -24,10 +24,6 @@ export const listDocumentsSchema = baseListQuerySchema.extend({
|
|||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const generateEoiSchema = z.object({
|
|
||||||
interestId: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const uploadSignedSchema = z.object({
|
export const uploadSignedSchema = z.object({
|
||||||
documentId: z.string().min(1),
|
documentId: z.string().min(1),
|
||||||
});
|
});
|
||||||
@@ -35,4 +31,3 @@ export const uploadSignedSchema = z.object({
|
|||||||
export type CreateDocumentInput = z.infer<typeof createDocumentSchema>;
|
export type CreateDocumentInput = z.infer<typeof createDocumentSchema>;
|
||||||
export type UpdateDocumentInput = z.infer<typeof updateDocumentSchema>;
|
export type UpdateDocumentInput = z.infer<typeof updateDocumentSchema>;
|
||||||
export type ListDocumentsInput = z.infer<typeof listDocumentsSchema>;
|
export type ListDocumentsInput = z.infer<typeof listDocumentsSchema>;
|
||||||
export type GenerateEoiInput = z.infer<typeof generateEoiSchema>;
|
|
||||||
|
|||||||
@@ -130,17 +130,6 @@ export const publicInterestSchema = z
|
|||||||
source: z.literal('website').default('website'),
|
source: z.literal('website').default('website'),
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
address: addressSchema.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), {
|
.refine((data) => data.fullName || (data.firstName && data.lastName), {
|
||||||
message: 'Either fullName or both firstName and lastName are required',
|
message: 'Either fullName or both firstName and lastName are required',
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export interface Client {
|
|||||||
id: string;
|
id: string;
|
||||||
portId: string;
|
portId: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
companyName?: string | null;
|
|
||||||
nationality?: string | null;
|
nationality?: string | null;
|
||||||
source?: string | null;
|
source?: string | null;
|
||||||
archivedAt?: Date | null;
|
archivedAt?: Date | null;
|
||||||
|
|||||||
@@ -592,7 +592,6 @@ export function makeCreateClientInput(overrides?: { fullName?: string; portId?:
|
|||||||
return {
|
return {
|
||||||
fullName: overrides?.fullName ?? 'Test Client',
|
fullName: overrides?.fullName ?? 'Test Client',
|
||||||
contacts: [{ channel: 'email' as const, value: 'test@example.com', isPrimary: true }],
|
contacts: [{ channel: 'email' as const, value: 'test@example.com', isPrimary: true }],
|
||||||
isProxy: false,
|
|
||||||
tagIds: [] as string[],
|
tagIds: [] as string[],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
425
tests/integration/document-templates-eoi.test.ts
Normal file
425
tests/integration/document-templates-eoi.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
414
tests/integration/document-templates-generate-and-sign.test.ts
Normal file
414
tests/integration/document-templates-generate-and-sign.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
176
tests/unit/pdf/fill-eoi-form.test.ts
Normal file
176
tests/unit/pdf/fill-eoi-form.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
210
tests/unit/services/documenso-payload.test.ts
Normal file
210
tests/unit/services/documenso-payload.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
61
tests/unit/validators/document-templates.test.ts
Normal file
61
tests/unit/validators/document-templates.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user