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:
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* One-shot: load the 117-berth NocoDB snapshot into the port-nimara
|
||||
* port, skipping any moorings that already exist.
|
||||
*
|
||||
* The original seed only seeded 12 hand-rolled berths into port-nimara
|
||||
* (A-01..D-03), but the migration's interest rows reference moorings
|
||||
* across A-01..E-18. This loads the full set so interest→berth links
|
||||
* resolve cleanly on the next migration run.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { eq, and, sql, inArray } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import berthSnapshot from '@/lib/db/seed-data/berths.json';
|
||||
|
||||
interface SnapshotBerth {
|
||||
mooringNumber: string;
|
||||
area: string;
|
||||
status: 'available' | 'under_offer' | 'sold';
|
||||
lengthFt: number | null;
|
||||
widthFt: number | null;
|
||||
draftFt: number | null;
|
||||
lengthM: number | null;
|
||||
widthM: number | null;
|
||||
draftM: number | null;
|
||||
widthIsMinimum: boolean;
|
||||
nominalBoatSize: number | null;
|
||||
nominalBoatSizeM: number | null;
|
||||
waterDepth: number | null;
|
||||
waterDepthM: number | null;
|
||||
waterDepthIsMinimum: boolean;
|
||||
sidePontoon: string | null;
|
||||
powerCapacity: number | null;
|
||||
voltage: number | null;
|
||||
mooringType: string | null;
|
||||
cleatType: string | null;
|
||||
cleatCapacity: string | null;
|
||||
bollardType: string | null;
|
||||
bollardCapacity: string | null;
|
||||
access: string | null;
|
||||
price: number | null;
|
||||
bowFacing: string | null;
|
||||
berthApproved: boolean;
|
||||
statusOverrideMode: string | null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [port] = await db
|
||||
.select({ id: ports.id })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, 'port-nimara'))
|
||||
.limit(1);
|
||||
if (!port) throw new Error('port-nimara not found');
|
||||
|
||||
const snapshot = berthSnapshot as unknown as SnapshotBerth[];
|
||||
|
||||
// Existing moorings — skip these.
|
||||
const existingRows = await db
|
||||
.select({ mooringNumber: berths.mooringNumber })
|
||||
.from(berths)
|
||||
.where(eq(berths.portId, port.id));
|
||||
const existingMoorings = new Set(existingRows.map((r) => r.mooringNumber));
|
||||
|
||||
const toInsert = snapshot.filter((b) => !existingMoorings.has(b.mooringNumber));
|
||||
console.log(
|
||||
`Snapshot: ${snapshot.length} berths, existing in port-nimara: ${existingRows.length}, to insert: ${toInsert.length}`,
|
||||
);
|
||||
|
||||
if (toInsert.length === 0) {
|
||||
console.log('Nothing to do.');
|
||||
return;
|
||||
}
|
||||
|
||||
const inserted = await db
|
||||
.insert(berths)
|
||||
.values(
|
||||
toInsert.map((b) => ({
|
||||
portId: port.id,
|
||||
mooringNumber: b.mooringNumber,
|
||||
area: b.area,
|
||||
status: b.status,
|
||||
lengthFt: b.lengthFt != null ? String(b.lengthFt) : null,
|
||||
widthFt: b.widthFt != null ? String(b.widthFt) : null,
|
||||
draftFt: b.draftFt != null ? String(b.draftFt) : null,
|
||||
lengthM: b.lengthM != null ? String(b.lengthM) : null,
|
||||
widthM: b.widthM != null ? String(b.widthM) : null,
|
||||
draftM: b.draftM != null ? String(b.draftM) : null,
|
||||
widthIsMinimum: b.widthIsMinimum,
|
||||
nominalBoatSize: b.nominalBoatSize != null ? String(b.nominalBoatSize) : null,
|
||||
nominalBoatSizeM: b.nominalBoatSizeM != null ? String(b.nominalBoatSizeM) : null,
|
||||
waterDepth: b.waterDepth != null ? String(b.waterDepth) : null,
|
||||
waterDepthM: b.waterDepthM != null ? String(b.waterDepthM) : null,
|
||||
waterDepthIsMinimum: b.waterDepthIsMinimum,
|
||||
sidePontoon: b.sidePontoon,
|
||||
powerCapacity: b.powerCapacity != null ? String(b.powerCapacity) : null,
|
||||
voltage: b.voltage != null ? String(b.voltage) : null,
|
||||
mooringType: b.mooringType,
|
||||
cleatType: b.cleatType,
|
||||
cleatCapacity: b.cleatCapacity,
|
||||
bollardType: b.bollardType,
|
||||
bollardCapacity: b.bollardCapacity,
|
||||
access: b.access,
|
||||
price: b.price != null ? String(b.price) : null,
|
||||
priceCurrency: 'USD',
|
||||
bowFacing: b.bowFacing,
|
||||
berthApproved: b.berthApproved,
|
||||
statusOverrideMode: b.statusOverrideMode,
|
||||
tenureType: 'permanent' as const,
|
||||
})),
|
||||
)
|
||||
.returning({ id: berths.id, mooringNumber: berths.mooringNumber });
|
||||
|
||||
console.log(`Inserted ${inserted.length} berths.`);
|
||||
|
||||
// Suppress unused-import warning if eslint is strict.
|
||||
void and;
|
||||
void sql;
|
||||
void inArray;
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
31
src/lib/db/migrations/0024_normalize_mooring_numbers.sql
Normal file
31
src/lib/db/migrations/0024_normalize_mooring_numbers.sql
Normal 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 $$;
|
||||
10639
src/lib/db/migrations/meta/0024_snapshot.json
Normal file
10639
src/lib/db/migrations/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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] ?? '';
|
||||
});
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ export const USERS = {
|
||||
async function signUpUser(email: string, password: string, name: string) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': BASE,
|
||||
'Referer': `${BASE}/`,
|
||||
Origin: BASE,
|
||||
Referer: `${BASE}/`,
|
||||
};
|
||||
|
||||
const res = await fetch(`${BASE}/api/auth/sign-up/email`, {
|
||||
@@ -95,11 +95,7 @@ setup('seed test database', async () => {
|
||||
);
|
||||
console.log(` ✓ sales_agent created: ${agentId}`);
|
||||
|
||||
const viewerId = await signUpUser(
|
||||
USERS.viewer.email,
|
||||
USERS.viewer.password,
|
||||
USERS.viewer.name,
|
||||
);
|
||||
const viewerId = await signUpUser(USERS.viewer.email, USERS.viewer.password, USERS.viewer.name);
|
||||
console.log(` ✓ viewer created: ${viewerId}`);
|
||||
|
||||
// 2. Get portId and roleIds from seed data
|
||||
@@ -158,17 +154,17 @@ setup('seed test database', async () => {
|
||||
console.log('🔧 Seeding berths...');
|
||||
await runSQL(`
|
||||
INSERT INTO berths (id, port_id, mooring_number, area, status, length_ft, width_ft, price, tenure_type)
|
||||
SELECT gen_random_uuid()::text, p.id, 'A-001', 'Marina A', 'available', '60', '20', '150000', 'permanent'
|
||||
SELECT gen_random_uuid()::text, p.id, 'A1', 'Marina A', 'available', '60', '20', '150000', 'permanent'
|
||||
FROM ports p WHERE p.slug = 'port-nimara'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO berths (id, port_id, mooring_number, area, status, length_ft, width_ft, price, tenure_type)
|
||||
SELECT gen_random_uuid()::text, p.id, 'A-002', 'Marina A', 'available', '80', '25', '250000', 'permanent'
|
||||
SELECT gen_random_uuid()::text, p.id, 'A2', 'Marina A', 'available', '80', '25', '250000', 'permanent'
|
||||
FROM ports p WHERE p.slug = 'port-nimara'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO berths (id, port_id, mooring_number, area, status, length_ft, width_ft, price, tenure_type)
|
||||
SELECT gen_random_uuid()::text, p.id, 'B-001', 'Marina B', 'under_offer', '45', '15', '95000', 'fixed_term'
|
||||
SELECT gen_random_uuid()::text, p.id, 'B1', 'Marina B', 'under_offer', '45', '15', '95000', 'fixed_term'
|
||||
FROM ports p WHERE p.slug = 'port-nimara'
|
||||
ON CONFLICT DO NOTHING;
|
||||
`);
|
||||
|
||||
@@ -291,7 +291,7 @@ describe('resolveTemplate — company-owned yacht', () => {
|
||||
});
|
||||
const berth = await makeBerth({
|
||||
portId: port.id,
|
||||
overrides: { mooringNumber: 'B-7' },
|
||||
overrides: { mooringNumber: 'B7' },
|
||||
});
|
||||
const [interest] = await db
|
||||
.insert(interestsTable)
|
||||
|
||||
@@ -62,7 +62,7 @@ function makeContext(overrides: Partial<EoiContext> = {}): EoiContext {
|
||||
company: null,
|
||||
owner: { type: 'client', name: 'Alice Smith' },
|
||||
berth: {
|
||||
mooringNumber: 'A-12',
|
||||
mooringNumber: 'A12',
|
||||
area: 'North',
|
||||
lengthFt: '50',
|
||||
price: '1000',
|
||||
@@ -93,7 +93,7 @@ describe('fillEoiFormFields', () => {
|
||||
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.getTextField('Berth Number').getText()).toBe('A12');
|
||||
|
||||
expect(form.getCheckBox('Purchase').isChecked()).toBe(true);
|
||||
expect(form.getCheckBox('Lease_10').isChecked()).toBe(false);
|
||||
|
||||
@@ -27,7 +27,7 @@ function makeContext(overrides?: Partial<EoiContext>): EoiContext {
|
||||
company: null,
|
||||
owner: { type: 'client', name: 'Alice Smith' },
|
||||
berth: {
|
||||
mooringNumber: 'A-12',
|
||||
mooringNumber: 'A12',
|
||||
area: 'North Dock',
|
||||
lengthFt: '50',
|
||||
price: '1200',
|
||||
@@ -77,7 +77,7 @@ describe('buildDocumensoPayload', () => {
|
||||
Length: '45',
|
||||
Width: '14',
|
||||
Draft: '6',
|
||||
'Berth Number': 'A-12',
|
||||
'Berth Number': 'A12',
|
||||
Lease_10: false,
|
||||
Purchase: true,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user