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
|
|
|
import { and, eq, gte, lte, inArray, sql } from 'drizzle-orm';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
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';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { tags } from '@/lib/db/schema/system';
|
2026-04-29 01:58:42 +02:00
|
|
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { diffEntity } from '@/lib/entity-diff';
|
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 { NotFoundError, ValidationError } from '@/lib/errors';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { buildListQuery } from '@/lib/db/query-builder';
|
|
|
|
|
import { emitToRoom } from '@/lib/socket/server';
|
2026-04-29 01:58:42 +02:00
|
|
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
2026-05-12 14:50:58 +02:00
|
|
|
import { getPortBerthsDefaultCurrency } from '@/lib/services/port-config';
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
import { ConflictError } from '@/lib/errors';
|
2026-05-12 14:50:58 +02:00
|
|
|
import { sortByMooring } from '@/lib/utils/mooring-sort';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import type {
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
CreateBerthInput,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
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 = [
|
2026-05-09 04:10:04 +02:00
|
|
|
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`,
|
|
|
|
|
];
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const sortColumn = (() => {
|
|
|
|
|
switch (query.sort) {
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
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;
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
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;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
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,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
page: query.page,
|
|
|
|
|
pageSize: query.limit,
|
|
|
|
|
searchColumns: [berths.mooringNumber, berths.area],
|
|
|
|
|
searchTerm: query.search,
|
|
|
|
|
// No archivedAt column on berths
|
|
|
|
|
includeArchived: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = (result.data as Array<Record<string, unknown>>).map((b) => ({
|
|
|
|
|
...b,
|
|
|
|
|
tags: tagsByBerthId[b.id as string] ?? [],
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
return { data, total: result.total };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── 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));
|
|
|
|
|
|
|
|
|
|
return { ...berth, tags: tagRows };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── 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');
|
|
|
|
|
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
const { changed, diff } = diffEntity(
|
|
|
|
|
existing as Record<string, unknown>,
|
|
|
|
|
data as Record<string, unknown>,
|
|
|
|
|
);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
if (!changed) return existing;
|
|
|
|
|
|
2026-05-04 22:57:01 +02:00
|
|
|
// Drizzle numeric columns expect string | null - coerce numbers to strings
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
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),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
waterDepth: 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),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
mooringType: 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,
|
|
|
|
|
oldValue: diff as unknown as Record<string, unknown>,
|
|
|
|
|
newValue: data as unknown as Record<string, unknown>,
|
|
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-09 18:37:04 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
return updated!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Set Tags ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
export async function setBerthTags(id: string, portId: string, tagIds: string[], meta: AuditMeta) {
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const existing = await db.query.berths.findFirst({
|
|
|
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!existing) throw new NotFoundError('Berth');
|
|
|
|
|
|
2026-04-29 01:58:42 +02:00
|
|
|
const result = await setEntityTags({
|
|
|
|
|
joinTable: berthTags,
|
|
|
|
|
entityColumn: berthTags.berthId,
|
|
|
|
|
tagColumn: berthTags.tagId,
|
|
|
|
|
entityId: id,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
portId,
|
2026-04-29 01:58:42 +02:00
|
|
|
tagIds,
|
|
|
|
|
meta,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
entityType: 'berth',
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-29 01:58:42 +02:00
|
|
|
return { berthId: result.entityId, tagIds: result.tagIds };
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── 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
|
2026-05-04 22:57:01 +02:00
|
|
|
// 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');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
// ─── 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`);
|
|
|
|
|
}
|
|
|
|
|
|
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));
|
|
|
|
|
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
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(),
|
2026-05-12 14:50:58 +02:00
|
|
|
priceCurrency: resolvedCurrency,
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
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(),
|
Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone)
- User settings page with profile editor + notification preferences
- Audit log API with filtering (entity, action, user, date range)
- Audit log page with search, entity type, and action filters
- Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id]
- Client duplicates endpoint: GET /api/v1/clients/duplicates?name=
- Replace settings and audit stub pages with real implementations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:45:56 -04:00
|
|
|
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!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Delete ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function deleteBerth(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');
|
|
|
|
|
|
|
|
|
|
await db.delete(berths).where(and(eq(berths.id, id), eq(berths.portId, portId)));
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'delete',
|
|
|
|
|
entityType: 'berth',
|
|
|
|
|
entityId: id,
|
|
|
|
|
oldValue: { mooringNumber: berth.mooringNumber, area: berth.area },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'system:alert', {
|
|
|
|
|
alertType: 'berth:deleted',
|
|
|
|
|
message: `Berth "${berth.mooringNumber}" deleted`,
|
|
|
|
|
severity: 'info',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// ─── Options ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getBerthOptions(portId: string) {
|
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
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
.select({
|
|
|
|
|
id: berths.id,
|
|
|
|
|
mooringNumber: berths.mooringNumber,
|
|
|
|
|
area: berths.area,
|
|
|
|
|
status: berths.status,
|
|
|
|
|
})
|
|
|
|
|
.from(berths)
|
2026-05-12 14:50:58 +02:00
|
|
|
.where(eq(berths.portId, portId));
|
|
|
|
|
return sortByMooring(rows, (r) => r.mooringNumber);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|