Single coherent commit completing § 1.1 (hot-path correctness) plus
§ 1.1.4.5 (multi-berth EOI mooring fix). Numbers users see are now
self-consistent across dashboard / kanban / hot deals / PDF reports.
## Active-interest sweep (canonical predicate everywhere)
Routed every "active interest" filter through `activeInterestsWhere`
(commit b966d81 helper). The helper enforces port-scoping + archivedAt
IS NULL + outcome IS NULL — strict definition, won is closed.
Touched sites:
- src/lib/services/reminders.service.ts:digestPort — no longer fires
reminders for won/lost/cancelled deals
- src/lib/services/berths.service.ts:getLatestInterestStageByBerth
- src/lib/services/client-archive-dossier.service.ts (next-in-line
others lookup)
- src/lib/services/client-archive.service.ts (remaining-under-offer
recount before flipping berth back to available)
- src/lib/services/client-restore.service.ts (yacht-usage check)
- src/lib/services/interests.service.ts:listInterestsForBoard +
getInterestStageCounts + the "others on same berth" lookup —
kanban / board now exclude terminal deals
- src/lib/services/report-generators.ts: fetchPipelineData,
fetchRevenueData stage breakdowns, top-N interests
## Pipeline-value currency conversion
`getKpis()` now fetches the port's defaultCurrency from `ports` and
converts each berth's `priceCurrency`→port-default via
`currency.service`. Returns `pipelineValue` + `pipelineValueCurrency`
instead of the lying `pipelineValueUsd`. Missing rates fall through to
raw amount summing (so the tile still shows an approximate number) —
behind a follow-up to surface a "rates incomplete" indicator.
3 consumers updated: KpiCards, PipelineValueTile, ActiveDealsTile.
## Occupancy = sold only
Both the dashboard KPI tile and the revenue-report PDF occupancy data
now count only `berth.status='sold'`. `under_offer` is a hold, not
occupation. The analytics timeline switches from
`berth_reservations`-derived to a cumulative-won-deals derivation via
`interests.outcome='won' AND outcome_at::date <= day` — same source of
truth, historical shape preserved.
## Revenue PDF two-card layout
Added `totalForecast` + `pipelineWeights` to `RevenueData`. Summary
section now renders both:
- "Completed revenue (won)" — money in the bank
- "Forecast revenue (pipeline-weighted)" — expected pipeline value
Pipeline weights resolve from `system_settings.pipeline_weights`
(per-port admin override) and fall back to STAGE_WEIGHTS defaults. PDF
and dashboard forecast tiles reconcile.
## Multi-berth EOI mooring (4.5)
Documenso `Berth Number` form field now carries the formatBerthRange
output for BOTH single- and multi-berth EOIs. Single-berth output is
byte-identical to the legacy primary-only path
(`formatBerthRange(['A1']) === 'A1'`). Multi-berth EOIs now render
the full range ("A1-A3, B5") in the existing field instead of being
silently dropped against a nonexistent `Berth Range` field.
Dropped:
- `'Berth Range'` from the Documenso formValues payload + TS type
- `setBerthRange()` helper from fill-eoi-form.ts (now redundant)
- The "missing Berth Range AcroForm field" warning log
Updated CLAUDE.md to reflect — no Documenso admin template change
needed.
## Tests
- Updated `documenso-payload.test.ts` — new fixture asserts
formatBerthRange output flows into Berth Number; multi-berth case
added.
- Updated `analytics-service.test.ts:computeOccupancyTimeline` —
fixture creates a won interest instead of a reservation.
- Updated `alerts-engine.test.ts:interest.stale` — fixture stage
switched from dead `'in_communication'` to canonical `'qualified'`.
- Updated `report-templates.test.tsx:revenue` — fixture carries
`totalForecast` + `pipelineWeights` to match new RevenueData.
1373/1373 vitest pass. tsc + eslint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
6.4 KiB
TypeScript
167 lines
6.4 KiB
TypeScript
import { createHash } from 'node:crypto';
|
||
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';
|
||
import { logger } from '@/lib/logger';
|
||
|
||
/**
|
||
* Pinned sha256 of `assets/eoi-template.pdf`. Bump this AND the README
|
||
* checksum in lockstep when the template is intentionally re-cut. A
|
||
* mismatch only logs a warning (we don't want a malformed swap to take
|
||
* the EOI flow offline at boot), but ops monitoring will surface it.
|
||
*/
|
||
export const EXPECTED_EOI_SHA256 =
|
||
'ba495fd88d99ebe4b7f61acbe397fb2f1cd116e1e1f1b217de93106915c7c44b';
|
||
|
||
let shaCheckPerformed = false;
|
||
|
||
/**
|
||
* 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();
|
||
let bytes: Buffer;
|
||
try {
|
||
bytes = 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}`,
|
||
);
|
||
}
|
||
|
||
// SHA-pin check (M-6): warn once per process when the template's bytes
|
||
// don't match the committed hash. Skipped entirely when the override
|
||
// env var is in play (fixture / dev workflows are expected to use
|
||
// arbitrary PDFs). Only checks the default path so test setups stay
|
||
// unconstrained.
|
||
if (!shaCheckPerformed && !process.env.EOI_TEMPLATE_PDF_PATH) {
|
||
shaCheckPerformed = true;
|
||
const actual = createHash('sha256').update(bytes).digest('hex');
|
||
if (actual !== EXPECTED_EOI_SHA256) {
|
||
logger.warn(
|
||
{ expected: EXPECTED_EOI_SHA256, actual },
|
||
'EOI source PDF sha256 mismatch — template was modified without an EXPECTED_EOI_SHA256 bump. Update assets/README.md + EXPECTED_EOI_SHA256 in lockstep if this was intentional.',
|
||
);
|
||
}
|
||
}
|
||
|
||
return bytes;
|
||
}
|
||
|
||
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 so a slightly different PDF
|
||
// template still produces output, but surface a warning so a re-cut
|
||
// template with drifted field names is observable in ops logs
|
||
// instead of shipping silently with empty values. Only warn when
|
||
// there's a real value to render; an empty value would be a no-op
|
||
// even if the field existed.
|
||
if (value && value.trim().length > 0) {
|
||
logger.warn(
|
||
{ field: name },
|
||
`EOI in-app PDF template is missing AcroForm field "${name}" — value was dropped. Update the source template.`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
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 {
|
||
logger.warn(
|
||
{ field: name, checked },
|
||
`EOI in-app PDF template is missing checkbox AcroForm field "${name}" — checkbox state was dropped. Update the source template.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 **flattened** after filling so the recipient can't edit
|
||
* pre-filled values (yacht dimensions, address, berth number) after the
|
||
* fact. Documenso pathway flattens server-side; this brings the in-app
|
||
* pathway to parity. Set metadata so the artifact carries Title/Author/
|
||
* Lang for downstream readers and a11y tooling.
|
||
*/
|
||
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));
|
||
// Yacht + berth (EOI Section 3) are optional - leave the AcroForm fields
|
||
// blank when the interest hasn't been linked to either.
|
||
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 ?? '');
|
||
// Berth Number = compact range for multi-berth, primary mooring for
|
||
// single-berth (formatBerthRange(['A1']) === 'A1' so single-berth is
|
||
// byte-identical to the legacy primary-only path). The dedicated
|
||
// `Berth Range` AcroForm field was retired 2026-05-14 — the source
|
||
// PDF only carries `Berth Number`.
|
||
setText(form, 'Berth Number', context.eoiBerthRange || (context.berth?.mooringNumber ?? ''));
|
||
|
||
setCheckbox(form, 'Purchase', true);
|
||
setCheckbox(form, 'Lease_10', false);
|
||
|
||
// PDF metadata so the artifact carries Title/Author/Lang downstream.
|
||
doc.setTitle(`EOI – ${context.client.fullName}`);
|
||
doc.setAuthor(context.port.name);
|
||
doc.setSubject('Expression of Interest');
|
||
doc.setLanguage('en-GB');
|
||
doc.setProducer('Port CRM');
|
||
doc.setCreator('Port CRM');
|
||
|
||
// Flatten so the signer can't edit pre-filled values after the fact.
|
||
form.flatten();
|
||
|
||
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);
|
||
}
|