feat(eoi): multi-berth EOI generation + berth-range formatter

Plan §4.6 + §1: a render function that compresses every berth marked
is_in_eoi_bundle=true on an interest into a compact range string
("A1-A3, B5-B7"), wired into both EOI generation paths (the Documenso
template-generate call and the in-app pdf-lib AcroForm fill).

- src/lib/templates/berth-range.ts: pure formatBerthRange() with the
  full edge-case set from §4.6 - empty, single, run, gap, multiple
  prefixes, sort/dedup, multi-letter prefixes, non-canonical
  passthrough, long ranges. Sorts by (prefix, number); dedupes; passes
  non-canonical inputs through with a logger warning.
- src/lib/templates/merge-fields.ts: new {{eoi.berthRange}} token
  added to VALID_MERGE_TOKENS allow-list under a fresh `eoi` scope so
  unknown-token validation at template creation time still rejects
  typos.
- src/lib/services/eoi-context.ts: EoiContext gains eoiBerthRange.
  Resolved by joining interest_berths (is_in_eoi_bundle=true) →
  berths and feeding the mooring numbers through formatBerthRange.
- src/lib/services/documenso-payload.ts: formValues now includes
  "Berth Range" alongside the legacy "Berth Number". Multi-berth EOIs
  surface here; single-berth EOIs duplicate the primary.
- src/lib/pdf/fill-eoi-form.ts: in-app AcroForm fill mirrors the
  Documenso payload by populating "Berth Range". Falls back silently
  when older PDFs don't have the field (setText is no-op-on-missing).

15 unit tests on the formatter; existing EoiContext + Documenso
payload tests updated to assert the new field. 1022 -> 1037 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-05 03:03:29 +02:00
parent b1e787e55c
commit e00e812199
8 changed files with 208 additions and 1 deletions

View File

@@ -4,12 +4,13 @@ import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
import { companies, companyAddresses } from '@/lib/db/schema/companies';
import { interests } from '@/lib/db/schema/interests';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { ports } from '@/lib/db/schema/ports';
import { yachts } from '@/lib/db/schema/yachts';
import { getCountryName } from '@/lib/i18n/countries';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { getPrimaryBerth } from '@/lib/services/interest-berths.service';
import { formatBerthRange } from '@/lib/templates/berth-range';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -57,6 +58,13 @@ export type EoiContext = {
priceCurrency: string;
tenureType: string;
} | null;
/**
* Compact range string for every berth in the interest's EOI bundle
* (rows where `interest_berths.is_in_eoi_bundle=true`). Populates the
* Documenso `Berth Range` form field for multi-berth EOIs (plan §1
* + §4.6). Empty string when the bundle is empty.
*/
eoiBerthRange: string;
interest: {
stage: string;
leadCategory: string | null;
@@ -102,6 +110,16 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
const primaryBerth = await getPrimaryBerth(interest.id);
const primaryBerthId = primaryBerth?.berthId ?? null;
// Resolve every berth in the EOI bundle (is_in_eoi_bundle=true) for the
// multi-berth EOI compact-range merge field. Empty bundle → "" so the
// Documenso template renders blank rather than "undefined".
const bundleRows = await db
.select({ mooringNumber: berths.mooringNumber })
.from(interestBerths)
.innerJoin(berths, eq(berths.id, interestBerths.berthId))
.where(and(eq(interestBerths.interestId, interest.id), eq(interestBerths.isInEoiBundle, true)));
const eoiBerthRange = formatBerthRange(bundleRows.map((r) => r.mooringNumber));
// Parallelise independent reads. Yacht and berth are both nullable -
// the EOI's Section 3 stays blank when they're absent.
const [yacht, berth, client, port] = await Promise.all([
@@ -277,6 +295,7 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
tenureType: berth.tenureType,
}
: null,
eoiBerthRange,
interest: {
stage: interest.pipelineStage,
leadCategory: interest.leadCategory,