feat(berths): normalize mooring numbers to canonical form

Sweep CRM mooring numbers from the legacy hyphen+padded form ("A-01")
to the canonical bare form ("A1") used by NocoDB, the public website,
the per-berth PDFs, and the Documenso EOI templates. Drift was
introduced by the original load-berths-to-port-nimara.ts seed; this
gates the Phase 3 public-website cutover where /berths/A1 URLs would
404 against a CRM still storing "A-01".

- 0024 data migration: idempotent regexp_replace + post-update sanity
  check that surfaces any non-conforming rows for manual triage.
- Invert normalizeLegacyMooring in dedup/migration-apply: it now
  canonicalizes ("D-32" -> "D32") instead of legacy-izing.
- Update tiptap-to-pdfme example tokens, EOI fixture moorings, and
  smoke-test seed moorings.
- Refresh seed-data/berths.json to canonical form; drop the now-
  redundant legacyMooringNumber field.
- Delete scripts/load-berths-to-port-nimara.ts (superseded in 0c).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-05 01:59:26 +02:00
parent 8699f81879
commit 05be89ec6f
12 changed files with 10829 additions and 415 deletions

View File

@@ -0,0 +1,31 @@
-- Normalize berth mooring numbers from legacy hyphen+padded form ("A-01")
-- to the canonical form ("A1") that NocoDB, the public website, the
-- Documenso EOI templates, and every external reference use.
--
-- Idempotent: rows already in canonical form are untouched. The regex
-- accepts:
-- - optional hyphen between letter prefix and digits
-- - optional leading zeros on the digits
-- - one or more letters in the prefix (future-proofs "AA1" etc.)
-- Pure-numeric or otherwise non-conforming moorings (e.g. "B-LEG") are
-- left unchanged so they show up in the orphan check below for manual
-- review.
UPDATE berths
SET mooring_number = regexp_replace(mooring_number, '^([A-Z]+)-?0*(\d+)$', '\1\2')
WHERE mooring_number ~ '^[A-Z]+-0*\d+$';
-- Sanity check: surface any moorings that don't match the canonical
-- pattern after the rewrite. These need manual triage before Phase 3
-- can ship (the public website builds /berths/:mooring URLs from this
-- value). Logged via NOTICE so the migration runner prints them.
DO $$
DECLARE
bad_count integer;
BEGIN
SELECT count(*) INTO bad_count
FROM berths
WHERE mooring_number !~ '^[A-Z]+\d+$';
IF bad_count > 0 THEN
RAISE NOTICE 'Mooring normalization: % rows do not match ^[A-Z]+\d+$ - manual review needed', bad_count;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -169,6 +169,13 @@
"when": 1777927586934,
"tag": "0023_omniscient_reaper",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1777938954111,
"tag": "0024_normalize_mooring_numbers",
"breakpoints": true
}
]
}

View File

@@ -49,11 +49,10 @@ import berthSnapshot from './seed-data/berths.json';
// ─── Berth snapshot ──────────────────────────────────────────────────────────
// 117 rows imported from the legacy NocoDB Berths table on 2026-05-03.
// Refresh by re-running the snapshot script (see git history of this file).
// Refresh via `pnpm tsx scripts/import-berths-from-nocodb.ts --update-snapshot`.
type SeedBerth = {
legacyId: number;
mooringNumber: string;
legacyMooringNumber: string;
area: string | null;
status: 'available' | 'under_offer' | 'sold';
lengthFt: number | null;

File diff suppressed because it is too large Load Diff

View File

@@ -37,22 +37,18 @@ import type {
const SOURCE_SYSTEM = 'nocodb_interests';
/**
* Convert a legacy bare mooring string like "D32" / "A1" / "E18" to the
* dashed/padded form "D-32" / "A-01" / "E-18" used by the new berths
* schema. If the input doesn't match the bare pattern, returns it
* unchanged so a literal lookup can still hit (handles the case where
* the legacy data already has the dashed form).
*
* Multi-mooring strings ("A3, D30") return the original string -
* those need human review and we don't want to silently pick one half.
* Canonicalize a mooring string to the unified form ("A1", "D32", "E18")
* used by the new berths schema after the Phase 0 mooring-number sweep.
* Accepts inputs in any of the historical forms - bare ("D32"), dashed
* ("D-32"), or dashed-padded ("D-032") - and returns the canonical bare
* form. Inputs that don't match (multi-mooring strings like "A3, D30",
* non-numeric suffixes like "B-LEG") are returned unchanged so a literal
* lookup can still hit and the caller can flag the row for review.
*/
function normalizeLegacyMooring(raw: string): string {
// Bare letter+digits, e.g. "D32"
const m = /^([A-E])(\d{1,3})$/i.exec(raw.trim());
const m = /^([A-Z]+)-?0*(\d+)$/i.exec(raw.trim());
if (!m) return raw;
const letter = m[1]!.toUpperCase();
const num = parseInt(m[2]!, 10);
return `${letter}-${num.toString().padStart(2, '0')}`;
return `${m[1]!.toUpperCase()}${parseInt(m[2]!, 10)}`;
}
export interface ApplyResult {
@@ -254,10 +250,9 @@ async function applyInterest(
if (planned.berthMooringNumber) {
berthId =
mooringToBerthId.get(planned.berthMooringNumber) ??
// The legacy NocoDB Interests table uses bare mooring strings like
// "D32", "B16", whereas the new berths schema (mirroring the NocoDB
// Berths snapshot) uses zero-padded "D-32", "B-16". Try the dashed
// form as a fallback so legacy references resolve correctly.
// Legacy NocoDB Interests rows recorded mooring strings inconsistently
// ("D32", "D-32", "D-032"). The new berths schema stores the canonical
// bare form ("D32") - canonicalize the lookup key as a fallback.
mooringToBerthId.get(normalizeLegacyMooring(planned.berthMooringNumber)) ??
null;
if (!berthId) {

View File

@@ -48,10 +48,10 @@ interface SchemaField {
// ─── Constants ────────────────────────────────────────────────────────────────
const PAGE_WIDTH_MM = 170; // A4 content width (210 - 20mm margins each side)
const PAGE_BREAK_THRESHOLD = 250; // y position (mm from page top) to start new logical page
const MARGIN_X_MM = 20; // Left margin
const MARGIN_TOP_MM = 20; // Top margin
const PAGE_WIDTH_MM = 170; // A4 content width (210 - 20mm margins each side)
const PAGE_BREAK_THRESHOLD = 250; // y position (mm from page top) to start new logical page
const MARGIN_X_MM = 20; // Left margin
const MARGIN_TOP_MM = 20; // Top margin
const UNSUPPORTED_NODES = new Set([
'blockquote',
@@ -85,8 +85,8 @@ export const TEMPLATE_VARIABLES: Array<{ key: string; label: string; example: st
{ key: 'client.email', label: 'Client Email', example: 'john@smithholdings.com' },
{ key: 'client.phone', label: 'Client Phone', example: '+61 400 000 000' },
{ key: 'interest.stage', label: 'Pipeline Stage', example: 'Signed EOI/NDA' },
{ key: 'interest.berthNumber', label: 'Berth Number (from interest)', example: 'A-23' },
{ key: 'berth.mooring_number', label: 'Berth Number', example: 'A-23' },
{ key: 'interest.berthNumber', label: 'Berth Number (from interest)', example: 'A23' },
{ key: 'berth.mooring_number', label: 'Berth Number', example: 'A23' },
{ key: 'berth.price', label: 'Berth Price', example: '$45,000' },
{ key: 'berth.tenure_type', label: 'Tenure Type', example: 'Freehold' },
{ key: 'port.name', label: 'Port Name', example: 'Port Nimara' },
@@ -417,10 +417,7 @@ export function buildTemplateInputs(
* Replaces all {{variable.key}} tokens in a string with values from the data map.
* Unmatched tokens are left as-is.
*/
export function substituteVariables(
text: string,
data: Record<string, string>,
): string {
export function substituteVariables(text: string, data: Record<string, string>): string {
return text.replace(/\{\{([^}]+)\}\}/g, (_match, key: string) => {
return data[key.trim()] ?? _match;
});
@@ -468,10 +465,7 @@ export function tiptapDocumentToTemplateWithData(
/**
* Deeply substitutes variables in all text nodes of a TipTap document.
*/
function substituteInDoc(
node: TipTapNode,
data: Record<string, string>,
): TipTapNode {
function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
if (node.type === 'text' && node.text) {
return { ...node, text: substituteVariables(node.text, data) };
}
@@ -488,10 +482,7 @@ function substituteInDoc(
* Builds pdfme input records by extracting text content from the TipTap doc
* and mapping it to generated field names (in schema order).
*/
function buildContentInputs(
doc: TipTapNode,
template: Template,
): Record<string, string>[] {
function buildContentInputs(doc: TipTapNode, template: Template): Record<string, string>[] {
const textContents = extractAllTextContents(doc);
const schemas = template.schemas;
@@ -501,9 +492,8 @@ function buildContentInputs(
fields.forEach((field, fieldIdx) => {
const globalIdx =
schemas
.slice(0, pageIdx)
.reduce((acc, ps) => acc + (ps as SchemaField[]).length, 0) + fieldIdx;
schemas.slice(0, pageIdx).reduce((acc, ps) => acc + (ps as SchemaField[]).length, 0) +
fieldIdx;
record[field.name] = textContents[globalIdx] ?? '';
});