Files
pn-new-crm/src/lib/services/berths.service.ts

811 lines
27 KiB
TypeScript
Raw Normal View History

fix(P1): soft-archive berths instead of hard-delete — F5 Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which permanently dropped the row, cascade-vanished `interest_berths` links, broke historical audit references, and could 404 the public feed mid- customer-inquiry. The `berths.archived_at` column existed in the schema but was never written. Changes: - `archiveBerth(id, portId, { reason }, meta)` is the new canonical soft-archive. Requires a reason (min 5 chars). Blocks when an active interest still depends on the berth (forces the rep to resolve the deal first). Audit-logs the old status + reason. - `restoreBerth(...)` reverses it. - DELETE route now accepts `{ reason }` and routes to archiveBerth. - New POST /api/v1/berths/[id]/restore. - `getBerthOptions` + dashboard occupancy / status-distribution queries gain `isNull(berths.archivedAt)` so archived moorings don't show up in pickers or skew metrics. - Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth so import sites we haven't migrated still work — labeled @deprecated. Verified live: - DELETE w/o reason → 400 (validation) - DELETE w/ "x" → 400 "Reason must be ≥ 5 characters" - DELETE w/ proper reason → 204, row archived, reason persisted - DELETE twice → 409 "Berth is already archived" - POST /restore → 204, archived_at cleared Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts, alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules- engine.ts. The current set covers the visible surfaces; the rest are secondary aggregators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
import { and, eq, gte, lte, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
import { clients } from '@/lib/db/schema/clients';
feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units Berth surfaces - New compact mooring-chip header (colored plate + status pill, dock-label in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack - Berth list gains a "Latest deal stage" column showing the most-advanced pipeline stage of any active linked interest (server-aggregated, ranks by PIPELINE_STAGES index) - "Linked prospect" Select on the status-change dialog rebuilt as a Command combobox: search, recent-first sort, stage-coloured pills Pipeline UX - Reverting an interest to Open with linked berths now prompts: keep the links, unlink and reset, or cancel. Silent when no berths are linked - Activity feed + entity-activity feed normalise enum field values via STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as "10% Deposit → Contract Sent" EOI generate dialog - Inline-editable rows for client name, nationality (country combobox), and yacht name — pencil affordance saves directly via clients/yachts PATCH - Replaces the single "Edit on client's page" link with two contextual links framed by short copy explaining what's inline vs what needs the canonical page - Backend EoiContext now includes client.id + yacht.id so the dialog can PATCH without an extra round-trip Company form - New "Connections" section lets the rep attach members (clients) and yachts during create. Yacht attach uses the existing transfer endpoint so audit log + ownership history capture the change - Inline "+ New client" / "+ New yacht" buttons open the canonical forms stacked over the company sheet - After save, the form chains to a yacht pull-in prompt (if any attached client owns yachts not yet linked) and an optional "Create interest" step pre-filled with the first attached client Admin - /admin landing gains a searchable index — typed query flattens groups into a result list matching label + description + group title - "Documenso & EOI" card relabelled to "EOI signing service" (consistent with the user-facing language rename from round 1) Measurement units (migration 0053) - interests gains desired_*_m columns + desired_*_unit discriminators so the rep's literal entry (ft OR m) is preserved verbatim instead of being reconstructed from a single canonical column on every render - yachts + berths gain matching *_unit columns alongside their existing ft + m pairs; defaults to 'ft' so legacy rows still render normally - Interest form POST/PATCH now sends both ft + m + unit; computed m is derived from the ft canonical to keep the recommender SQL unchanged Misc - Active-deals tile + topbar type their Link href as `Route` instead of `any` - Unused REPORT_TYPE_LABELS const dropped from generate-report-form - Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated to include the new id + unit fields on the EoiContext / Berth shapes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
import { interestBerths, interests } from '@/lib/db/schema/interests';
import { tags } from '@/lib/db/schema/system';
feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units Berth surfaces - New compact mooring-chip header (colored plate + status pill, dock-label in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack - Berth list gains a "Latest deal stage" column showing the most-advanced pipeline stage of any active linked interest (server-aggregated, ranks by PIPELINE_STAGES index) - "Linked prospect" Select on the status-change dialog rebuilt as a Command combobox: search, recent-first sort, stage-coloured pills Pipeline UX - Reverting an interest to Open with linked berths now prompts: keep the links, unlink and reset, or cancel. Silent when no berths are linked - Activity feed + entity-activity feed normalise enum field values via STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as "10% Deposit → Contract Sent" EOI generate dialog - Inline-editable rows for client name, nationality (country combobox), and yacht name — pencil affordance saves directly via clients/yachts PATCH - Replaces the single "Edit on client's page" link with two contextual links framed by short copy explaining what's inline vs what needs the canonical page - Backend EoiContext now includes client.id + yacht.id so the dialog can PATCH without an extra round-trip Company form - New "Connections" section lets the rep attach members (clients) and yachts during create. Yacht attach uses the existing transfer endpoint so audit log + ownership history capture the change - Inline "+ New client" / "+ New yacht" buttons open the canonical forms stacked over the company sheet - After save, the form chains to a yacht pull-in prompt (if any attached client owns yachts not yet linked) and an optional "Create interest" step pre-filled with the first attached client Admin - /admin landing gains a searchable index — typed query flattens groups into a result list matching label + description + group title - "Documenso & EOI" card relabelled to "EOI signing service" (consistent with the user-facing language rename from round 1) Measurement units (migration 0053) - interests gains desired_*_m columns + desired_*_unit discriminators so the rep's literal entry (ft OR m) is preserved verbatim instead of being reconstructed from a single canonical column on every render - yachts + berths gain matching *_unit columns alongside their existing ft + m pairs; defaults to 'ft' so legacy rows still render normally - Interest form POST/PATCH now sends both ft + m + unit; computed m is derived from the ft canonical to keep the recommender SQL unchanged Misc - Active-deals tile + topbar type their Link href as `Route` instead of `any` - Unused REPORT_TYPE_LABELS const dropped from generate-report-form - Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated to include the new id + unit fields on the EoiContext / Berth shapes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
import { PIPELINE_STAGES } from '@/lib/constants';
fix(audit-wave-10): types-auditor fixes — Tx type, BerthDetailData, parseBody, toAuditJson Address the CRITICAL + high-leverage HIGH items from the types-auditor: **C1 — `tx: any` in client-restore.service** Export a canonical `Tx` type from `lib/db/utils.ts` (derived from Drizzle's `db.transaction` callback shape) and use it in `applyReversal` so the 12+ downstream tx writes get full inference. **C2 — berth-detail page stacked `useQuery<any>` escape hatches** Export `BerthDetailData` from berth-detail-header and consume it through useQuery + apiFetch. Removed three `any` escapes in the highest-traffic detail page. Also collapsed the duplicate `BerthData` in berth-tabs.tsx to import from berth-detail-header so the two types can't drift. **C3 — parseBody migration for portal/public routes** Replace raw `await req.json() + schema.parse(body)` with the project-standard `parseBody(req, schema)` helper across 7 routes: - portal/auth/{change-password, activate, reset-password} - auth/set-password - public/{interests, residential-inquiries} Skipped the three anti-enumeration routes (forgot-password, sign-in, sign-in-by-identifier) where the manual validation gives opaque errors on purpose. website-inquiries already wraps the parse in a custom 400 — left as-is. **HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)** Introduce `toAuditJson<T extends object>(row: T): Record<string, unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow` that already exists for the same reason). Codemod 21 `<row> as unknown as Record<string, unknown>` sites across: - invoices.ts × 6 - expenses.ts × 6 - berths.service × 2 - documents.service × 2 - ocr-config.service × 2 - ai-budget.service × 2 - yachts.service, companies.service, company-memberships.service × 1 each document-templates' `payload as unknown as Record<...>` is a different shape (Documenso form-values widening, not an audit log) — kept the manual cast there. Tests stay 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:27:08 +02:00
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
feat(reporting): money-math sweep — Step 1 PRE-DEPLOY-PLAN 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>
2026-05-14 15:19:38 +02:00
import { activeInterestsWhere } from '@/lib/services/active-interest';
import { diffEntity } from '@/lib/entity-diff';
fix(P1): soft-archive berths instead of hard-delete — F5 Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which permanently dropped the row, cascade-vanished `interest_berths` links, broke historical audit references, and could 404 the public feed mid- customer-inquiry. The `berths.archived_at` column existed in the schema but was never written. Changes: - `archiveBerth(id, portId, { reason }, meta)` is the new canonical soft-archive. Requires a reason (min 5 chars). Blocks when an active interest still depends on the berth (forces the rep to resolve the deal first). Audit-logs the old status + reason. - `restoreBerth(...)` reverses it. - DELETE route now accepts `{ reason }` and routes to archiveBerth. - New POST /api/v1/berths/[id]/restore. - `getBerthOptions` + dashboard occupancy / status-distribution queries gain `isNull(berths.archivedAt)` so archived moorings don't show up in pickers or skew metrics. - Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth so import sites we haven't migrated still work — labeled @deprecated. Verified live: - DELETE w/o reason → 400 (validation) - DELETE w/ "x" → 400 "Reason must be ≥ 5 characters" - DELETE w/ proper reason → 204, row archived, reason persisted - DELETE twice → 409 "Berth is already archived" - POST /restore → 204, archived_at cleared Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts, alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules- engine.ts. The current set covers the visible surfaces; the rest are secondary aggregators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { buildListQuery } from '@/lib/db/query-builder';
import { emitToRoom } from '@/lib/socket/server';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones Mobile + responsive - berth-form full-width on phones (was 480px fixed → overflowed iPhone) - currency-input switched to inputMode=decimal with live thousands separator - client-form Country/Timezone/Source/Preferred-Contact full-width <sm - contacts row restructured so Primary toggle + Remove get their own strip - customize-dashboard footer stacks vertically on mobile; Done full-width - interest-form client/berth pickers no longer cmdk-filter on UUID (typing "Carlos" now returns Carlos Vega instead of "No clients found") Data + consistency - SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces now resolve interest/client source from one place - INTEREST_OUTCOMES adds lost_other (picker, badge, timeline) - Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort - archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles - TableBody last-row uses border-b-0 (not border-0); colored left-accent on the bottom berth row now renders - Hide Invite-to-Portal until port setting === true (was !== false default-show) - OwnerPicker primer query resolves entity name on first paint (no more UUID flash before the popover opens) Terminology - Replaced user-facing "Documenso" with "signing service" / "Generated EOI" / "Manual EOI" in 8 components (admin/internal references kept) - Plainer status-change copy on berth-detail-header Forms + editing - InlineEditableField gained a `date` variant (native picker); applied to company incorporation date and ready for other YYYY-MM-DD plaintext fields - Inline source picker on interest-tabs detail (was free text) - TagPicker self-hides when port has no tags AND nothing is selected - New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom) - Compose dialog follow-up is now a toggle that reveals datetime picker Pipeline milestones - changeStageSchema accepts optional milestoneDate; service stamps it on the matching date column instead of always using now - MilestoneAdvanceButton popover collects a back-date before stage advance - Applied to every "Mark X manually" surface on the interest overview EOI / linked-berths polish - Add-bypass row aligned inline with toggle descriptions - Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their legal vs. public-map consequences Surfaces - Companies list now has the column picker + persisted hidden-column prefs - NotesList aggregate flag enabled on clients, companies, residential_clients (yachts already aggregated) ft/m unit toggle (interim, before drift fix) - "Berth size desired" gets a section-level ft/m toggle; per-field hint shows the converted value. Storage stays canonical-ft for now; the drift-safe persistence migration is the next step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:50:58 +02:00
import { getPortBerthsDefaultCurrency } from '@/lib/services/port-config';
import { sortByMooring } from '@/lib/utils/mooring-sort';
import type {
CreateBerthInput,
UpdateBerthInput,
UpdateBerthStatusInput,
ListBerthsQuery,
AddMaintenanceLogInput,
UpdateWaitingListInput,
} from '@/lib/validators/berths';
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listBerths(portId: string, query: ListBerthsQuery) {
const filters = [];
if (query.status) {
filters.push(eq(berths.status, query.status));
}
if (query.area) {
filters.push(eq(berths.area, query.area));
}
if (query.minLength !== undefined) {
filters.push(gte(berths.lengthM, String(query.minLength)));
}
if (query.maxLength !== undefined) {
filters.push(lte(berths.lengthM, String(query.maxLength)));
}
if (query.minPrice !== undefined) {
filters.push(gte(berths.price, String(query.minPrice)));
}
if (query.maxPrice !== undefined) {
filters.push(lte(berths.price, String(query.maxPrice)));
}
if (query.tenureType) {
filters.push(eq(berths.tenureType, query.tenureType));
}
// Tag filter: join against berthTags
if (query.tagIds && query.tagIds.length > 0) {
const tagIds = query.tagIds;
const berthsWithTags = await db
.selectDistinct({ berthId: berthTags.berthId })
.from(berthTags)
.where(inArray(berthTags.tagId, tagIds));
const matchingIds = berthsWithTags.map((r) => r.berthId);
if (matchingIds.length === 0) {
return { data: [], total: 0 };
}
filters.push(inArray(berths.id, matchingIds));
}
feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul Major interest workflow expansion driven by the rapid-fire UX session. EOI / Contract / Reservation tabs replace the generic Documents tab when the deal is at the relevant stage — workspace pattern with active-doc hero, signing progress, paper-signed upload, and history strip. Stage- conditional visibility wired through interest-tabs.tsx so the tab set shrinks/expands as the deal moves through the pipeline. Contact log: per-interaction structured log (channel/direction/summary/ optional follow-up reminder). New `interest_contact_log` table + service + tab UI (timeline with channel-coded icons + compose dialog). auto-creates a reminder when followUpAt is set. Berth Interest milestone: first milestone in the OverviewTab's pipeline strip, completes the moment any berth is linked via the junction. Drives the "have we captured what they want?" sanity check for general_interest leads before they move to EOI. Stage-conditional milestones: past phases collapse into a one-liner strip, current phase expands, future phases hide behind a "Show upcoming" toggle. Inline stage picker now defers reason capture to an override-confirm view (only required for illegal transitions, not the default flow). Notes blob → threaded: dropped `interests.notes` column entirely; the threaded `interest_notes` table is the single source of truth. Latest- note teaser on Overview links into the dedicated Notes tab. Polymorphic notes service gains aggregated client view (unions client + interest + yacht notes with source chips and group-by-source toggle). Berth interest list overhaul: - Configurable columns via ColumnPicker (18 toggleable, 5 default-on) - Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2) - Per-letter row tinting via colored left-border accent + dot in cell - Documents tab merged Files (single attachments section) Topbar improvements: - Always-visible back arrow on detail pages (path depth > 2) - Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can push their entity hierarchy (Clients › Mary Smith › Interest › B17) - Tighter spacing, softer separators, 160px crumb truncation DataTable upgrades: - Page-size selector with All option (validator cap raised to 1000) - getRowClassName slot for per-row styling (used by berth tinting) - Fixed Radix SelectItem crash on empty-string values via __any__ sentinel (was crashing every list page that opened a select filter) Interest list: - Configurable columns picker - Stage cell clickable into detail - TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons - Save view moved into ColumnPicker menu; Views button hidden when no views are saved - Pipeline kanban board endpoint at /api/v1/interests/board with minimal projection, 5000-row cap + truncated banner, filter pass-through Mobile chrome + sidebar collapse removed (always-expanded design choice). User management lists super-admins (was inner-joined on user_port_roles which excluded global super-admins). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:59:28 +02:00
// Default ordering is natural alphanumeric on mooring number
// (A1, A2, A10, B1...) — Postgres' default lexicographic sort
// would put A10 before A2, which is the wrong story for a marina
// map. The mooring format is locked at `^[A-Z]+\d+$` so the regexp
// splits are safe.
const NATURAL_MOORING_SORT = [
sql`regexp_replace(${berths.mooringNumber}, '[0-9]+$', '') ASC`,
feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul Major interest workflow expansion driven by the rapid-fire UX session. EOI / Contract / Reservation tabs replace the generic Documents tab when the deal is at the relevant stage — workspace pattern with active-doc hero, signing progress, paper-signed upload, and history strip. Stage- conditional visibility wired through interest-tabs.tsx so the tab set shrinks/expands as the deal moves through the pipeline. Contact log: per-interaction structured log (channel/direction/summary/ optional follow-up reminder). New `interest_contact_log` table + service + tab UI (timeline with channel-coded icons + compose dialog). auto-creates a reminder when followUpAt is set. Berth Interest milestone: first milestone in the OverviewTab's pipeline strip, completes the moment any berth is linked via the junction. Drives the "have we captured what they want?" sanity check for general_interest leads before they move to EOI. Stage-conditional milestones: past phases collapse into a one-liner strip, current phase expands, future phases hide behind a "Show upcoming" toggle. Inline stage picker now defers reason capture to an override-confirm view (only required for illegal transitions, not the default flow). Notes blob → threaded: dropped `interests.notes` column entirely; the threaded `interest_notes` table is the single source of truth. Latest- note teaser on Overview links into the dedicated Notes tab. Polymorphic notes service gains aggregated client view (unions client + interest + yacht notes with source chips and group-by-source toggle). Berth interest list overhaul: - Configurable columns via ColumnPicker (18 toggleable, 5 default-on) - Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2) - Per-letter row tinting via colored left-border accent + dot in cell - Documents tab merged Files (single attachments section) Topbar improvements: - Always-visible back arrow on detail pages (path depth > 2) - Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can push their entity hierarchy (Clients › Mary Smith › Interest › B17) - Tighter spacing, softer separators, 160px crumb truncation DataTable upgrades: - Page-size selector with All option (validator cap raised to 1000) - getRowClassName slot for per-row styling (used by berth tinting) - Fixed Radix SelectItem crash on empty-string values via __any__ sentinel (was crashing every list page that opened a select filter) Interest list: - Configurable columns picker - Stage cell clickable into detail - TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons - Save view moved into ColumnPicker menu; Views button hidden when no views are saved - Pipeline kanban board endpoint at /api/v1/interests/board with minimal projection, 5000-row cap + truncated banner, filter pass-through Mobile chrome + sidebar collapse removed (always-expanded design choice). User management lists super-admins (was inner-joined on user_port_roles which excluded global super-admins). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:59:28 +02:00
sql`(regexp_replace(${berths.mooringNumber}, '^[A-Z]+', ''))::int ASC`,
];
const sortColumn = (() => {
switch (query.sort) {
case 'mooringNumber':
feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul Major interest workflow expansion driven by the rapid-fire UX session. EOI / Contract / Reservation tabs replace the generic Documents tab when the deal is at the relevant stage — workspace pattern with active-doc hero, signing progress, paper-signed upload, and history strip. Stage- conditional visibility wired through interest-tabs.tsx so the tab set shrinks/expands as the deal moves through the pipeline. Contact log: per-interaction structured log (channel/direction/summary/ optional follow-up reminder). New `interest_contact_log` table + service + tab UI (timeline with channel-coded icons + compose dialog). auto-creates a reminder when followUpAt is set. Berth Interest milestone: first milestone in the OverviewTab's pipeline strip, completes the moment any berth is linked via the junction. Drives the "have we captured what they want?" sanity check for general_interest leads before they move to EOI. Stage-conditional milestones: past phases collapse into a one-liner strip, current phase expands, future phases hide behind a "Show upcoming" toggle. Inline stage picker now defers reason capture to an override-confirm view (only required for illegal transitions, not the default flow). Notes blob → threaded: dropped `interests.notes` column entirely; the threaded `interest_notes` table is the single source of truth. Latest- note teaser on Overview links into the dedicated Notes tab. Polymorphic notes service gains aggregated client view (unions client + interest + yacht notes with source chips and group-by-source toggle). Berth interest list overhaul: - Configurable columns via ColumnPicker (18 toggleable, 5 default-on) - Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2) - Per-letter row tinting via colored left-border accent + dot in cell - Documents tab merged Files (single attachments section) Topbar improvements: - Always-visible back arrow on detail pages (path depth > 2) - Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can push their entity hierarchy (Clients › Mary Smith › Interest › B17) - Tighter spacing, softer separators, 160px crumb truncation DataTable upgrades: - Page-size selector with All option (validator cap raised to 1000) - getRowClassName slot for per-row styling (used by berth tinting) - Fixed Radix SelectItem crash on empty-string values via __any__ sentinel (was crashing every list page that opened a select filter) Interest list: - Configurable columns picker - Stage cell clickable into detail - TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons - Save view moved into ColumnPicker menu; Views button hidden when no views are saved - Pipeline kanban board endpoint at /api/v1/interests/board with minimal projection, 5000-row cap + truncated banner, filter pass-through Mobile chrome + sidebar collapse removed (always-expanded design choice). User management lists super-admins (was inner-joined on user_port_roles which excluded global super-admins). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:59:28 +02:00
// Honoured via customOrderBy below — caller asked for mooring
// sort explicitly, give them the natural order.
return null;
case 'area':
return berths.area;
case 'price':
return berths.price;
case 'status':
return berths.status;
case 'lengthM':
return berths.lengthM;
default:
feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul Major interest workflow expansion driven by the rapid-fire UX session. EOI / Contract / Reservation tabs replace the generic Documents tab when the deal is at the relevant stage — workspace pattern with active-doc hero, signing progress, paper-signed upload, and history strip. Stage- conditional visibility wired through interest-tabs.tsx so the tab set shrinks/expands as the deal moves through the pipeline. Contact log: per-interaction structured log (channel/direction/summary/ optional follow-up reminder). New `interest_contact_log` table + service + tab UI (timeline with channel-coded icons + compose dialog). auto-creates a reminder when followUpAt is set. Berth Interest milestone: first milestone in the OverviewTab's pipeline strip, completes the moment any berth is linked via the junction. Drives the "have we captured what they want?" sanity check for general_interest leads before they move to EOI. Stage-conditional milestones: past phases collapse into a one-liner strip, current phase expands, future phases hide behind a "Show upcoming" toggle. Inline stage picker now defers reason capture to an override-confirm view (only required for illegal transitions, not the default flow). Notes blob → threaded: dropped `interests.notes` column entirely; the threaded `interest_notes` table is the single source of truth. Latest- note teaser on Overview links into the dedicated Notes tab. Polymorphic notes service gains aggregated client view (unions client + interest + yacht notes with source chips and group-by-source toggle). Berth interest list overhaul: - Configurable columns via ColumnPicker (18 toggleable, 5 default-on) - Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2) - Per-letter row tinting via colored left-border accent + dot in cell - Documents tab merged Files (single attachments section) Topbar improvements: - Always-visible back arrow on detail pages (path depth > 2) - Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can push their entity hierarchy (Clients › Mary Smith › Interest › B17) - Tighter spacing, softer separators, 160px crumb truncation DataTable upgrades: - Page-size selector with All option (validator cap raised to 1000) - getRowClassName slot for per-row styling (used by berth tinting) - Fixed Radix SelectItem crash on empty-string values via __any__ sentinel (was crashing every list page that opened a select filter) Interest list: - Configurable columns picker - Stage cell clickable into detail - TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons - Save view moved into ColumnPicker menu; Views button hidden when no views are saved - Pipeline kanban board endpoint at /api/v1/interests/board with minimal projection, 5000-row cap + truncated banner, filter pass-through Mobile chrome + sidebar collapse removed (always-expanded design choice). User management lists super-admins (was inner-joined on user_port_roles which excluded global super-admins). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:59:28 +02:00
// No sort requested → natural mooring order is the friendliest
// default for the berth grid (groups by pontoon letter).
return null;
}
})();
const result = await buildListQuery({
table: berths,
portIdColumn: berths.portId,
portId,
idColumn: berths.id,
updatedAtColumn: berths.updatedAt,
filters,
feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul Major interest workflow expansion driven by the rapid-fire UX session. EOI / Contract / Reservation tabs replace the generic Documents tab when the deal is at the relevant stage — workspace pattern with active-doc hero, signing progress, paper-signed upload, and history strip. Stage- conditional visibility wired through interest-tabs.tsx so the tab set shrinks/expands as the deal moves through the pipeline. Contact log: per-interaction structured log (channel/direction/summary/ optional follow-up reminder). New `interest_contact_log` table + service + tab UI (timeline with channel-coded icons + compose dialog). auto-creates a reminder when followUpAt is set. Berth Interest milestone: first milestone in the OverviewTab's pipeline strip, completes the moment any berth is linked via the junction. Drives the "have we captured what they want?" sanity check for general_interest leads before they move to EOI. Stage-conditional milestones: past phases collapse into a one-liner strip, current phase expands, future phases hide behind a "Show upcoming" toggle. Inline stage picker now defers reason capture to an override-confirm view (only required for illegal transitions, not the default flow). Notes blob → threaded: dropped `interests.notes` column entirely; the threaded `interest_notes` table is the single source of truth. Latest- note teaser on Overview links into the dedicated Notes tab. Polymorphic notes service gains aggregated client view (unions client + interest + yacht notes with source chips and group-by-source toggle). Berth interest list overhaul: - Configurable columns via ColumnPicker (18 toggleable, 5 default-on) - Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2) - Per-letter row tinting via colored left-border accent + dot in cell - Documents tab merged Files (single attachments section) Topbar improvements: - Always-visible back arrow on detail pages (path depth > 2) - Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can push their entity hierarchy (Clients › Mary Smith › Interest › B17) - Tighter spacing, softer separators, 160px crumb truncation DataTable upgrades: - Page-size selector with All option (validator cap raised to 1000) - getRowClassName slot for per-row styling (used by berth tinting) - Fixed Radix SelectItem crash on empty-string values via __any__ sentinel (was crashing every list page that opened a select filter) Interest list: - Configurable columns picker - Stage cell clickable into detail - TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons - Save view moved into ColumnPicker menu; Views button hidden when no views are saved - Pipeline kanban board endpoint at /api/v1/interests/board with minimal projection, 5000-row cap + truncated banner, filter pass-through Mobile chrome + sidebar collapse removed (always-expanded design choice). User management lists super-admins (was inner-joined on user_port_roles which excluded global super-admins). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:59:28 +02:00
sort: sortColumn ? { column: sortColumn, direction: query.order } : undefined,
customOrderBy: sortColumn ? undefined : NATURAL_MOORING_SORT,
page: query.page,
pageSize: query.limit,
searchColumns: [berths.mooringNumber, berths.area],
searchTerm: query.search,
feat(schema): berths.archived_at + clients.source_inquiry_id + email_bounces Step 3 schema additions per PRE-DEPLOY-PLAN § 1.4. berths.archived_at (+ archived_by, archive_reason) — soft-delete column so retired moorings can be hidden from the public feed and admin lists without losing historical interest joins. Partial index `idx_berths_active` on (port_id) WHERE archived_at IS NULL keeps the active-only list path fast. Already wired: - /api/public/berths and /api/public/berths/[mooringNumber] now filter out archived rows. - berths.service.listBerths defaults to active-only with an ?includeArchived=true escape hatch for the archive bin. clients.source_inquiry_id — text column with ON DELETE SET NULL FK to website_submissions(id). Preserves the linkage from a website inquiry to the client that came out of the "Convert to client" triage flow (P-4.5). Drives the conversion-funnel-by-source chart (Step 6). The Drizzle column ships without `.references()` to avoid the cross-file circular import; the FK lives in the migration SQL. email_bounces table — bounce-monitoring storage. The DSN poller worker (forthcoming, depends on this table existing) writes one row per parsed bounce; consumers join via (original_send_type, original_send_id). Three secondary indexes cover the expected access patterns (port + recent bounces; lookup by bounced address; lookup by original send). Schema additions plus the migration SQL are ready for `pnpm db:push` (or the migration runner once its journal is backfilled — separate concern, journal currently stops at 0042 despite migrations through 0065 existing on disk). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:33:20 +02:00
// berths.archivedAt + ?includeArchived flag landed in migration 0065.
// Default the admin list to active-only; an `?includeArchived=true`
// query string surfaces the archive bin for ops.
archivedAtColumn: berths.archivedAt,
includeArchived: Boolean(query.includeArchived),
});
// Attach tags for list items
const berthIds = (result.data as Array<{ id: string }>).map((b) => b.id);
const tagsByBerthId: Record<string, Array<{ id: string; name: string; color: string }>> = {};
if (berthIds.length > 0) {
const tagRows = await db
.select({
berthId: berthTags.berthId,
id: tags.id,
name: tags.name,
color: tags.color,
})
.from(berthTags)
.innerJoin(tags, eq(berthTags.tagId, tags.id))
.where(inArray(berthTags.berthId, berthIds));
for (const row of tagRows) {
if (!tagsByBerthId[row.berthId]) tagsByBerthId[row.berthId] = [];
tagsByBerthId[row.berthId]!.push({ id: row.id, name: row.name, color: row.color });
}
}
feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units Berth surfaces - New compact mooring-chip header (colored plate + status pill, dock-label in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack - Berth list gains a "Latest deal stage" column showing the most-advanced pipeline stage of any active linked interest (server-aggregated, ranks by PIPELINE_STAGES index) - "Linked prospect" Select on the status-change dialog rebuilt as a Command combobox: search, recent-first sort, stage-coloured pills Pipeline UX - Reverting an interest to Open with linked berths now prompts: keep the links, unlink and reset, or cancel. Silent when no berths are linked - Activity feed + entity-activity feed normalise enum field values via STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as "10% Deposit → Contract Sent" EOI generate dialog - Inline-editable rows for client name, nationality (country combobox), and yacht name — pencil affordance saves directly via clients/yachts PATCH - Replaces the single "Edit on client's page" link with two contextual links framed by short copy explaining what's inline vs what needs the canonical page - Backend EoiContext now includes client.id + yacht.id so the dialog can PATCH without an extra round-trip Company form - New "Connections" section lets the rep attach members (clients) and yachts during create. Yacht attach uses the existing transfer endpoint so audit log + ownership history capture the change - Inline "+ New client" / "+ New yacht" buttons open the canonical forms stacked over the company sheet - After save, the form chains to a yacht pull-in prompt (if any attached client owns yachts not yet linked) and an optional "Create interest" step pre-filled with the first attached client Admin - /admin landing gains a searchable index — typed query flattens groups into a result list matching label + description + group title - "Documenso & EOI" card relabelled to "EOI signing service" (consistent with the user-facing language rename from round 1) Measurement units (migration 0053) - interests gains desired_*_m columns + desired_*_unit discriminators so the rep's literal entry (ft OR m) is preserved verbatim instead of being reconstructed from a single canonical column on every render - yachts + berths gain matching *_unit columns alongside their existing ft + m pairs; defaults to 'ft' so legacy rows still render normally - Interest form POST/PATCH now sends both ft + m + unit; computed m is derived from the ft canonical to keep the recommender SQL unchanged Misc - Active-deals tile + topbar type their Link href as `Route` instead of `any` - Unused REPORT_TYPE_LABELS const dropped from generate-report-form - Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated to include the new id + unit fields on the EoiContext / Berth shapes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
const latestStageByBerthId = await getLatestInterestStageByBerth(berthIds, portId);
const data = (result.data as Array<Record<string, unknown>>).map((b) => ({
...b,
tags: tagsByBerthId[b.id as string] ?? [],
feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units Berth surfaces - New compact mooring-chip header (colored plate + status pill, dock-label in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack - Berth list gains a "Latest deal stage" column showing the most-advanced pipeline stage of any active linked interest (server-aggregated, ranks by PIPELINE_STAGES index) - "Linked prospect" Select on the status-change dialog rebuilt as a Command combobox: search, recent-first sort, stage-coloured pills Pipeline UX - Reverting an interest to Open with linked berths now prompts: keep the links, unlink and reset, or cancel. Silent when no berths are linked - Activity feed + entity-activity feed normalise enum field values via STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as "10% Deposit → Contract Sent" EOI generate dialog - Inline-editable rows for client name, nationality (country combobox), and yacht name — pencil affordance saves directly via clients/yachts PATCH - Replaces the single "Edit on client's page" link with two contextual links framed by short copy explaining what's inline vs what needs the canonical page - Backend EoiContext now includes client.id + yacht.id so the dialog can PATCH without an extra round-trip Company form - New "Connections" section lets the rep attach members (clients) and yachts during create. Yacht attach uses the existing transfer endpoint so audit log + ownership history capture the change - Inline "+ New client" / "+ New yacht" buttons open the canonical forms stacked over the company sheet - After save, the form chains to a yacht pull-in prompt (if any attached client owns yachts not yet linked) and an optional "Create interest" step pre-filled with the first attached client Admin - /admin landing gains a searchable index — typed query flattens groups into a result list matching label + description + group title - "Documenso & EOI" card relabelled to "EOI signing service" (consistent with the user-facing language rename from round 1) Measurement units (migration 0053) - interests gains desired_*_m columns + desired_*_unit discriminators so the rep's literal entry (ft OR m) is preserved verbatim instead of being reconstructed from a single canonical column on every render - yachts + berths gain matching *_unit columns alongside their existing ft + m pairs; defaults to 'ft' so legacy rows still render normally - Interest form POST/PATCH now sends both ft + m + unit; computed m is derived from the ft canonical to keep the recommender SQL unchanged Misc - Active-deals tile + topbar type their Link href as `Route` instead of `any` - Unused REPORT_TYPE_LABELS const dropped from generate-report-form - Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated to include the new id + unit fields on the EoiContext / Berth shapes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
latestInterestStage: latestStageByBerthId[b.id as string] ?? null,
}));
return { data, total: result.total };
}
feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units Berth surfaces - New compact mooring-chip header (colored plate + status pill, dock-label in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack - Berth list gains a "Latest deal stage" column showing the most-advanced pipeline stage of any active linked interest (server-aggregated, ranks by PIPELINE_STAGES index) - "Linked prospect" Select on the status-change dialog rebuilt as a Command combobox: search, recent-first sort, stage-coloured pills Pipeline UX - Reverting an interest to Open with linked berths now prompts: keep the links, unlink and reset, or cancel. Silent when no berths are linked - Activity feed + entity-activity feed normalise enum field values via STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as "10% Deposit → Contract Sent" EOI generate dialog - Inline-editable rows for client name, nationality (country combobox), and yacht name — pencil affordance saves directly via clients/yachts PATCH - Replaces the single "Edit on client's page" link with two contextual links framed by short copy explaining what's inline vs what needs the canonical page - Backend EoiContext now includes client.id + yacht.id so the dialog can PATCH without an extra round-trip Company form - New "Connections" section lets the rep attach members (clients) and yachts during create. Yacht attach uses the existing transfer endpoint so audit log + ownership history capture the change - Inline "+ New client" / "+ New yacht" buttons open the canonical forms stacked over the company sheet - After save, the form chains to a yacht pull-in prompt (if any attached client owns yachts not yet linked) and an optional "Create interest" step pre-filled with the first attached client Admin - /admin landing gains a searchable index — typed query flattens groups into a result list matching label + description + group title - "Documenso & EOI" card relabelled to "EOI signing service" (consistent with the user-facing language rename from round 1) Measurement units (migration 0053) - interests gains desired_*_m columns + desired_*_unit discriminators so the rep's literal entry (ft OR m) is preserved verbatim instead of being reconstructed from a single canonical column on every render - yachts + berths gain matching *_unit columns alongside their existing ft + m pairs; defaults to 'ft' so legacy rows still render normally - Interest form POST/PATCH now sends both ft + m + unit; computed m is derived from the ft canonical to keep the recommender SQL unchanged Misc - Active-deals tile + topbar type their Link href as `Route` instead of `any` - Unused REPORT_TYPE_LABELS const dropped from generate-report-form - Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated to include the new id + unit fields on the EoiContext / Berth shapes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
/**
* For each berth id, returns the most-advanced pipeline stage among its
* linked active interests (outcome IS NULL, not archived). Used by the
* berth list + detail to surface the deal furthest along on a berth so
* reps can see at a glance whether a berth is "Reservation Sent" via
* its connected interest, even though berth.status only tracks
* available/under_offer/sold.
*/
async function getLatestInterestStageByBerth(
berthIds: string[],
portId: string,
): Promise<Record<string, string>> {
if (berthIds.length === 0) return {};
const rows = await db
.select({
berthId: interestBerths.berthId,
pipelineStage: interests.pipelineStage,
})
.from(interestBerths)
.innerJoin(interests, eq(interestBerths.interestId, interests.id))
feat(reporting): money-math sweep — Step 1 PRE-DEPLOY-PLAN 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>
2026-05-14 15:19:38 +02:00
.where(and(activeInterestsWhere(portId), inArray(interestBerths.berthId, berthIds)));
feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units Berth surfaces - New compact mooring-chip header (colored plate + status pill, dock-label in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack - Berth list gains a "Latest deal stage" column showing the most-advanced pipeline stage of any active linked interest (server-aggregated, ranks by PIPELINE_STAGES index) - "Linked prospect" Select on the status-change dialog rebuilt as a Command combobox: search, recent-first sort, stage-coloured pills Pipeline UX - Reverting an interest to Open with linked berths now prompts: keep the links, unlink and reset, or cancel. Silent when no berths are linked - Activity feed + entity-activity feed normalise enum field values via STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as "10% Deposit → Contract Sent" EOI generate dialog - Inline-editable rows for client name, nationality (country combobox), and yacht name — pencil affordance saves directly via clients/yachts PATCH - Replaces the single "Edit on client's page" link with two contextual links framed by short copy explaining what's inline vs what needs the canonical page - Backend EoiContext now includes client.id + yacht.id so the dialog can PATCH without an extra round-trip Company form - New "Connections" section lets the rep attach members (clients) and yachts during create. Yacht attach uses the existing transfer endpoint so audit log + ownership history capture the change - Inline "+ New client" / "+ New yacht" buttons open the canonical forms stacked over the company sheet - After save, the form chains to a yacht pull-in prompt (if any attached client owns yachts not yet linked) and an optional "Create interest" step pre-filled with the first attached client Admin - /admin landing gains a searchable index — typed query flattens groups into a result list matching label + description + group title - "Documenso & EOI" card relabelled to "EOI signing service" (consistent with the user-facing language rename from round 1) Measurement units (migration 0053) - interests gains desired_*_m columns + desired_*_unit discriminators so the rep's literal entry (ft OR m) is preserved verbatim instead of being reconstructed from a single canonical column on every render - yachts + berths gain matching *_unit columns alongside their existing ft + m pairs; defaults to 'ft' so legacy rows still render normally - Interest form POST/PATCH now sends both ft + m + unit; computed m is derived from the ft canonical to keep the recommender SQL unchanged Misc - Active-deals tile + topbar type their Link href as `Route` instead of `any` - Unused REPORT_TYPE_LABELS const dropped from generate-report-form - Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated to include the new id + unit fields on the EoiContext / Berth shapes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
// Pipeline stages are an ordered enum — rank by position in PIPELINE_STAGES
// so "contract_signed" beats "eoi_sent". Falls back to 0 for any unknown
// legacy values so they're treated as least-advanced.
const rankOf = (stage: string) => {
const idx = (PIPELINE_STAGES as readonly string[]).indexOf(stage);
return idx === -1 ? -1 : idx;
};
const top: Record<string, string> = {};
for (const row of rows) {
const current = top[row.berthId];
if (!current || rankOf(row.pipelineStage) > rankOf(current)) {
top[row.berthId] = row.pipelineStage;
}
}
return top;
}
// ─── Get By ID ────────────────────────────────────────────────────────────────
export async function getBerthById(id: string, portId: string) {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, portId)),
with: {
mapData: true,
},
});
if (!berth) throw new NotFoundError('Berth');
// Fetch tags
const tagRows = await db
.select({ id: tags.id, name: tags.name, color: tags.color })
.from(berthTags)
.innerJoin(tags, eq(berthTags.tagId, tags.id))
.where(eq(berthTags.berthId, id));
feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units Berth surfaces - New compact mooring-chip header (colored plate + status pill, dock-label in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack - Berth list gains a "Latest deal stage" column showing the most-advanced pipeline stage of any active linked interest (server-aggregated, ranks by PIPELINE_STAGES index) - "Linked prospect" Select on the status-change dialog rebuilt as a Command combobox: search, recent-first sort, stage-coloured pills Pipeline UX - Reverting an interest to Open with linked berths now prompts: keep the links, unlink and reset, or cancel. Silent when no berths are linked - Activity feed + entity-activity feed normalise enum field values via STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as "10% Deposit → Contract Sent" EOI generate dialog - Inline-editable rows for client name, nationality (country combobox), and yacht name — pencil affordance saves directly via clients/yachts PATCH - Replaces the single "Edit on client's page" link with two contextual links framed by short copy explaining what's inline vs what needs the canonical page - Backend EoiContext now includes client.id + yacht.id so the dialog can PATCH without an extra round-trip Company form - New "Connections" section lets the rep attach members (clients) and yachts during create. Yacht attach uses the existing transfer endpoint so audit log + ownership history capture the change - Inline "+ New client" / "+ New yacht" buttons open the canonical forms stacked over the company sheet - After save, the form chains to a yacht pull-in prompt (if any attached client owns yachts not yet linked) and an optional "Create interest" step pre-filled with the first attached client Admin - /admin landing gains a searchable index — typed query flattens groups into a result list matching label + description + group title - "Documenso & EOI" card relabelled to "EOI signing service" (consistent with the user-facing language rename from round 1) Measurement units (migration 0053) - interests gains desired_*_m columns + desired_*_unit discriminators so the rep's literal entry (ft OR m) is preserved verbatim instead of being reconstructed from a single canonical column on every render - yachts + berths gain matching *_unit columns alongside their existing ft + m pairs; defaults to 'ft' so legacy rows still render normally - Interest form POST/PATCH now sends both ft + m + unit; computed m is derived from the ft canonical to keep the recommender SQL unchanged Misc - Active-deals tile + topbar type their Link href as `Route` instead of `any` - Unused REPORT_TYPE_LABELS const dropped from generate-report-form - Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated to include the new id + unit fields on the EoiContext / Berth shapes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
const latestStageMap = await getLatestInterestStageByBerth([id], portId);
return {
...berth,
tags: tagRows,
latestInterestStage: latestStageMap[id] ?? null,
};
}
// ─── Update ───────────────────────────────────────────────────────────────────
export async function updateBerth(
id: string,
portId: string,
data: UpdateBerthInput,
meta: AuditMeta,
) {
const existing = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, portId)),
});
if (!existing) throw new NotFoundError('Berth');
const { changed, diff } = diffEntity(
existing as Record<string, unknown>,
data as Record<string, unknown>,
);
if (!changed) return existing;
// Drizzle numeric columns expect string | null - coerce numbers to strings
const n = (v: number | undefined) => (v !== undefined ? String(v) : undefined);
const [updated] = await db
.update(berths)
.set({
area: data.area,
lengthFt: n(data.lengthFt),
lengthM: n(data.lengthM),
widthFt: n(data.widthFt),
widthM: n(data.widthM),
draftFt: n(data.draftFt),
draftM: n(data.draftM),
widthIsMinimum: data.widthIsMinimum,
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
nominalBoatSize: n(data.nominalBoatSize),
nominalBoatSizeM: n(data.nominalBoatSizeM),
waterDepth: n(data.waterDepth),
waterDepthM: n(data.waterDepthM),
waterDepthIsMinimum: data.waterDepthIsMinimum,
sidePontoon: data.sidePontoon,
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: n(data.powerCapacity),
voltage: n(data.voltage),
mooringType: data.mooringType,
cleatType: data.cleatType,
cleatCapacity: data.cleatCapacity,
bollardType: data.bollardType,
bollardCapacity: data.bollardCapacity,
access: data.access,
price: n(data.price),
priceCurrency: data.priceCurrency,
bowFacing: data.bowFacing,
berthApproved: data.berthApproved,
tenureType: data.tenureType,
tenureYears: data.tenureYears,
tenureStartDate: data.tenureStartDate,
tenureEndDate: data.tenureEndDate,
updatedAt: new Date(),
})
.where(and(eq(berths.id, id), eq(berths.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'berth',
entityId: id,
fix(audit-wave-10): types-auditor fixes — Tx type, BerthDetailData, parseBody, toAuditJson Address the CRITICAL + high-leverage HIGH items from the types-auditor: **C1 — `tx: any` in client-restore.service** Export a canonical `Tx` type from `lib/db/utils.ts` (derived from Drizzle's `db.transaction` callback shape) and use it in `applyReversal` so the 12+ downstream tx writes get full inference. **C2 — berth-detail page stacked `useQuery<any>` escape hatches** Export `BerthDetailData` from berth-detail-header and consume it through useQuery + apiFetch. Removed three `any` escapes in the highest-traffic detail page. Also collapsed the duplicate `BerthData` in berth-tabs.tsx to import from berth-detail-header so the two types can't drift. **C3 — parseBody migration for portal/public routes** Replace raw `await req.json() + schema.parse(body)` with the project-standard `parseBody(req, schema)` helper across 7 routes: - portal/auth/{change-password, activate, reset-password} - auth/set-password - public/{interests, residential-inquiries} Skipped the three anti-enumeration routes (forgot-password, sign-in, sign-in-by-identifier) where the manual validation gives opaque errors on purpose. website-inquiries already wraps the parse in a custom 400 — left as-is. **HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)** Introduce `toAuditJson<T extends object>(row: T): Record<string, unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow` that already exists for the same reason). Codemod 21 `<row> as unknown as Record<string, unknown>` sites across: - invoices.ts × 6 - expenses.ts × 6 - berths.service × 2 - documents.service × 2 - ocr-config.service × 2 - ai-budget.service × 2 - yachts.service, companies.service, company-memberships.service × 1 each document-templates' `payload as unknown as Record<...>` is a different shape (Documenso form-values widening, not an audit log) — kept the manual cast there. Tests stay 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:27:08 +02:00
oldValue: toAuditJson(diff),
newValue: toAuditJson(data),
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'berth:updated', {
berthId: id,
changedFields: Object.keys(diff),
});
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'berth:updated', { berthId: id }),
);
return updated!;
}
// ─── Update Status ────────────────────────────────────────────────────────────
export async function updateBerthStatus(
id: string,
portId: string,
data: UpdateBerthStatusInput,
meta: AuditMeta,
) {
const existing = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, portId)),
});
if (!existing) throw new NotFoundError('Berth');
const [updated] = await db
.update(berths)
.set({
status: data.status,
statusLastChangedBy: meta.userId,
statusLastChangedReason: data.reason,
statusLastModified: new Date(),
updatedAt: new Date(),
})
.where(and(eq(berths.id, id), eq(berths.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'berth',
entityId: id,
oldValue: { status: existing.status },
newValue: { status: data.status, reason: data.reason },
metadata: { type: 'status_change', reason: data.reason },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'berth:statusChanged', {
berthId: id,
oldStatus: existing.status,
newStatus: data.status,
triggeredBy: meta.userId,
});
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'berth:statusChanged', {
berthId: id,
oldStatus: existing.status,
newStatus: data.status,
}),
);
// Optional: link the chosen interest as the primary holder of this
// berth. Cross-port checks live inside the helper so a malicious
// interestId from another port can't slip past the status PATCH.
if (data.interestId) {
const { setPrimaryBerth } = await import('@/lib/services/interest-berths.service');
await setPrimaryBerth(data.interestId, id);
}
return updated!;
}
// ─── Set Tags ─────────────────────────────────────────────────────────────────
export async function setBerthTags(id: string, portId: string, tagIds: string[], meta: AuditMeta) {
const existing = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, portId)),
});
if (!existing) throw new NotFoundError('Berth');
const result = await setEntityTags({
joinTable: berthTags,
entityColumn: berthTags.berthId,
tagColumn: berthTags.tagId,
entityId: id,
portId,
tagIds,
meta,
entityType: 'berth',
});
return { berthId: result.entityId, tagIds: result.tagIds };
}
// ─── Add Maintenance Log ──────────────────────────────────────────────────────
export async function addMaintenanceLog(
id: string,
portId: string,
data: AddMaintenanceLogInput,
meta: AuditMeta,
) {
const existing = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, portId)),
});
if (!existing) throw new NotFoundError('Berth');
const rows = await db
.insert(berthMaintenanceLog)
.values({
berthId: id,
portId,
category: data.category,
description: data.description,
cost: data.cost !== undefined ? String(data.cost) : undefined,
costCurrency: data.costCurrency,
responsibleParty: data.responsibleParty,
performedDate: data.performedDate,
photoFileIds: data.photoFileIds,
createdBy: meta.userId,
})
.returning();
const log = rows[0]!;
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'berth_maintenance_log',
entityId: log.id,
metadata: { berthId: id, category: data.category },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'berth:maintenanceAdded', {
berthId: id,
logEntry: log,
});
return log;
}
// ─── Get Maintenance Logs ─────────────────────────────────────────────────────
export async function getMaintenanceLogs(id: string, portId: string) {
const existing = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, portId)),
});
if (!existing) throw new NotFoundError('Berth');
return db
.select()
.from(berthMaintenanceLog)
.where(and(eq(berthMaintenanceLog.berthId, id), eq(berthMaintenanceLog.portId, portId)))
.orderBy(berthMaintenanceLog.performedDate);
}
// ─── Get Waiting List ─────────────────────────────────────────────────────────
export async function getWaitingList(id: string, portId: string) {
const existing = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, portId)),
});
if (!existing) throw new NotFoundError('Berth');
return db
.select()
.from(berthWaitingList)
.where(eq(berthWaitingList.berthId, id))
.orderBy(berthWaitingList.position);
}
// ─── Update Waiting List ──────────────────────────────────────────────────────
export async function updateWaitingList(
id: string,
portId: string,
data: UpdateWaitingListInput,
meta: AuditMeta,
) {
const existing = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, portId)),
});
if (!existing) throw new NotFoundError('Berth');
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
// Validate every supplied clientId belongs to portId. Without this
// check, a port-A admin could insert port-B clientIds into the
// waiting list - corrupting reportable data and creating a join
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
// surface that hydrates foreign-tenant client rows.
if (data.entries.length > 0) {
const clientIds = [...new Set(data.entries.map((e) => e.clientId))];
const validClients = await db
.select({ id: clients.id })
.from(clients)
.where(and(inArray(clients.id, clientIds), eq(clients.portId, portId)));
if (validClients.length !== clientIds.length) {
throw new ValidationError('One or more clients are not in this port');
}
}
// Replace entire waiting list
await db.delete(berthWaitingList).where(eq(berthWaitingList.berthId, id));
if (data.entries.length > 0) {
await db.insert(berthWaitingList).values(
data.entries.map((entry) => ({
berthId: id,
clientId: entry.clientId,
position: entry.position,
priority: entry.priority ?? 'normal',
notifyPref: entry.notifyPref ?? 'email',
notes: entry.notes,
})),
);
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'berth',
entityId: id,
metadata: { type: 'waiting_list_updated', count: data.entries.length },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'berth:waitingListChanged', {
berthId: id,
action: 'replaced',
entry: data.entries,
});
return data.entries;
}
// ─── Create ──────────────────────────────────────────────────────────────────
export async function createBerth(portId: string, data: CreateBerthInput, meta: AuditMeta) {
// Check mooring number uniqueness within port
const existing = await db.query.berths.findFirst({
where: and(eq(berths.portId, portId), eq(berths.mooringNumber, data.mooringNumber)),
});
if (existing) {
throw new ConflictError(`Berth "${data.mooringNumber}" already exists in this port`);
}
feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones Mobile + responsive - berth-form full-width on phones (was 480px fixed → overflowed iPhone) - currency-input switched to inputMode=decimal with live thousands separator - client-form Country/Timezone/Source/Preferred-Contact full-width <sm - contacts row restructured so Primary toggle + Remove get their own strip - customize-dashboard footer stacks vertically on mobile; Done full-width - interest-form client/berth pickers no longer cmdk-filter on UUID (typing "Carlos" now returns Carlos Vega instead of "No clients found") Data + consistency - SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces now resolve interest/client source from one place - INTEREST_OUTCOMES adds lost_other (picker, badge, timeline) - Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort - archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles - TableBody last-row uses border-b-0 (not border-0); colored left-accent on the bottom berth row now renders - Hide Invite-to-Portal until port setting === true (was !== false default-show) - OwnerPicker primer query resolves entity name on first paint (no more UUID flash before the popover opens) Terminology - Replaced user-facing "Documenso" with "signing service" / "Generated EOI" / "Manual EOI" in 8 components (admin/internal references kept) - Plainer status-change copy on berth-detail-header Forms + editing - InlineEditableField gained a `date` variant (native picker); applied to company incorporation date and ready for other YYYY-MM-DD plaintext fields - Inline source picker on interest-tabs detail (was free text) - TagPicker self-hides when port has no tags AND nothing is selected - New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom) - Compose dialog follow-up is now a toggle that reveals datetime picker Pipeline milestones - changeStageSchema accepts optional milestoneDate; service stamps it on the matching date column instead of always using now - MilestoneAdvanceButton popover collects a back-date before stage advance - Applied to every "Mark X manually" surface on the interest overview EOI / linked-berths polish - Add-bypass row aligned inline with toggle descriptions - Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their legal vs. public-map consequences Surfaces - Companies list now has the column picker + persisted hidden-column prefs - NotesList aggregate flag enabled on clients, companies, residential_clients (yachts already aggregated) ft/m unit toggle (interim, before drift fix) - "Berth size desired" gets a section-level ft/m toggle; per-field hint shows the converted value. Storage stays canonical-ft for now; the drift-safe persistence migration is the next step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:50:58 +02:00
// Caller-specified currency wins; otherwise inherit the port's admin-
// configured default (system_settings.berths_default_currency, USD if
// unset). Lets a multi-currency portfolio be modelled cleanly without
// forcing reps to pick a currency on every new-berth form.
const resolvedCurrency = data.priceCurrency ?? (await getPortBerthsDefaultCurrency(portId));
const [berth] = await db
.insert(berths)
.values({
portId,
mooringNumber: data.mooringNumber,
area: data.area,
status: data.status ?? 'available',
lengthFt: data.lengthFt?.toString(),
lengthM: data.lengthM?.toString(),
widthFt: data.widthFt?.toString(),
widthM: data.widthM?.toString(),
draftFt: data.draftFt?.toString(),
draftM: data.draftM?.toString(),
price: data.price?.toString(),
feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones Mobile + responsive - berth-form full-width on phones (was 480px fixed → overflowed iPhone) - currency-input switched to inputMode=decimal with live thousands separator - client-form Country/Timezone/Source/Preferred-Contact full-width <sm - contacts row restructured so Primary toggle + Remove get their own strip - customize-dashboard footer stacks vertically on mobile; Done full-width - interest-form client/berth pickers no longer cmdk-filter on UUID (typing "Carlos" now returns Carlos Vega instead of "No clients found") Data + consistency - SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces now resolve interest/client source from one place - INTEREST_OUTCOMES adds lost_other (picker, badge, timeline) - Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort - archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles - TableBody last-row uses border-b-0 (not border-0); colored left-accent on the bottom berth row now renders - Hide Invite-to-Portal until port setting === true (was !== false default-show) - OwnerPicker primer query resolves entity name on first paint (no more UUID flash before the popover opens) Terminology - Replaced user-facing "Documenso" with "signing service" / "Generated EOI" / "Manual EOI" in 8 components (admin/internal references kept) - Plainer status-change copy on berth-detail-header Forms + editing - InlineEditableField gained a `date` variant (native picker); applied to company incorporation date and ready for other YYYY-MM-DD plaintext fields - Inline source picker on interest-tabs detail (was free text) - TagPicker self-hides when port has no tags AND nothing is selected - New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom) - Compose dialog follow-up is now a toggle that reveals datetime picker Pipeline milestones - changeStageSchema accepts optional milestoneDate; service stamps it on the matching date column instead of always using now - MilestoneAdvanceButton popover collects a back-date before stage advance - Applied to every "Mark X manually" surface on the interest overview EOI / linked-berths polish - Add-bypass row aligned inline with toggle descriptions - Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their legal vs. public-map consequences Surfaces - Companies list now has the column picker + persisted hidden-column prefs - NotesList aggregate flag enabled on clients, companies, residential_clients (yachts already aggregated) ft/m unit toggle (interim, before drift fix) - "Berth size desired" gets a section-level ft/m toggle; per-field hint shows the converted value. Storage stays canonical-ft for now; the drift-safe persistence migration is the next step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:50:58 +02:00
priceCurrency: resolvedCurrency,
tenureType: data.tenureType ?? 'permanent',
mooringType: data.mooringType,
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: data.powerCapacity?.toString(),
voltage: data.voltage?.toString(),
access: data.access,
bowFacing: data.bowFacing,
sidePontoon: data.sidePontoon,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'berth',
entityId: berth!.id,
newValue: { mooringNumber: berth!.mooringNumber, area: berth!.area },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'system:alert', {
alertType: 'berth:created',
message: `Berth "${berth!.mooringNumber}" created`,
severity: 'info',
});
return berth!;
}
// ─── Bulk add ───────────────────────────────────────────────────────────────
export async function bulkAddBerths(
portId: string,
inputs: CreateBerthInput[],
meta: AuditMeta,
): Promise<{ inserted: number; ids: string[] }> {
// Input-level dedup: catch fat-finger duplicates in the wizard before
// hitting the unique index.
const seenMoorings = new Set<string>();
for (const row of inputs) {
if (seenMoorings.has(row.mooringNumber)) {
throw new ConflictError(`Duplicate mooring number "${row.mooringNumber}" in input`);
}
seenMoorings.add(row.mooringNumber);
}
const moorings = inputs.map((r) => r.mooringNumber);
const existing = await db
.select({ mooringNumber: berths.mooringNumber })
.from(berths)
.where(and(eq(berths.portId, portId), inArray(berths.mooringNumber, moorings)));
if (existing.length > 0) {
throw new ConflictError(
`Mooring numbers already exist in this port: ${existing.map((r) => r.mooringNumber).join(', ')}`,
);
}
const defaultCurrency = await getPortBerthsDefaultCurrency(portId);
const values = inputs.map((row) => ({
portId,
mooringNumber: row.mooringNumber,
area: row.area,
status: row.status ?? 'available',
lengthFt: row.lengthFt?.toString(),
lengthM: row.lengthM?.toString(),
widthFt: row.widthFt?.toString(),
widthM: row.widthM?.toString(),
draftFt: row.draftFt?.toString(),
draftM: row.draftM?.toString(),
price: row.price?.toString(),
priceCurrency: row.priceCurrency ?? defaultCurrency,
tenureType: row.tenureType ?? 'permanent',
mooringType: row.mooringType,
powerCapacity: row.powerCapacity?.toString(),
voltage: row.voltage?.toString(),
access: row.access,
bowFacing: row.bowFacing,
sidePontoon: row.sidePontoon,
}));
const inserted = await db.insert(berths).values(values).returning({ id: berths.id });
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'berth',
entityId: 'bulk',
newValue: { count: inserted.length, mooringNumbers: moorings },
metadata: { type: 'bulk_add' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'system:alert', {
alertType: 'berth:bulk_created',
message: `${inserted.length} berths added`,
severity: 'info',
});
return { inserted: inserted.length, ids: inserted.map((r) => r.id) };
}
fix(P1): soft-archive berths instead of hard-delete — F5 Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which permanently dropped the row, cascade-vanished `interest_berths` links, broke historical audit references, and could 404 the public feed mid- customer-inquiry. The `berths.archived_at` column existed in the schema but was never written. Changes: - `archiveBerth(id, portId, { reason }, meta)` is the new canonical soft-archive. Requires a reason (min 5 chars). Blocks when an active interest still depends on the berth (forces the rep to resolve the deal first). Audit-logs the old status + reason. - `restoreBerth(...)` reverses it. - DELETE route now accepts `{ reason }` and routes to archiveBerth. - New POST /api/v1/berths/[id]/restore. - `getBerthOptions` + dashboard occupancy / status-distribution queries gain `isNull(berths.archivedAt)` so archived moorings don't show up in pickers or skew metrics. - Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth so import sites we haven't migrated still work — labeled @deprecated. Verified live: - DELETE w/o reason → 400 (validation) - DELETE w/ "x" → 400 "Reason must be ≥ 5 characters" - DELETE w/ proper reason → 204, row archived, reason persisted - DELETE twice → 409 "Berth is already archived" - POST /restore → 204, archived_at cleared Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts, alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules- engine.ts. The current set covers the visible surfaces; the rest are secondary aggregators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
// ─── Archive / Restore ─────────────────────────────────────────────────────
fix(P1): soft-archive berths instead of hard-delete — F5 Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which permanently dropped the row, cascade-vanished `interest_berths` links, broke historical audit references, and could 404 the public feed mid- customer-inquiry. The `berths.archived_at` column existed in the schema but was never written. Changes: - `archiveBerth(id, portId, { reason }, meta)` is the new canonical soft-archive. Requires a reason (min 5 chars). Blocks when an active interest still depends on the berth (forces the rep to resolve the deal first). Audit-logs the old status + reason. - `restoreBerth(...)` reverses it. - DELETE route now accepts `{ reason }` and routes to archiveBerth. - New POST /api/v1/berths/[id]/restore. - `getBerthOptions` + dashboard occupancy / status-distribution queries gain `isNull(berths.archivedAt)` so archived moorings don't show up in pickers or skew metrics. - Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth so import sites we haven't migrated still work — labeled @deprecated. Verified live: - DELETE w/o reason → 400 (validation) - DELETE w/ "x" → 400 "Reason must be ≥ 5 characters" - DELETE w/ proper reason → 204, row archived, reason persisted - DELETE twice → 409 "Berth is already archived" - POST /restore → 204, archived_at cleared Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts, alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules- engine.ts. The current set covers the visible surfaces; the rest are secondary aggregators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
/**
* Post-audit F5: soft-archive replaces hard-delete. The previous
* `db.delete()` permanently dropped the berth row + cascade-vanished
* interest_berths links + broke historical audit references. Now the
* row stays; `archived_at` shields it from default queries.
*
* Reasoning chain:
* 1. Block if there's an active (non-archived, no-outcome) interest
* still linked archiving with deals in flight breaks reports.
* 2. Stamp archived_at + archived_by + archive_reason in a single update.
* 3. Audit log captures the reason so /admin/audit shows the why.
* 4. Emit a socket alert so any open berth-detail page bounces.
*/
export async function archiveBerth(
id: string,
portId: string,
input: { reason: string },
meta: AuditMeta,
) {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, portId)),
});
if (!berth) throw new NotFoundError('Berth');
fix(P1): soft-archive berths instead of hard-delete — F5 Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which permanently dropped the row, cascade-vanished `interest_berths` links, broke historical audit references, and could 404 the public feed mid- customer-inquiry. The `berths.archived_at` column existed in the schema but was never written. Changes: - `archiveBerth(id, portId, { reason }, meta)` is the new canonical soft-archive. Requires a reason (min 5 chars). Blocks when an active interest still depends on the berth (forces the rep to resolve the deal first). Audit-logs the old status + reason. - `restoreBerth(...)` reverses it. - DELETE route now accepts `{ reason }` and routes to archiveBerth. - New POST /api/v1/berths/[id]/restore. - `getBerthOptions` + dashboard occupancy / status-distribution queries gain `isNull(berths.archivedAt)` so archived moorings don't show up in pickers or skew metrics. - Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth so import sites we haven't migrated still work — labeled @deprecated. Verified live: - DELETE w/o reason → 400 (validation) - DELETE w/ "x" → 400 "Reason must be ≥ 5 characters" - DELETE w/ proper reason → 204, row archived, reason persisted - DELETE twice → 409 "Berth is already archived" - POST /restore → 204, archived_at cleared Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts, alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules- engine.ts. The current set covers the visible surfaces; the rest are secondary aggregators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
if (berth.archivedAt) {
throw new ConflictError('Berth is already archived');
}
// Block archive when an active interest still depends on the berth —
// forces the rep to resolve the deal first instead of orphaning it.
const activeLink = await db
.select({ interestId: interestBerths.interestId })
.from(interestBerths)
.innerJoin(interests, eq(interests.id, interestBerths.interestId))
.where(
and(eq(interestBerths.berthId, id), isNull(interests.archivedAt), isNull(interests.outcome)),
)
.limit(1);
if (activeLink.length > 0) {
throw new ConflictError(
'Cannot archive a berth with an active interest. Resolve or archive the interest first.',
);
}
fix(P1): soft-archive berths instead of hard-delete — F5 Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which permanently dropped the row, cascade-vanished `interest_berths` links, broke historical audit references, and could 404 the public feed mid- customer-inquiry. The `berths.archived_at` column existed in the schema but was never written. Changes: - `archiveBerth(id, portId, { reason }, meta)` is the new canonical soft-archive. Requires a reason (min 5 chars). Blocks when an active interest still depends on the berth (forces the rep to resolve the deal first). Audit-logs the old status + reason. - `restoreBerth(...)` reverses it. - DELETE route now accepts `{ reason }` and routes to archiveBerth. - New POST /api/v1/berths/[id]/restore. - `getBerthOptions` + dashboard occupancy / status-distribution queries gain `isNull(berths.archivedAt)` so archived moorings don't show up in pickers or skew metrics. - Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth so import sites we haven't migrated still work — labeled @deprecated. Verified live: - DELETE w/o reason → 400 (validation) - DELETE w/ "x" → 400 "Reason must be ≥ 5 characters" - DELETE w/ proper reason → 204, row archived, reason persisted - DELETE twice → 409 "Berth is already archived" - POST /restore → 204, archived_at cleared Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts, alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules- engine.ts. The current set covers the visible surfaces; the rest are secondary aggregators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
await db
.update(berths)
.set({
archivedAt: new Date(),
archivedBy: meta.userId,
archiveReason: input.reason,
updatedAt: new Date(),
})
.where(and(eq(berths.id, id), eq(berths.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
fix(P1): soft-archive berths instead of hard-delete — F5 Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which permanently dropped the row, cascade-vanished `interest_berths` links, broke historical audit references, and could 404 the public feed mid- customer-inquiry. The `berths.archived_at` column existed in the schema but was never written. Changes: - `archiveBerth(id, portId, { reason }, meta)` is the new canonical soft-archive. Requires a reason (min 5 chars). Blocks when an active interest still depends on the berth (forces the rep to resolve the deal first). Audit-logs the old status + reason. - `restoreBerth(...)` reverses it. - DELETE route now accepts `{ reason }` and routes to archiveBerth. - New POST /api/v1/berths/[id]/restore. - `getBerthOptions` + dashboard occupancy / status-distribution queries gain `isNull(berths.archivedAt)` so archived moorings don't show up in pickers or skew metrics. - Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth so import sites we haven't migrated still work — labeled @deprecated. Verified live: - DELETE w/o reason → 400 (validation) - DELETE w/ "x" → 400 "Reason must be ≥ 5 characters" - DELETE w/ proper reason → 204, row archived, reason persisted - DELETE twice → 409 "Berth is already archived" - POST /restore → 204, archived_at cleared Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts, alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules- engine.ts. The current set covers the visible surfaces; the rest are secondary aggregators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
action: 'archive',
entityType: 'berth',
entityId: id,
fix(P1): soft-archive berths instead of hard-delete — F5 Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which permanently dropped the row, cascade-vanished `interest_berths` links, broke historical audit references, and could 404 the public feed mid- customer-inquiry. The `berths.archived_at` column existed in the schema but was never written. Changes: - `archiveBerth(id, portId, { reason }, meta)` is the new canonical soft-archive. Requires a reason (min 5 chars). Blocks when an active interest still depends on the berth (forces the rep to resolve the deal first). Audit-logs the old status + reason. - `restoreBerth(...)` reverses it. - DELETE route now accepts `{ reason }` and routes to archiveBerth. - New POST /api/v1/berths/[id]/restore. - `getBerthOptions` + dashboard occupancy / status-distribution queries gain `isNull(berths.archivedAt)` so archived moorings don't show up in pickers or skew metrics. - Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth so import sites we haven't migrated still work — labeled @deprecated. Verified live: - DELETE w/o reason → 400 (validation) - DELETE w/ "x" → 400 "Reason must be ≥ 5 characters" - DELETE w/ proper reason → 204, row archived, reason persisted - DELETE twice → 409 "Berth is already archived" - POST /restore → 204, archived_at cleared Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts, alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules- engine.ts. The current set covers the visible surfaces; the rest are secondary aggregators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
oldValue: { mooringNumber: berth.mooringNumber, area: berth.area, status: berth.status },
newValue: { reason: input.reason },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'system:alert', {
fix(P1): soft-archive berths instead of hard-delete — F5 Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which permanently dropped the row, cascade-vanished `interest_berths` links, broke historical audit references, and could 404 the public feed mid- customer-inquiry. The `berths.archived_at` column existed in the schema but was never written. Changes: - `archiveBerth(id, portId, { reason }, meta)` is the new canonical soft-archive. Requires a reason (min 5 chars). Blocks when an active interest still depends on the berth (forces the rep to resolve the deal first). Audit-logs the old status + reason. - `restoreBerth(...)` reverses it. - DELETE route now accepts `{ reason }` and routes to archiveBerth. - New POST /api/v1/berths/[id]/restore. - `getBerthOptions` + dashboard occupancy / status-distribution queries gain `isNull(berths.archivedAt)` so archived moorings don't show up in pickers or skew metrics. - Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth so import sites we haven't migrated still work — labeled @deprecated. Verified live: - DELETE w/o reason → 400 (validation) - DELETE w/ "x" → 400 "Reason must be ≥ 5 characters" - DELETE w/ proper reason → 204, row archived, reason persisted - DELETE twice → 409 "Berth is already archived" - POST /restore → 204, archived_at cleared Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts, alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules- engine.ts. The current set covers the visible surfaces; the rest are secondary aggregators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
alertType: 'berth:archived',
message: `Berth "${berth.mooringNumber}" archived: ${input.reason}`,
severity: 'info',
});
}
fix(P1): soft-archive berths instead of hard-delete — F5 Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which permanently dropped the row, cascade-vanished `interest_berths` links, broke historical audit references, and could 404 the public feed mid- customer-inquiry. The `berths.archived_at` column existed in the schema but was never written. Changes: - `archiveBerth(id, portId, { reason }, meta)` is the new canonical soft-archive. Requires a reason (min 5 chars). Blocks when an active interest still depends on the berth (forces the rep to resolve the deal first). Audit-logs the old status + reason. - `restoreBerth(...)` reverses it. - DELETE route now accepts `{ reason }` and routes to archiveBerth. - New POST /api/v1/berths/[id]/restore. - `getBerthOptions` + dashboard occupancy / status-distribution queries gain `isNull(berths.archivedAt)` so archived moorings don't show up in pickers or skew metrics. - Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth so import sites we haven't migrated still work — labeled @deprecated. Verified live: - DELETE w/o reason → 400 (validation) - DELETE w/ "x" → 400 "Reason must be ≥ 5 characters" - DELETE w/ proper reason → 204, row archived, reason persisted - DELETE twice → 409 "Berth is already archived" - POST /restore → 204, archived_at cleared Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts, alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules- engine.ts. The current set covers the visible surfaces; the rest are secondary aggregators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
/** Un-archive. Available to anyone with `berths:edit`. Audit-logged. */
export async function restoreBerth(id: string, portId: string, meta: AuditMeta) {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, portId)),
});
if (!berth) throw new NotFoundError('Berth');
if (!berth.archivedAt) {
throw new ConflictError('Berth is not archived');
}
await db
.update(berths)
.set({
archivedAt: null,
archivedBy: null,
archiveReason: null,
updatedAt: new Date(),
})
.where(and(eq(berths.id, id), eq(berths.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'restore',
entityType: 'berth',
entityId: id,
oldValue: { archivedAt: berth.archivedAt, archiveReason: berth.archiveReason },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'system:alert', {
alertType: 'berth:restored',
message: `Berth "${berth.mooringNumber}" restored`,
severity: 'info',
});
}
/**
* @deprecated Use `archiveBerth` instead. Kept temporarily for callers
* that haven't migrated. Calls archiveBerth under the hood the
* "hard delete" name is now a lie but we don't break the import sites
* in a single PR.
*/
export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
return archiveBerth(id, portId, { reason: 'Deleted via legacy delete path' }, meta);
}
// ─── Options ──────────────────────────────────────────────────────────────────
export async function getBerthOptions(portId: string) {
feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones Mobile + responsive - berth-form full-width on phones (was 480px fixed → overflowed iPhone) - currency-input switched to inputMode=decimal with live thousands separator - client-form Country/Timezone/Source/Preferred-Contact full-width <sm - contacts row restructured so Primary toggle + Remove get their own strip - customize-dashboard footer stacks vertically on mobile; Done full-width - interest-form client/berth pickers no longer cmdk-filter on UUID (typing "Carlos" now returns Carlos Vega instead of "No clients found") Data + consistency - SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces now resolve interest/client source from one place - INTEREST_OUTCOMES adds lost_other (picker, badge, timeline) - Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort - archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles - TableBody last-row uses border-b-0 (not border-0); colored left-accent on the bottom berth row now renders - Hide Invite-to-Portal until port setting === true (was !== false default-show) - OwnerPicker primer query resolves entity name on first paint (no more UUID flash before the popover opens) Terminology - Replaced user-facing "Documenso" with "signing service" / "Generated EOI" / "Manual EOI" in 8 components (admin/internal references kept) - Plainer status-change copy on berth-detail-header Forms + editing - InlineEditableField gained a `date` variant (native picker); applied to company incorporation date and ready for other YYYY-MM-DD plaintext fields - Inline source picker on interest-tabs detail (was free text) - TagPicker self-hides when port has no tags AND nothing is selected - New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom) - Compose dialog follow-up is now a toggle that reveals datetime picker Pipeline milestones - changeStageSchema accepts optional milestoneDate; service stamps it on the matching date column instead of always using now - MilestoneAdvanceButton popover collects a back-date before stage advance - Applied to every "Mark X manually" surface on the interest overview EOI / linked-berths polish - Add-bypass row aligned inline with toggle descriptions - Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their legal vs. public-map consequences Surfaces - Companies list now has the column picker + persisted hidden-column prefs - NotesList aggregate flag enabled on clients, companies, residential_clients (yachts already aggregated) ft/m unit toggle (interim, before drift fix) - "Berth size desired" gets a section-level ft/m toggle; per-field hint shows the converted value. Storage stays canonical-ft for now; the drift-safe persistence migration is the next step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:50:58 +02:00
// DB-side `ORDER BY mooring_number` is lexicographic (A1, A10, A11, A2…).
// Natural-sort in JS so dropdowns surface them as reps read them: A1, A2,
// …, A10, A11. See compareMooringNumbers for the prefix/index split.
const rows = await db
.select({
id: berths.id,
mooringNumber: berths.mooringNumber,
area: berths.area,
status: berths.status,
})
.from(berths)
fix(P1): soft-archive berths instead of hard-delete — F5 Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which permanently dropped the row, cascade-vanished `interest_berths` links, broke historical audit references, and could 404 the public feed mid- customer-inquiry. The `berths.archived_at` column existed in the schema but was never written. Changes: - `archiveBerth(id, portId, { reason }, meta)` is the new canonical soft-archive. Requires a reason (min 5 chars). Blocks when an active interest still depends on the berth (forces the rep to resolve the deal first). Audit-logs the old status + reason. - `restoreBerth(...)` reverses it. - DELETE route now accepts `{ reason }` and routes to archiveBerth. - New POST /api/v1/berths/[id]/restore. - `getBerthOptions` + dashboard occupancy / status-distribution queries gain `isNull(berths.archivedAt)` so archived moorings don't show up in pickers or skew metrics. - Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth so import sites we haven't migrated still work — labeled @deprecated. Verified live: - DELETE w/o reason → 400 (validation) - DELETE w/ "x" → 400 "Reason must be ≥ 5 characters" - DELETE w/ proper reason → 204, row archived, reason persisted - DELETE twice → 409 "Berth is already archived" - POST /restore → 204, archived_at cleared Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts, alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules- engine.ts. The current set covers the visible surfaces; the rest are secondary aggregators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
// F5: hide archived berths from option pickers; otherwise a dead berth
// appears in the New Interest combobox and re-links itself to a deal.
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)));
feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones Mobile + responsive - berth-form full-width on phones (was 480px fixed → overflowed iPhone) - currency-input switched to inputMode=decimal with live thousands separator - client-form Country/Timezone/Source/Preferred-Contact full-width <sm - contacts row restructured so Primary toggle + Remove get their own strip - customize-dashboard footer stacks vertically on mobile; Done full-width - interest-form client/berth pickers no longer cmdk-filter on UUID (typing "Carlos" now returns Carlos Vega instead of "No clients found") Data + consistency - SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces now resolve interest/client source from one place - INTEREST_OUTCOMES adds lost_other (picker, badge, timeline) - Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort - archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles - TableBody last-row uses border-b-0 (not border-0); colored left-accent on the bottom berth row now renders - Hide Invite-to-Portal until port setting === true (was !== false default-show) - OwnerPicker primer query resolves entity name on first paint (no more UUID flash before the popover opens) Terminology - Replaced user-facing "Documenso" with "signing service" / "Generated EOI" / "Manual EOI" in 8 components (admin/internal references kept) - Plainer status-change copy on berth-detail-header Forms + editing - InlineEditableField gained a `date` variant (native picker); applied to company incorporation date and ready for other YYYY-MM-DD plaintext fields - Inline source picker on interest-tabs detail (was free text) - TagPicker self-hides when port has no tags AND nothing is selected - New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom) - Compose dialog follow-up is now a toggle that reveals datetime picker Pipeline milestones - changeStageSchema accepts optional milestoneDate; service stamps it on the matching date column instead of always using now - MilestoneAdvanceButton popover collects a back-date before stage advance - Applied to every "Mark X manually" surface on the interest overview EOI / linked-berths polish - Add-bypass row aligned inline with toggle descriptions - Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their legal vs. public-map consequences Surfaces - Companies list now has the column picker + persisted hidden-column prefs - NotesList aggregate flag enabled on clients, companies, residential_clients (yachts already aggregated) ft/m unit toggle (interim, before drift fix) - "Berth size desired" gets a section-level ft/m toggle; per-field hint shows the converted value. Storage stays canonical-ft for now; the drift-safe persistence migration is the next step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:50:58 +02:00
return sortByMooring(rows, (r) => r.mooringNumber);
}