Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import {
|
|
|
|
|
pgTable,
|
|
|
|
|
text,
|
|
|
|
|
boolean,
|
|
|
|
|
integer,
|
|
|
|
|
numeric,
|
|
|
|
|
timestamp,
|
|
|
|
|
date,
|
|
|
|
|
jsonb,
|
|
|
|
|
index,
|
|
|
|
|
uniqueIndex,
|
|
|
|
|
primaryKey,
|
|
|
|
|
} from 'drizzle-orm/pg-core';
|
|
|
|
|
import { ports } from './ports';
|
|
|
|
|
import { clients } from './clients';
|
|
|
|
|
|
|
|
|
|
export const berths = pgTable(
|
|
|
|
|
'berths',
|
|
|
|
|
{
|
2026-04-23 17:57:29 +02:00
|
|
|
id: text('id')
|
|
|
|
|
.primaryKey()
|
|
|
|
|
.$defaultFn(() => crypto.randomUUID()),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
portId: text('port_id')
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => ports.id),
|
|
|
|
|
mooringNumber: text('mooring_number').notNull(),
|
|
|
|
|
area: text('area'),
|
|
|
|
|
status: text('status').notNull().default('available'), // available, under_offer, sold
|
|
|
|
|
lengthFt: numeric('length_ft'),
|
|
|
|
|
widthFt: numeric('width_ft'),
|
|
|
|
|
draftFt: numeric('draft_ft'),
|
|
|
|
|
lengthM: numeric('length_m'),
|
|
|
|
|
widthM: numeric('width_m'),
|
|
|
|
|
draftM: numeric('draft_m'),
|
|
|
|
|
widthIsMinimum: boolean('width_is_minimum').default(false),
|
feat(berths): full NocoDB field parity, numeric types, sales edit access
Aligns the berths schema with the 117 production rows in NocoDB and exposes
every field for editing via the BerthForm sheet.
Schema (migration 0020):
- power_capacity / voltage / nominal_boat_size / nominal_boat_size_m: text -> numeric
(NocoDB stores plain numbers; text was wrong shape and broke filter/sort)
- ADD status_override_mode text (1/117 legacy rows have a value; carried
forward for parity but not yet wired into the UI)
- USING NULLIF(TRIM(...), '')::numeric so legacy whitespace and empty
strings convert cleanly
Validator + service:
- updateBerthSchema / createBerthSchema use z.coerce.number() for the
four numeric fields
- berths.service stringifies numeric values for Drizzle's numeric type
Form (src/components/berths/berth-form.tsx):
- adds: nominal boat size (ft/m), water depth (ft/m) + "is minimum" flag,
side pontoon, cleat type/capacity, bollard type/capacity, bow facing
- converts to typed selects (with NocoDB option lists in src/lib/constants):
area, side pontoon, mooring type, cleat type/capacity, bollard type/capacity,
access
- power capacity / voltage become numeric inputs (with kW / V hints)
Permissions (seed.ts + dev DB):
- sales_manager and sales_agent: berths.edit false -> true
("sales will sometimes have to update these and I cannot be the only one")
- super_admin / director already had it; viewer stays read-only
- dev DB updated in-place via UPDATE roles ... jsonb_set
Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
on feat/mobile-foundation, none introduced)
- lint clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:30:32 +02:00
|
|
|
// Numeric: ft (legacy NocoDB stored as plain numbers, no units in value).
|
|
|
|
|
nominalBoatSize: numeric('nominal_boat_size'),
|
|
|
|
|
nominalBoatSizeM: numeric('nominal_boat_size_m'),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
waterDepth: numeric('water_depth'),
|
|
|
|
|
waterDepthM: numeric('water_depth_m'),
|
|
|
|
|
waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false),
|
|
|
|
|
sidePontoon: text('side_pontoon'),
|
feat(berths): full NocoDB field parity, numeric types, sales edit access
Aligns the berths schema with the 117 production rows in NocoDB and exposes
every field for editing via the BerthForm sheet.
Schema (migration 0020):
- power_capacity / voltage / nominal_boat_size / nominal_boat_size_m: text -> numeric
(NocoDB stores plain numbers; text was wrong shape and broke filter/sort)
- ADD status_override_mode text (1/117 legacy rows have a value; carried
forward for parity but not yet wired into the UI)
- USING NULLIF(TRIM(...), '')::numeric so legacy whitespace and empty
strings convert cleanly
Validator + service:
- updateBerthSchema / createBerthSchema use z.coerce.number() for the
four numeric fields
- berths.service stringifies numeric values for Drizzle's numeric type
Form (src/components/berths/berth-form.tsx):
- adds: nominal boat size (ft/m), water depth (ft/m) + "is minimum" flag,
side pontoon, cleat type/capacity, bollard type/capacity, bow facing
- converts to typed selects (with NocoDB option lists in src/lib/constants):
area, side pontoon, mooring type, cleat type/capacity, bollard type/capacity,
access
- power capacity / voltage become numeric inputs (with kW / V hints)
Permissions (seed.ts + dev DB):
- sales_manager and sales_agent: berths.edit false -> true
("sales will sometimes have to update these and I cannot be the only one")
- super_admin / director already had it; viewer stays read-only
- dev DB updated in-place via UPDATE roles ... jsonb_set
Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
on feat/mobile-foundation, none introduced)
- lint clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:30:32 +02:00
|
|
|
powerCapacity: numeric('power_capacity'), // kW
|
|
|
|
|
voltage: numeric('voltage'), // V at 60Hz
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
mooringType: text('mooring_type'),
|
|
|
|
|
cleatType: text('cleat_type'),
|
|
|
|
|
cleatCapacity: text('cleat_capacity'),
|
|
|
|
|
bollardType: text('bollard_type'),
|
|
|
|
|
bollardCapacity: text('bollard_capacity'),
|
|
|
|
|
access: text('access'),
|
|
|
|
|
price: numeric('price'),
|
|
|
|
|
priceCurrency: text('price_currency').notNull().default('USD'),
|
2026-05-05 02:00:46 +02:00
|
|
|
// Lease/rental rates surfaced by the per-berth PDFs (Phase 6b). Null
|
|
|
|
|
// until reps upload PDFs; rendered on the berth detail page with a
|
|
|
|
|
// "Pricing data may be stale" chip when pricing_valid_until < today().
|
|
|
|
|
weeklyRateHighUsd: numeric('weekly_rate_high_usd'),
|
|
|
|
|
weeklyRateLowUsd: numeric('weekly_rate_low_usd'),
|
|
|
|
|
dailyRateHighUsd: numeric('daily_rate_high_usd'),
|
|
|
|
|
dailyRateLowUsd: numeric('daily_rate_low_usd'),
|
|
|
|
|
pricingValidUntil: date('pricing_valid_until'),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
bowFacing: text('bow_facing'),
|
|
|
|
|
berthApproved: boolean('berth_approved').default(false),
|
2026-05-05 02:00:46 +02:00
|
|
|
// permanent, fixed_term, fee_simple, strata_lot (the last two map to
|
|
|
|
|
// the Fee Simple / Strata Lot tenures shown in the per-berth PDFs).
|
|
|
|
|
tenureType: text('tenure_type').notNull().default('permanent'),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
tenureYears: integer('tenure_years'),
|
|
|
|
|
tenureStartDate: date('tenure_start_date'),
|
|
|
|
|
tenureEndDate: date('tenure_end_date'),
|
|
|
|
|
statusLastChangedBy: text('status_last_changed_by'), // user ID
|
|
|
|
|
statusLastChangedReason: text('status_last_changed_reason'),
|
|
|
|
|
statusLastModified: timestamp('status_last_modified', { withTimezone: true }),
|
feat(berths): full NocoDB field parity, numeric types, sales edit access
Aligns the berths schema with the 117 production rows in NocoDB and exposes
every field for editing via the BerthForm sheet.
Schema (migration 0020):
- power_capacity / voltage / nominal_boat_size / nominal_boat_size_m: text -> numeric
(NocoDB stores plain numbers; text was wrong shape and broke filter/sort)
- ADD status_override_mode text (1/117 legacy rows have a value; carried
forward for parity but not yet wired into the UI)
- USING NULLIF(TRIM(...), '')::numeric so legacy whitespace and empty
strings convert cleanly
Validator + service:
- updateBerthSchema / createBerthSchema use z.coerce.number() for the
four numeric fields
- berths.service stringifies numeric values for Drizzle's numeric type
Form (src/components/berths/berth-form.tsx):
- adds: nominal boat size (ft/m), water depth (ft/m) + "is minimum" flag,
side pontoon, cleat type/capacity, bollard type/capacity, bow facing
- converts to typed selects (with NocoDB option lists in src/lib/constants):
area, side pontoon, mooring type, cleat type/capacity, bollard type/capacity,
access
- power capacity / voltage become numeric inputs (with kW / V hints)
Permissions (seed.ts + dev DB):
- sales_manager and sales_agent: berths.edit false -> true
("sales will sometimes have to update these and I cannot be the only one")
- super_admin / director already had it; viewer stays read-only
- dev DB updated in-place via UPDATE roles ... jsonb_set
Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
on feat/mobile-foundation, none introduced)
- lint clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:30:32 +02:00
|
|
|
// Optional override flag carried over from NocoDB ("auto" or null in legacy data).
|
|
|
|
|
// Reserved for future "manual override" semantics; not surfaced in the UI today.
|
|
|
|
|
statusOverrideMode: text('status_override_mode'),
|
2026-05-05 02:00:46 +02:00
|
|
|
// Set by scripts/import-berths-from-nocodb.ts. The import compares this
|
|
|
|
|
// against updated_at to detect human edits made after the last import,
|
|
|
|
|
// so re-running the import doesn't clobber CRM-side overrides.
|
|
|
|
|
lastImportedAt: timestamp('last_imported_at', { withTimezone: true }),
|
feat(berths): per-berth PDF storage (versioned) + reverse parser
Phase 6b of the berth-recommender refactor (see
docs/berth-recommender-and-pdf-plan.md §3.2, §3.3, §4.7b, §11.1, §14.6).
Builds on the Phase 6a pluggable storage backend (commit 83693dd) — every
file write goes through `getStorageBackend()`; no direct minio imports.
Schema (migration 0030_berth_pdf_versions):
- new table `berth_pdf_versions` with monotonic `version_number` per
berth, `storage_key` (renamed convention from §4.7a), sha256, size,
`download_url_expires_at` cache slot for §11.1 signed-URL throttling,
and `parse_results` jsonb for the audit trail.
- new column `berths.current_pdf_version_id` (deferred from Phase 0)
with FK to `berth_pdf_versions(id)` ON DELETE SET NULL.
- relations + types exported from `schema/berths.ts`.
3-tier reverse parser (`lib/services/berth-pdf-parser.ts`):
1. AcroForm via pdf-lib — pulls named fields (`length_ft`,
`mooring_number`, etc.) at confidence 1. Sample PDF has 0 such
fields, so this is defensive coverage for future templates.
2. OCR via Tesseract.js — positional/regex heuristics keyed off the
§9.2 layout (Length/Width/Water Depth as `<imperial> / <metric>`,
`WEEK HIGH / LOW`, `CONFIRMED THROUGH UNTIL <date>`, etc.). Returns
per-field confidence + global mean; flags imperial-vs-metric drift
>1% in `warnings`.
3. AI fallback — gated via `getResolvedOcrConfig()` (existing
openai/claude provider). Surfaced from the diff dialog only when
`shouldOfferAiTier()` returns true (mean OCR confidence below
0.55 threshold), so OPENAI_API_KEY isn't burned on every upload.
Service layer (`lib/services/berth-pdf.service.ts`):
- `uploadBerthPdf()` — magic-byte check, size cap, version-number
bump + current pointer in one transaction.
- `reconcilePdfWithBerth()` — auto-applies fields where CRM is null;
flags conflicts when CRM and PDF disagree; tolerates ±1% on numeric
columns; warns on mooring-number-in-PDF mismatch (§14.6).
- `applyParseResults()` — hard allowlist of writable columns;
stamps `appliedFields` onto `parse_results` for audit.
- `rollbackToVersion()` — pointer flip only, never re-parses (§14.6).
- `listBerthPdfVersions()` — version list with 15-min signed URLs.
- `getMaxUploadMb()` — port-override → global → default 15 lookup
on `system_settings.berth_pdf_max_upload_mb`.
§14.6 critical mitigations:
- Magic-byte check (`%PDF-`) on every upload; mismatch deletes the
storage object and rejects the request.
- Size cap from `system_settings.berth_pdf_max_upload_mb` (default
15 MB); enforced in the upload-url presign AND server-side.
- 0-byte uploads rejected.
- Mooring-number mismatch surfaces as a `warnings[]` entry on the
reconcile result so the rep sees it in the diff dialog.
- Imperial vs metric ±1% tolerance in both the parser warnings and
the reconcile equality check.
- Path traversal already blocked at the storage layer (Phase 6a).
API + UI:
- `POST /api/v1/berths/[id]/pdf-upload-url` — presigned URL (S3) or
HMAC-signed proxy URL (filesystem) sized to the per-port cap.
- `POST /api/v1/berths/[id]/pdf-versions` — verifies the upload via
`backend.head()`, writes the row, bumps `current_pdf_version_id`.
- `GET /api/v1/berths/[id]/pdf-versions` — version list + signed URLs.
- `POST /api/v1/berths/[id]/pdf-versions/[versionId]/rollback`.
- `POST /api/v1/berths/[id]/pdf-versions/parse-results/apply` —
rep-confirmed diff payload.
- New "Documents" tab on the berth detail page (`berth-tabs.tsx`)
with current-PDF panel, version history, Replace PDF button, and
`<PdfReconcileDialog>` for the auto-applied + conflicts UX.
System settings:
- `berth_pdf_max_upload_mb` (default 15) — caps presigned-upload size
+ server-side validation. Resolved port-override → global → default.
Tests:
- `tests/unit/services/berth-pdf-parser.test.ts` — magic bytes,
feet-inches, human dates, full §9.2-shaped OCR text → 18 fields,
drift warning, AI-tier gate.
- `tests/unit/services/berth-pdf-acroform.test.ts` — synthetic
pdf-lib AcroForm round-trip.
- `tests/integration/berth-pdf-versions.test.ts` — upload, version-
number bump, magic-byte rejection, reconcile auto-applied vs
conflicts vs ±1% tolerance, mooring-number warning,
applyParseResults allowlist enforcement, rollback semantics.
Acceptance: `pnpm exec tsc --noEmit` clean, `pnpm exec vitest run`
green at 1103/1103.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:34:24 +02:00
|
|
|
// Pointer to the active per-berth PDF version (Phase 6b). Null until a
|
|
|
|
|
// rep uploads the first PDF; a later rollback can re-target this column
|
|
|
|
|
// to any prior `berth_pdf_versions.id`. The full history lives in the
|
|
|
|
|
// junction table — this column is just the "current" pointer.
|
|
|
|
|
currentPdfVersionId: text('current_pdf_version_id'),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
|
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
|
|
|
},
|
|
|
|
|
(table) => [
|
|
|
|
|
index('idx_berths_port').on(table.portId),
|
|
|
|
|
index('idx_berths_status').on(table.portId, table.status),
|
|
|
|
|
index('idx_berths_area').on(table.portId, table.area),
|
|
|
|
|
uniqueIndex('idx_berths_mooring').on(table.portId, table.mooringNumber),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-05 04:20:38 +02:00
|
|
|
// Note: `berths.current_pdf_version_id` has an `ON DELETE SET NULL` FK to
|
|
|
|
|
// `berth_pdf_versions.id` installed by migration 0030. The column is left
|
|
|
|
|
// without a `.references()` / `foreignKey()` declaration in the Drizzle
|
|
|
|
|
// schema because the two tables form a circular FK (berth_pdf_versions →
|
|
|
|
|
// berths), and Drizzle's relation inference doesn't tolerate the cycle
|
|
|
|
|
// when both sides are declared via column-level `.references()`. The
|
|
|
|
|
// migration chain authoritatively maintains the constraint; a fresh
|
|
|
|
|
// `db:push` against an empty DB would skip the FK and require a follow-up
|
|
|
|
|
// generated migration to add it back. This is acceptable because we
|
|
|
|
|
// always apply migrations in order in dev/CI/prod.
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
export const berthMapData = pgTable(
|
|
|
|
|
'berth_map_data',
|
|
|
|
|
{
|
2026-04-23 17:57:29 +02:00
|
|
|
id: text('id')
|
|
|
|
|
.primaryKey()
|
|
|
|
|
.$defaultFn(() => crypto.randomUUID()),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
berthId: text('berth_id')
|
|
|
|
|
.notNull()
|
|
|
|
|
.unique()
|
|
|
|
|
.references(() => berths.id, { onDelete: 'cascade' }),
|
|
|
|
|
svgPath: text('svg_path'),
|
|
|
|
|
x: numeric('x'),
|
|
|
|
|
y: numeric('y'),
|
|
|
|
|
transform: text('transform'),
|
|
|
|
|
fontSize: numeric('font_size'),
|
|
|
|
|
extraData: jsonb('extra_data').default({}),
|
|
|
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
|
|
|
},
|
|
|
|
|
(table) => [uniqueIndex('berth_map_data_berth_id_idx').on(table.berthId)],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const berthRecommendations = pgTable(
|
|
|
|
|
'berth_recommendations',
|
|
|
|
|
{
|
2026-04-23 17:57:29 +02:00
|
|
|
id: text('id')
|
|
|
|
|
.primaryKey()
|
|
|
|
|
.$defaultFn(() => crypto.randomUUID()),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
interestId: text('interest_id').notNull(), // references interests.id
|
|
|
|
|
berthId: text('berth_id')
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => berths.id, { onDelete: 'cascade' }),
|
|
|
|
|
matchScore: numeric('match_score'), // 0-100
|
|
|
|
|
matchReasons: jsonb('match_reasons'), // { "dimensional_fit": 95, "power_match": 80, ... }
|
|
|
|
|
source: text('source').notNull().default('ai'), // ai, manual
|
|
|
|
|
createdBy: text('created_by'), // user ID for manual recommendations
|
|
|
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
|
|
|
},
|
|
|
|
|
(table) => [
|
|
|
|
|
uniqueIndex('berth_rec_interest_berth_idx').on(table.interestId, table.berthId),
|
|
|
|
|
index('idx_br_interest').on(table.interestId),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const berthWaitingList = pgTable(
|
|
|
|
|
'berth_waiting_list',
|
|
|
|
|
{
|
2026-04-23 17:57:29 +02:00
|
|
|
id: text('id')
|
|
|
|
|
.primaryKey()
|
|
|
|
|
.$defaultFn(() => crypto.randomUUID()),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
berthId: text('berth_id')
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => berths.id, { onDelete: 'cascade' }),
|
|
|
|
|
clientId: text('client_id')
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
2026-04-23 17:57:29 +02:00
|
|
|
yachtId: text('yacht_id'), // FK added via relation; nullable (waiting for this yacht)
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
position: integer('position').notNull(),
|
|
|
|
|
priority: text('priority').notNull().default('normal'), // normal, high
|
|
|
|
|
notifyPref: text('notify_pref').default('email'), // email, in_app, both
|
|
|
|
|
notes: text('notes'),
|
|
|
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
|
|
|
},
|
|
|
|
|
(table) => [
|
|
|
|
|
uniqueIndex('berth_waiting_list_berth_client_idx').on(table.berthId, table.clientId),
|
|
|
|
|
index('idx_bwl_berth').on(table.berthId, table.position),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const berthMaintenanceLog = pgTable(
|
|
|
|
|
'berth_maintenance_log',
|
|
|
|
|
{
|
2026-04-23 17:57:29 +02:00
|
|
|
id: text('id')
|
|
|
|
|
.primaryKey()
|
|
|
|
|
.$defaultFn(() => crypto.randomUUID()),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
berthId: text('berth_id')
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => berths.id, { onDelete: 'cascade' }),
|
|
|
|
|
portId: text('port_id')
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => ports.id),
|
|
|
|
|
category: text('category').notNull(), // routine, repair, inspection, upgrade
|
|
|
|
|
description: text('description').notNull(),
|
|
|
|
|
cost: numeric('cost'),
|
|
|
|
|
costCurrency: text('cost_currency').default('USD'),
|
|
|
|
|
responsibleParty: text('responsible_party'),
|
|
|
|
|
performedDate: date('performed_date').notNull(),
|
|
|
|
|
photoFileIds: text('photo_file_ids').array(), // references to files table
|
|
|
|
|
createdBy: text('created_by').notNull(),
|
|
|
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
|
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
|
|
|
},
|
2026-04-23 17:57:29 +02:00
|
|
|
(table) => [index('idx_bml_berth').on(table.berthId), index('idx_bml_port').on(table.portId)],
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
);
|
|
|
|
|
|
feat(berths): per-berth PDF storage (versioned) + reverse parser
Phase 6b of the berth-recommender refactor (see
docs/berth-recommender-and-pdf-plan.md §3.2, §3.3, §4.7b, §11.1, §14.6).
Builds on the Phase 6a pluggable storage backend (commit 83693dd) — every
file write goes through `getStorageBackend()`; no direct minio imports.
Schema (migration 0030_berth_pdf_versions):
- new table `berth_pdf_versions` with monotonic `version_number` per
berth, `storage_key` (renamed convention from §4.7a), sha256, size,
`download_url_expires_at` cache slot for §11.1 signed-URL throttling,
and `parse_results` jsonb for the audit trail.
- new column `berths.current_pdf_version_id` (deferred from Phase 0)
with FK to `berth_pdf_versions(id)` ON DELETE SET NULL.
- relations + types exported from `schema/berths.ts`.
3-tier reverse parser (`lib/services/berth-pdf-parser.ts`):
1. AcroForm via pdf-lib — pulls named fields (`length_ft`,
`mooring_number`, etc.) at confidence 1. Sample PDF has 0 such
fields, so this is defensive coverage for future templates.
2. OCR via Tesseract.js — positional/regex heuristics keyed off the
§9.2 layout (Length/Width/Water Depth as `<imperial> / <metric>`,
`WEEK HIGH / LOW`, `CONFIRMED THROUGH UNTIL <date>`, etc.). Returns
per-field confidence + global mean; flags imperial-vs-metric drift
>1% in `warnings`.
3. AI fallback — gated via `getResolvedOcrConfig()` (existing
openai/claude provider). Surfaced from the diff dialog only when
`shouldOfferAiTier()` returns true (mean OCR confidence below
0.55 threshold), so OPENAI_API_KEY isn't burned on every upload.
Service layer (`lib/services/berth-pdf.service.ts`):
- `uploadBerthPdf()` — magic-byte check, size cap, version-number
bump + current pointer in one transaction.
- `reconcilePdfWithBerth()` — auto-applies fields where CRM is null;
flags conflicts when CRM and PDF disagree; tolerates ±1% on numeric
columns; warns on mooring-number-in-PDF mismatch (§14.6).
- `applyParseResults()` — hard allowlist of writable columns;
stamps `appliedFields` onto `parse_results` for audit.
- `rollbackToVersion()` — pointer flip only, never re-parses (§14.6).
- `listBerthPdfVersions()` — version list with 15-min signed URLs.
- `getMaxUploadMb()` — port-override → global → default 15 lookup
on `system_settings.berth_pdf_max_upload_mb`.
§14.6 critical mitigations:
- Magic-byte check (`%PDF-`) on every upload; mismatch deletes the
storage object and rejects the request.
- Size cap from `system_settings.berth_pdf_max_upload_mb` (default
15 MB); enforced in the upload-url presign AND server-side.
- 0-byte uploads rejected.
- Mooring-number mismatch surfaces as a `warnings[]` entry on the
reconcile result so the rep sees it in the diff dialog.
- Imperial vs metric ±1% tolerance in both the parser warnings and
the reconcile equality check.
- Path traversal already blocked at the storage layer (Phase 6a).
API + UI:
- `POST /api/v1/berths/[id]/pdf-upload-url` — presigned URL (S3) or
HMAC-signed proxy URL (filesystem) sized to the per-port cap.
- `POST /api/v1/berths/[id]/pdf-versions` — verifies the upload via
`backend.head()`, writes the row, bumps `current_pdf_version_id`.
- `GET /api/v1/berths/[id]/pdf-versions` — version list + signed URLs.
- `POST /api/v1/berths/[id]/pdf-versions/[versionId]/rollback`.
- `POST /api/v1/berths/[id]/pdf-versions/parse-results/apply` —
rep-confirmed diff payload.
- New "Documents" tab on the berth detail page (`berth-tabs.tsx`)
with current-PDF panel, version history, Replace PDF button, and
`<PdfReconcileDialog>` for the auto-applied + conflicts UX.
System settings:
- `berth_pdf_max_upload_mb` (default 15) — caps presigned-upload size
+ server-side validation. Resolved port-override → global → default.
Tests:
- `tests/unit/services/berth-pdf-parser.test.ts` — magic bytes,
feet-inches, human dates, full §9.2-shaped OCR text → 18 fields,
drift warning, AI-tier gate.
- `tests/unit/services/berth-pdf-acroform.test.ts` — synthetic
pdf-lib AcroForm round-trip.
- `tests/integration/berth-pdf-versions.test.ts` — upload, version-
number bump, magic-byte rejection, reconcile auto-applied vs
conflicts vs ±1% tolerance, mooring-number warning,
applyParseResults allowlist enforcement, rollback semantics.
Acceptance: `pnpm exec tsc --noEmit` clean, `pnpm exec vitest run`
green at 1103/1103.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:34:24 +02:00
|
|
|
/**
|
|
|
|
|
* Per-berth PDF version history (Phase 6b — see plan §3.3 / §4.7b).
|
|
|
|
|
*
|
|
|
|
|
* Each upload creates a new row with a monotonic `versionNumber` per berth.
|
|
|
|
|
* The active version is referenced by `berths.current_pdf_version_id`. The
|
|
|
|
|
* storage_key points at the file in the active `StorageBackend` (s3/filesystem),
|
|
|
|
|
* which is resolved at access time via `getStorageBackend()`.
|
|
|
|
|
*
|
|
|
|
|
* `parseResults` captures what the 3-tier reverse parser extracted at upload
|
|
|
|
|
* time plus any conflicts the rep resolved in the diff dialog. Kept as audit
|
|
|
|
|
* trail; rolling back to a prior version does NOT replay these (per §14.6).
|
|
|
|
|
*/
|
|
|
|
|
export const berthPdfVersions = pgTable(
|
|
|
|
|
'berth_pdf_versions',
|
|
|
|
|
{
|
|
|
|
|
id: text('id')
|
|
|
|
|
.primaryKey()
|
|
|
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
|
|
|
berthId: text('berth_id')
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => berths.id, { onDelete: 'cascade' }),
|
|
|
|
|
versionNumber: integer('version_number').notNull(),
|
|
|
|
|
/** Object key in the active storage backend (renamed from `s3_key` per §4.7a). */
|
|
|
|
|
storageKey: text('storage_key').notNull(),
|
|
|
|
|
fileName: text('file_name').notNull(),
|
|
|
|
|
fileSizeBytes: integer('file_size_bytes').notNull(),
|
|
|
|
|
contentSha256: text('content_sha256').notNull(),
|
|
|
|
|
uploadedBy: text('uploaded_by').notNull(),
|
|
|
|
|
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow(),
|
|
|
|
|
/** Cached signed-URL expiry per §11.1 — re-sign only when within 1h of expiry. */
|
|
|
|
|
downloadUrlExpiresAt: timestamp('download_url_expires_at', { withTimezone: true }),
|
|
|
|
|
/** { engine: 'acroform'|'ocr'|'ai', extracted: {...}, conflicts: [...], appliedFields: [...] } */
|
|
|
|
|
parseResults: jsonb('parse_results'),
|
|
|
|
|
},
|
|
|
|
|
(table) => [
|
|
|
|
|
uniqueIndex('berth_pdf_versions_berth_version_idx').on(table.berthId, table.versionNumber),
|
|
|
|
|
index('idx_bpv_berth').on(table.berthId, table.uploadedAt),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
export const berthTags = pgTable(
|
|
|
|
|
'berth_tags',
|
|
|
|
|
{
|
|
|
|
|
berthId: text('berth_id')
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => berths.id, { onDelete: 'cascade' }),
|
|
|
|
|
tagId: text('tag_id').notNull(), // references tags.id
|
|
|
|
|
},
|
|
|
|
|
(table) => [primaryKey({ columns: [table.berthId, table.tagId] })],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export type Berth = typeof berths.$inferSelect;
|
|
|
|
|
export type NewBerth = typeof berths.$inferInsert;
|
|
|
|
|
export type BerthMapData = typeof berthMapData.$inferSelect;
|
|
|
|
|
export type NewBerthMapData = typeof berthMapData.$inferInsert;
|
|
|
|
|
export type BerthRecommendation = typeof berthRecommendations.$inferSelect;
|
|
|
|
|
export type NewBerthRecommendation = typeof berthRecommendations.$inferInsert;
|
|
|
|
|
export type BerthWaitingList = typeof berthWaitingList.$inferSelect;
|
|
|
|
|
export type NewBerthWaitingList = typeof berthWaitingList.$inferInsert;
|
|
|
|
|
export type BerthMaintenanceLog = typeof berthMaintenanceLog.$inferSelect;
|
|
|
|
|
export type NewBerthMaintenanceLog = typeof berthMaintenanceLog.$inferInsert;
|
feat(berths): per-berth PDF storage (versioned) + reverse parser
Phase 6b of the berth-recommender refactor (see
docs/berth-recommender-and-pdf-plan.md §3.2, §3.3, §4.7b, §11.1, §14.6).
Builds on the Phase 6a pluggable storage backend (commit 83693dd) — every
file write goes through `getStorageBackend()`; no direct minio imports.
Schema (migration 0030_berth_pdf_versions):
- new table `berth_pdf_versions` with monotonic `version_number` per
berth, `storage_key` (renamed convention from §4.7a), sha256, size,
`download_url_expires_at` cache slot for §11.1 signed-URL throttling,
and `parse_results` jsonb for the audit trail.
- new column `berths.current_pdf_version_id` (deferred from Phase 0)
with FK to `berth_pdf_versions(id)` ON DELETE SET NULL.
- relations + types exported from `schema/berths.ts`.
3-tier reverse parser (`lib/services/berth-pdf-parser.ts`):
1. AcroForm via pdf-lib — pulls named fields (`length_ft`,
`mooring_number`, etc.) at confidence 1. Sample PDF has 0 such
fields, so this is defensive coverage for future templates.
2. OCR via Tesseract.js — positional/regex heuristics keyed off the
§9.2 layout (Length/Width/Water Depth as `<imperial> / <metric>`,
`WEEK HIGH / LOW`, `CONFIRMED THROUGH UNTIL <date>`, etc.). Returns
per-field confidence + global mean; flags imperial-vs-metric drift
>1% in `warnings`.
3. AI fallback — gated via `getResolvedOcrConfig()` (existing
openai/claude provider). Surfaced from the diff dialog only when
`shouldOfferAiTier()` returns true (mean OCR confidence below
0.55 threshold), so OPENAI_API_KEY isn't burned on every upload.
Service layer (`lib/services/berth-pdf.service.ts`):
- `uploadBerthPdf()` — magic-byte check, size cap, version-number
bump + current pointer in one transaction.
- `reconcilePdfWithBerth()` — auto-applies fields where CRM is null;
flags conflicts when CRM and PDF disagree; tolerates ±1% on numeric
columns; warns on mooring-number-in-PDF mismatch (§14.6).
- `applyParseResults()` — hard allowlist of writable columns;
stamps `appliedFields` onto `parse_results` for audit.
- `rollbackToVersion()` — pointer flip only, never re-parses (§14.6).
- `listBerthPdfVersions()` — version list with 15-min signed URLs.
- `getMaxUploadMb()` — port-override → global → default 15 lookup
on `system_settings.berth_pdf_max_upload_mb`.
§14.6 critical mitigations:
- Magic-byte check (`%PDF-`) on every upload; mismatch deletes the
storage object and rejects the request.
- Size cap from `system_settings.berth_pdf_max_upload_mb` (default
15 MB); enforced in the upload-url presign AND server-side.
- 0-byte uploads rejected.
- Mooring-number mismatch surfaces as a `warnings[]` entry on the
reconcile result so the rep sees it in the diff dialog.
- Imperial vs metric ±1% tolerance in both the parser warnings and
the reconcile equality check.
- Path traversal already blocked at the storage layer (Phase 6a).
API + UI:
- `POST /api/v1/berths/[id]/pdf-upload-url` — presigned URL (S3) or
HMAC-signed proxy URL (filesystem) sized to the per-port cap.
- `POST /api/v1/berths/[id]/pdf-versions` — verifies the upload via
`backend.head()`, writes the row, bumps `current_pdf_version_id`.
- `GET /api/v1/berths/[id]/pdf-versions` — version list + signed URLs.
- `POST /api/v1/berths/[id]/pdf-versions/[versionId]/rollback`.
- `POST /api/v1/berths/[id]/pdf-versions/parse-results/apply` —
rep-confirmed diff payload.
- New "Documents" tab on the berth detail page (`berth-tabs.tsx`)
with current-PDF panel, version history, Replace PDF button, and
`<PdfReconcileDialog>` for the auto-applied + conflicts UX.
System settings:
- `berth_pdf_max_upload_mb` (default 15) — caps presigned-upload size
+ server-side validation. Resolved port-override → global → default.
Tests:
- `tests/unit/services/berth-pdf-parser.test.ts` — magic bytes,
feet-inches, human dates, full §9.2-shaped OCR text → 18 fields,
drift warning, AI-tier gate.
- `tests/unit/services/berth-pdf-acroform.test.ts` — synthetic
pdf-lib AcroForm round-trip.
- `tests/integration/berth-pdf-versions.test.ts` — upload, version-
number bump, magic-byte rejection, reconcile auto-applied vs
conflicts vs ±1% tolerance, mooring-number warning,
applyParseResults allowlist enforcement, rollback semantics.
Acceptance: `pnpm exec tsc --noEmit` clean, `pnpm exec vitest run`
green at 1103/1103.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:34:24 +02:00
|
|
|
export type BerthPdfVersion = typeof berthPdfVersions.$inferSelect;
|
|
|
|
|
export type NewBerthPdfVersion = typeof berthPdfVersions.$inferInsert;
|