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

1009 lines
33 KiB
TypeScript
Raw Normal View History

fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
import { and, count, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import {
clients,
clientContacts,
fix(ux): pass-3 — yacht/company headers, reminder filters wrap, client tab counts Five small fixes from the third audit pass on previously-unchecked surfaces: Yacht detail header (mobile): - Stack the action cluster (Edit / Transfer / Archive) below the title block on phone widths. Previously the three buttons crowded the right side enough to truncate the status pill to "A..." and force the owner name to wrap to two lines. Same fix that landed for berth / client / company headers. Company detail header (mobile): - Same mobile stacking fix; legal-name + Tax-ID metadata no longer wraps awkwardly. Company detail Incorporation Date (all viewports): - Strip the time portion of the ISO timestamp before passing to the inline editor. Previously rendered the raw "2019-03-14T00:00:00.000Z" Postgres-serialized form. Now reads "2019-03-14" and round-trips through the YYYY-MM-DD inline editor cleanly. Reminders list filter row: - Allow flex-wrap on the My/All tabs + status filter + priority filter cluster. At 390px, the priority filter dropdown was being pushed off the right edge of the screen. Client detail tab counts: - Add interestCount + noteCount to getClientById response, surface as badges on the Interests + Notes tabs. Brings them into parity with Yachts/Companies/Reservations/Addresses which already showed counts; Files + Activity are still stubs and don't get a count yet. Verification: 0 tsc errors, 926/926 vitest passing, lint clean. Out of scope (deferred): - Residential clients / interests pages still render plain HTML tables on phone widths (header columns clip at the right edge). Needs the DataView card-on-mobile treatment that the main /clients and /interests pages already have. Substantial separate work. - Phone contacts in the legacy seed have value set but valueE164 NULL, so InlinePhoneField shows "—" even though metadata is technically populated. Fix is a one-time backfill via libphonenumber-js, not a UI change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:09:27 +02:00
clientNotes,
clientRelationships,
clientTags,
clientAddresses,
} from '@/lib/db/schema/clients';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { berthReservations } from '@/lib/db/schema/reservations';
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { tags } from '@/lib/db/schema/system';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors';
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
import { isPortalEnabledForPort } from '@/lib/services/portal-auth.service';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
import { emitToRoom } from '@/lib/socket/server';
import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
import { softDelete, restore, withTransaction } from '@/lib/db/utils';
import { logger } from '@/lib/logger';
import {
syncEntityFolderName,
applyEntityArchivedSuffix,
applyEntityRestoredSuffix,
} from '@/lib/services/document-folders.service';
import type {
CreateClientInput,
UpdateClientInput,
ListClientsInput,
} from '@/lib/validators/clients';
// ─── Types ────────────────────────────────────────────────────────────────────
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listClients(portId: string, query: ListClientsInput) {
const { page, limit, sort, order, search, includeArchived, source, nationality, tagIds } = query;
const filters = [];
if (source) {
filters.push(eq(clients.source, source));
}
if (nationality) {
// Filter accepts an ISO-3166-1 alpha-2 code.
filters.push(eq(clients.nationalityIso, nationality.toUpperCase()));
}
if (tagIds && tagIds.length > 0) {
const clientsWithTags = await db
.selectDistinct({ clientId: clientTags.clientId })
.from(clientTags)
.where(inArray(clientTags.tagId, tagIds));
const matchingIds = clientsWithTags.map((r) => r.clientId);
if (matchingIds.length > 0) {
filters.push(inArray(clients.id, matchingIds));
} else {
// No clients match these tags - return empty
return { data: [], total: 0 };
}
}
let sortColumn: typeof clients.fullName | typeof clients.createdAt | typeof clients.updatedAt =
clients.updatedAt;
if (sort === 'fullName') sortColumn = clients.fullName;
else if (sort === 'createdAt') sortColumn = clients.createdAt;
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
const result = await buildListQuery<typeof clients.$inferSelect>({
table: clients,
portIdColumn: clients.portId,
portId,
idColumn: clients.id,
updatedAtColumn: clients.updatedAt,
refactor(clients): drop deprecated yacht/company/proxy columns PR 13: now that all reads are migrated to the dedicated yacht / company / membership entities, drop the columns that mirrored them on `clients`: companyName, isProxy, proxyType, actualOwnerName, relationshipNotes, yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M}, berthSizeDesired. Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to apply. Caller cleanup (zero behavioral change to remaining flows): - Drops the legacy `generateEoi` flow entirely (route, service function, pdfme template, validator schema). The dual-path generate-and-sign service from PR 11 has fully replaced it; the route was no longer wired to the UI. - `clients.service`: company-name search column / WHERE / audit value removed; search now ranks by full name only. - `interests.service`: `resolveLeadCategory` reads dimensions from `yachts` via `interest.yachtId` instead of the dropped `client.yachtLength{Ft,M}`. - `record-export`: client-summary now lists yachts via owner-side lookup (direct + active company memberships); interest-summary fetches yacht via `interest.yachtId`. Both PDF templates updated to read yacht details from the new entity. - `client-detail-header`, `client-picker`, `command-search`, `search-result-item`, `use-search` hook, `types/domain.ts`, `search.service` — drop the companyName badge / sub-label / typed field everywhere it was rendered or fetched. - `ai.ts` worker: drop the company / yacht context lines from the prompt (will be re-added later sourced from the new entities). - `validators/interests.ts`: remove the deprecated public-form flat yacht/company fields. The route already ignores them. - `factories.ts`: drop the `isProxy: false` default. Tests: 652/652 green; type-check clean. The `security-sensitive-data` tests use `companyName` / `isProxy` as arbitrary record keys for a generic util — left unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
searchColumns: [clients.fullName],
searchTerm: search,
filters,
sort: sort ? { column: sortColumn, direction: order } : undefined,
page,
pageSize: limit,
includeArchived,
archivedAtColumn: clients.archivedAt,
});
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
if (result.data.length === 0) return result;
const ids = result.data.map((r) => r.id);
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
const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows, linkedBerthRows] =
await Promise.all([
db
.select({ ownerId: yachts.currentOwnerId, count: count() })
.from(yachts)
.where(
and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'client'),
inArray(yachts.currentOwnerId, ids),
isNull(yachts.archivedAt),
),
)
.groupBy(yachts.currentOwnerId),
db
.select({ clientId: companyMemberships.clientId, count: count() })
.from(companyMemberships)
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
.groupBy(companyMemberships.clientId),
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
// Latest interest per client + its primary-berth mooring (resolved via
// interest_berths join, plan §3.4). The is_primary filter narrows the
// join to ≤1 berth row per interest; non-primary links never surface
// through this list-page derivation.
db
.select({
clientId: interests.clientId,
pipelineStage: interests.pipelineStage,
updatedAt: interests.updatedAt,
mooringNumber: berths.mooringNumber,
})
.from(interests)
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
.leftJoin(
interestBerths,
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
)
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
.where(
and(
eq(interests.portId, portId),
inArray(interests.clientId, ids),
isNull(interests.archivedAt),
),
)
.orderBy(desc(interests.updatedAt)),
db
.select({ clientId: interests.clientId, count: count() })
.from(interests)
.where(
and(
eq(interests.portId, portId),
inArray(interests.clientId, ids),
isNull(interests.archivedAt),
),
)
.groupBy(interests.clientId),
fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
// Pull at most ONE contact per (client_id, channel) for the page.
// DISTINCT ON sorted by `is_primary DESC, created_at DESC` keeps
// the picker logic identical to the in-memory version it replaced
// while bounding the row count to ~2 per client (one email, one
// phone) regardless of how many contacts the client has.
db.execute<{
clientId: string;
channel: string;
value: string;
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
valueE164: string | null;
fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
isPrimary: boolean;
createdAt: Date;
}>(sql`
SELECT DISTINCT ON (client_id, channel)
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
client_id AS "clientId",
fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
channel,
value,
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
value_e164 AS "valueE164",
is_primary AS "isPrimary",
created_at AS "createdAt"
fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
FROM client_contacts
WHERE ${inArray(clientContacts.clientId, ids)}
fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
AND channel IN ('email', 'phone')
ORDER BY client_id, channel, is_primary DESC, created_at DESC
`),
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
// Berths each client has interests in, with the (most-active)
// interest's stage attached so the list-view chip can self-describe
// ("E17 · EOI sent") AND deep-link to the interest. DISTINCT ON
// collapses (client, berth) when the client has had multiple
// historical interests in the same berth — we keep the open-outcome
// one if any, otherwise the most recently updated. Excludes archived
// interests so closed deals don't crowd the chip row.
db.execute<{
clientId: string;
berthId: string;
mooringNumber: string;
interestId: string;
pipelineStage: string;
outcome: string | null;
}>(sql`
SELECT DISTINCT ON (i.client_id, b.id)
i.client_id AS "clientId",
b.id AS "berthId",
b.mooring_number AS "mooringNumber",
i.id AS "interestId",
i.pipeline_stage AS "pipelineStage",
i.outcome
FROM interests i
JOIN interest_berths ib ON ib.interest_id = i.id
JOIN berths b ON b.id = ib.berth_id
WHERE i.port_id = ${portId}
AND i.client_id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})
AND i.archived_at IS NULL
ORDER BY
i.client_id,
b.id,
CASE WHEN i.outcome IS NULL THEN 0 ELSE 1 END,
i.updated_at DESC
`),
]);
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
const interestCountMap = new Map(interestCounts.map((r) => [r.clientId, r.count]));
// interestRows is sorted desc by updatedAt; first hit per clientId is the latest.
const latestInterestMap = new Map<string, { stage: string; mooringNumber: string | null }>();
for (const row of interestRows) {
if (!latestInterestMap.has(row.clientId)) {
latestInterestMap.set(row.clientId, {
stage: row.pipelineStage,
mooringNumber: row.mooringNumber,
});
}
}
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
// Pick the per-client primary email + phone. The SQL DISTINCT ON
// returns at most one row per (clientId, channel); the result is
// already the picker's "is_primary desc, created_at desc" choice.
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
// We also keep the E.164 form of the phone so the UI can build a
// wa.me/<digits> link that doesn't need re-parsing.
const primaryEmailMap = new Map<string, string>();
const primaryPhoneMap = new Map<string, string>();
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
const primaryPhoneE164Map = new Map<string, string>();
fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
type ContactRow = {
clientId: string;
channel: string;
value: string;
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
valueE164: string | null;
fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
isPrimary: boolean;
createdAt: Date;
};
const contactRowList: ContactRow[] =
(contactRows as { rows?: ContactRow[] }).rows ?? (contactRows as unknown as ContactRow[]);
for (const c of contactRowList) {
if (c.channel === 'email') primaryEmailMap.set(c.clientId, c.value);
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
else if (c.channel === 'phone') {
primaryPhoneMap.set(c.clientId, c.value);
if (c.valueE164) primaryPhoneE164Map.set(c.clientId, c.valueE164);
}
}
// Aggregate berths per client, sorted so the most-action-worthy
// interest floats to the top of the chip row. Priority:
// 1. open outcome (active deal) before closed (won/lost/cancelled)
// 2. within open: most progressed stage first (contract_signed > … > open)
// 3. tie-breaker: mooring number alphabetical for stable ordering
// The list-view UI shows the top 2 with full labels; the rest fall
// through into a "+N more" popover.
const stageRank: Record<string, number> = {
contract_signed: 1,
deposit_10pct: 2,
contract_sent: 3,
eoi_signed: 4,
eoi_sent: 5,
in_communication: 6,
details_sent: 7,
qualified: 8,
open: 9,
completed: 10,
};
type LinkedBerth = {
id: string;
mooringNumber: string;
interestId: string;
stage: string;
outcome: string | null;
};
const linkedBerthsMap = new Map<string, LinkedBerth[]>();
type LinkedBerthRow = typeof linkedBerthRows extends Iterable<infer T> ? T : never;
const linkedBerthList: LinkedBerthRow[] =
(linkedBerthRows as { rows?: LinkedBerthRow[] }).rows ??
(linkedBerthRows as unknown as LinkedBerthRow[]);
for (const r of linkedBerthList) {
const list = linkedBerthsMap.get(r.clientId) ?? [];
list.push({
id: r.berthId,
mooringNumber: r.mooringNumber,
interestId: r.interestId,
stage: r.pipelineStage,
outcome: r.outcome,
});
linkedBerthsMap.set(r.clientId, list);
}
for (const list of linkedBerthsMap.values()) {
list.sort((a, b) => {
// Open before closed.
const openA = a.outcome === null ? 0 : 1;
const openB = b.outcome === null ? 0 : 1;
if (openA !== openB) return openA - openB;
// Within bucket, most-progressed stage first.
const rankA = stageRank[a.stage] ?? 99;
const rankB = stageRank[b.stage] ?? 99;
if (rankA !== rankB) return rankA - rankB;
return a.mooringNumber.localeCompare(b.mooringNumber);
});
}
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
return {
...result,
data: result.data.map((row) => {
const latest = latestInterestMap.get(row.id);
return {
...row,
yachtCount: yachtCountMap.get(row.id) ?? 0,
companyCount: companyCountMap.get(row.id) ?? 0,
interestCount: interestCountMap.get(row.id) ?? 0,
primaryEmail: primaryEmailMap.get(row.id) ?? null,
primaryPhone: primaryPhoneMap.get(row.id) ?? null,
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
primaryPhoneE164: primaryPhoneE164Map.get(row.id) ?? null,
linkedBerths: linkedBerthsMap.get(row.id) ?? [],
latestInterest: latest
? {
stage: latest.stage,
mooringNumber: latest.mooringNumber,
}
: null,
};
}),
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
};
}
// ─── Get by ID ────────────────────────────────────────────────────────────────
export async function getClientById(id: string, portId: string) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, id),
});
if (!client || client.portId !== portId) {
throw new NotFoundError('Client');
}
const contacts = await db.query.clientContacts.findMany({
where: eq(clientContacts.clientId, id),
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
});
const addresses = await db.query.clientAddresses.findMany({
where: eq(clientAddresses.clientId, id),
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
});
const clientTagRows = await db
.select({ tag: tags })
.from(clientTags)
.innerJoin(tags, eq(clientTags.tagId, tags.id))
.where(eq(clientTags.clientId, id));
const yachtRows = await db.query.yachts.findMany({
where: and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'client'),
eq(yachts.currentOwnerId, id),
isNull(yachts.archivedAt),
),
columns: {
id: true,
name: true,
hullNumber: true,
registration: true,
lengthFt: true,
widthFt: true,
status: true,
},
});
const membershipRows = await db
.select({
membershipId: companyMemberships.id,
role: companyMemberships.role,
isPrimary: companyMemberships.isPrimary,
startDate: companyMemberships.startDate,
company: {
id: companies.id,
name: companies.name,
legalName: companies.legalName,
status: companies.status,
},
})
.from(companyMemberships)
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
.where(
and(
eq(companyMemberships.clientId, id),
eq(companies.portId, portId),
isNull(companyMemberships.endDate),
),
);
const activeReservations = await db.query.berthReservations.findMany({
where: and(
eq(berthReservations.clientId, id),
eq(berthReservations.portId, portId),
eq(berthReservations.status, 'active'),
),
columns: {
id: true,
berthId: true,
yachtId: true,
startDate: true,
tenureType: true,
status: true,
},
});
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
const portalEnabled = await isPortalEnabledForPort(portId);
// Counts surfaced for tab badges (Interests + Notes - Yachts/Companies/etc
fix(ux): pass-3 — yacht/company headers, reminder filters wrap, client tab counts Five small fixes from the third audit pass on previously-unchecked surfaces: Yacht detail header (mobile): - Stack the action cluster (Edit / Transfer / Archive) below the title block on phone widths. Previously the three buttons crowded the right side enough to truncate the status pill to "A..." and force the owner name to wrap to two lines. Same fix that landed for berth / client / company headers. Company detail header (mobile): - Same mobile stacking fix; legal-name + Tax-ID metadata no longer wraps awkwardly. Company detail Incorporation Date (all viewports): - Strip the time portion of the ISO timestamp before passing to the inline editor. Previously rendered the raw "2019-03-14T00:00:00.000Z" Postgres-serialized form. Now reads "2019-03-14" and round-trips through the YYYY-MM-DD inline editor cleanly. Reminders list filter row: - Allow flex-wrap on the My/All tabs + status filter + priority filter cluster. At 390px, the priority filter dropdown was being pushed off the right edge of the screen. Client detail tab counts: - Add interestCount + noteCount to getClientById response, surface as badges on the Interests + Notes tabs. Brings them into parity with Yachts/Companies/Reservations/Addresses which already showed counts; Files + Activity are still stubs and don't get a count yet. Verification: 0 tsc errors, 926/926 vitest passing, lint clean. Out of scope (deferred): - Residential clients / interests pages still render plain HTML tables on phone widths (header columns clip at the right edge). Needs the DataView card-on-mobile treatment that the main /clients and /interests pages already have. Substantial separate work. - Phone contacts in the legacy seed have value set but valueE164 NULL, so InlinePhoneField shows "—" even though metadata is technically populated. Fix is a one-time backfill via libphonenumber-js, not a UI change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:09:27 +02:00
// get their counts from the corresponding row arrays we already fetched).
const [interestCountRow] = await db
.select({ count: count() })
.from(interests)
.where(
and(eq(interests.portId, portId), eq(interests.clientId, id), isNull(interests.archivedAt)),
);
const [noteCountRow] = await db
.select({ count: count() })
.from(clientNotes)
.where(eq(clientNotes.clientId, id));
return {
...client,
contacts,
addresses,
tags: clientTagRows.map((r) => r.tag),
yachts: yachtRows,
companies: membershipRows,
activeReservations,
fix(ux): pass-3 — yacht/company headers, reminder filters wrap, client tab counts Five small fixes from the third audit pass on previously-unchecked surfaces: Yacht detail header (mobile): - Stack the action cluster (Edit / Transfer / Archive) below the title block on phone widths. Previously the three buttons crowded the right side enough to truncate the status pill to "A..." and force the owner name to wrap to two lines. Same fix that landed for berth / client / company headers. Company detail header (mobile): - Same mobile stacking fix; legal-name + Tax-ID metadata no longer wraps awkwardly. Company detail Incorporation Date (all viewports): - Strip the time portion of the ISO timestamp before passing to the inline editor. Previously rendered the raw "2019-03-14T00:00:00.000Z" Postgres-serialized form. Now reads "2019-03-14" and round-trips through the YYYY-MM-DD inline editor cleanly. Reminders list filter row: - Allow flex-wrap on the My/All tabs + status filter + priority filter cluster. At 390px, the priority filter dropdown was being pushed off the right edge of the screen. Client detail tab counts: - Add interestCount + noteCount to getClientById response, surface as badges on the Interests + Notes tabs. Brings them into parity with Yachts/Companies/Reservations/Addresses which already showed counts; Files + Activity are still stubs and don't get a count yet. Verification: 0 tsc errors, 926/926 vitest passing, lint clean. Out of scope (deferred): - Residential clients / interests pages still render plain HTML tables on phone widths (header columns clip at the right edge). Needs the DataView card-on-mobile treatment that the main /clients and /interests pages already have. Substantial separate work. - Phone contacts in the legacy seed have value set but valueE164 NULL, so InlinePhoneField shows "—" even though metadata is technically populated. Fix is a one-time backfill via libphonenumber-js, not a UI change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:09:27 +02:00
interestCount: interestCountRow?.count ?? 0,
noteCount: noteCountRow?.count ?? 0,
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
clientPortalEnabled: portalEnabled,
};
}
// ─── Create ───────────────────────────────────────────────────────────────────
export async function createClient(portId: string, data: CreateClientInput, meta: AuditMeta) {
const result = await withTransaction(async (tx) => {
const { contacts: contactsInput, tagIds, ...clientData } = data;
const [client] = await tx
.insert(clients)
.values({ portId, ...clientData })
.returning();
if (contactsInput.length > 0) {
await tx
.insert(clientContacts)
.values(contactsInput.map((c) => ({ clientId: client!.id, ...c })));
}
if (tagIds && tagIds.length > 0) {
await tx.insert(clientTags).values(tagIds.map((tagId) => ({ clientId: client!.id, tagId })));
}
return client!;
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'client',
entityId: result.id,
refactor(clients): drop deprecated yacht/company/proxy columns PR 13: now that all reads are migrated to the dedicated yacht / company / membership entities, drop the columns that mirrored them on `clients`: companyName, isProxy, proxyType, actualOwnerName, relationshipNotes, yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M}, berthSizeDesired. Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to apply. Caller cleanup (zero behavioral change to remaining flows): - Drops the legacy `generateEoi` flow entirely (route, service function, pdfme template, validator schema). The dual-path generate-and-sign service from PR 11 has fully replaced it; the route was no longer wired to the UI. - `clients.service`: company-name search column / WHERE / audit value removed; search now ranks by full name only. - `interests.service`: `resolveLeadCategory` reads dimensions from `yachts` via `interest.yachtId` instead of the dropped `client.yachtLength{Ft,M}`. - `record-export`: client-summary now lists yachts via owner-side lookup (direct + active company memberships); interest-summary fetches yacht via `interest.yachtId`. Both PDF templates updated to read yacht details from the new entity. - `client-detail-header`, `client-picker`, `command-search`, `search-result-item`, `use-search` hook, `types/domain.ts`, `search.service` — drop the companyName badge / sub-label / typed field everywhere it was rendered or fetched. - `ai.ts` worker: drop the company / yacht context lines from the prompt (will be re-added later sourced from the new entities). - `validators/interests.ts`: remove the deprecated public-form flat yacht/company fields. The route already ignores them. - `factories.ts`: drop the `isProxy: false` default. Tests: 652/652 green; type-check clean. The `security-sensitive-data` tests use `companyName` / `isProxy` as arbitrary record keys for a generic util — left unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
newValue: { fullName: result.fullName },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:created', {
clientId: result.id,
clientName: result.fullName ?? '',
source: result.source ?? '',
});
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'client:created', { clientId: result.id }),
);
return result;
}
// ─── Update ───────────────────────────────────────────────────────────────────
export async function updateClient(
id: string,
portId: string,
data: UpdateClientInput,
meta: AuditMeta,
) {
const existing = await db.query.clients.findFirst({
where: eq(clients.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Client');
}
const { diff } = diffEntity(existing as Record<string, unknown>, data as Record<string, unknown>);
const [updated] = await db
.update(clients)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(clients.id, id), eq(clients.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'client',
entityId: id,
oldValue: diff as Record<string, unknown>,
newValue: data as Record<string, unknown>,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:updated', {
clientId: id,
changedFields: Object.keys(diff),
});
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'client:updated', { clientId: id }),
);
if (data.fullName !== undefined) {
await syncEntityFolderName(portId, 'client', id, meta.userId).catch((err) => {
logger.warn({ err, clientId: id }, 'Failed to sync client folder name');
});
}
return updated;
}
// ─── Archive / Restore ────────────────────────────────────────────────────────
export async function archiveClient(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.clients.findFirst({
where: eq(clients.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Client');
}
await softDelete(clients, clients.id, id);
// fire-and-forget: archive UI does not depend on the folder suffix
// being stamped before the HTTP response returns. Task 5 (rename
// hook) uses await because the rename should be visible to the
// next read; archive does not.
void applyEntityArchivedSuffix(portId, 'client', id, meta.userId).catch((err) => {
logger.warn(
{ err, clientId: id, portId },
'Failed to apply archived suffix to client folder',
);
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'archive',
entityType: 'client',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:archived', { clientId: id });
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'client:archived', { clientId: id }),
);
}
export async function restoreClient(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.clients.findFirst({
where: eq(clients.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Client');
}
await restore(clients, clients.id, id);
void applyEntityRestoredSuffix(portId, 'client', id, meta.userId).catch((err) => {
logger.warn(
{ err, clientId: id, portId },
'Failed to clear archived suffix on client folder',
);
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'restore',
entityType: 'client',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:restored', { clientId: id });
}
// ─── Contacts ─────────────────────────────────────────────────────────────────
export async function listContacts(clientId: string, portId: string) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
return db.query.clientContacts.findMany({
where: eq(clientContacts.clientId, clientId),
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
});
}
export async function addContact(
clientId: string,
portId: string,
feat(i18n): country/phone/timezone/subdivision primitives + form wiring Cross-cutting i18n polish for forms across the marina + residential + company domains. Introduces a single source of truth for country/phone/timezone/ subdivision data and replaces every nationality-as-free-text and timezone- as-string Input with a dedicated combobox. PR1 Countries — ALL_COUNTRY_CODES (~250 ISO-3166-1 alpha-2), Intl.DisplayNames for localized labels, detectDefaultCountry() with navigator-region fallback to US, CountryCombobox with regional-indicator flag glyphs + compact mode for inline use. PR2 Phone — libphonenumber-js wrapper (parsePhone / formatAsYouType / callingCodeFor), PhoneInput with flag dropdown + national-format AsYouType + paste-detect that flips the country dropdown for pasted international strings. PR3 Timezones — country->IANA map (250 entries, multi-zone for AU/BR/CA/CD/ ID/KZ/MN/MX/RU/US), formatTimezoneLabel ("Europe/London (UTC+1)"), TimezoneCombobox with Suggested/All grouping driven by countryHint. PR4 Subdivisions — wraps the iso-3166-2 npm package (~5000 ISO 3166-2 codes for every country), per-country cache, SubdivisionCombobox with "Pick a country first" / "No regions available" empty states. PR5 Schema deltas (migration 0015) — clients.nationality_iso, clientContacts {value_e164, value_country}, clientAddresses {country_iso, subdivision_iso}, residentialClients {phone_e164, phone_country, nationality_iso, timezone, place_of_residence_country_iso, subdivision_iso}, companies {incorporation_ country_iso, incorporation_subdivision_iso}, companyAddresses {country_iso, subdivision_iso}. Plus shared zod validators (validators/i18n.ts) used by every entity validator + route handler. PR6 ClientForm + ClientDetail — CountryCombobox replaces nationality Input, TimezoneCombobox replaces timezone Input (driven by nationalityIso hint), PhoneInput conditionally rendered for phone/whatsapp contacts. Inline editors (InlineCountryField / InlineTimezoneField / InlinePhoneField) for the detail-page overview rows + ContactsEditor. PR7 Residential client form + detail — phone -> PhoneInput, nationality/ timezone/place-of-residence-country/subdivision rows in both create sheet and inline-editable detail view. Subdivision wipes when country flips since codes are country-scoped. PR8 Company form + detail — incorporation country -> CountryCombobox, incorporation region -> SubdivisionCombobox in both modes. PR9 Public inquiry endpoint — accepts pre-normalized phoneE164/phoneCountry and i18n fields from newer website builds, server-side parsePhone() fallback for legacy raw-international submissions. Old Nuxt builds keep working unchanged. Tests: 4 unit suites for the primitives (25 tests), 1 integration spec for the public phone-normalization path (3 tests), 1 smoke spec asserting the combobox triggers render in all three create sheets. Test totals: vitest 713 -> 741 (+28). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:13:08 +02:00
data: {
channel: string;
value: string;
valueE164?: string | null;
valueCountry?: string | null;
label?: string;
isPrimary?: boolean;
notes?: string;
},
meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
const [contact] = await db
.insert(clientContacts)
.values({ clientId, ...data })
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'clientContact',
entityId: contact!.id,
newValue: { clientId, channel: contact!.channel },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
return contact!;
}
export async function updateContact(
contactId: string,
clientId: string,
portId: string,
data: Partial<{
channel: string;
value: string;
feat(i18n): country/phone/timezone/subdivision primitives + form wiring Cross-cutting i18n polish for forms across the marina + residential + company domains. Introduces a single source of truth for country/phone/timezone/ subdivision data and replaces every nationality-as-free-text and timezone- as-string Input with a dedicated combobox. PR1 Countries — ALL_COUNTRY_CODES (~250 ISO-3166-1 alpha-2), Intl.DisplayNames for localized labels, detectDefaultCountry() with navigator-region fallback to US, CountryCombobox with regional-indicator flag glyphs + compact mode for inline use. PR2 Phone — libphonenumber-js wrapper (parsePhone / formatAsYouType / callingCodeFor), PhoneInput with flag dropdown + national-format AsYouType + paste-detect that flips the country dropdown for pasted international strings. PR3 Timezones — country->IANA map (250 entries, multi-zone for AU/BR/CA/CD/ ID/KZ/MN/MX/RU/US), formatTimezoneLabel ("Europe/London (UTC+1)"), TimezoneCombobox with Suggested/All grouping driven by countryHint. PR4 Subdivisions — wraps the iso-3166-2 npm package (~5000 ISO 3166-2 codes for every country), per-country cache, SubdivisionCombobox with "Pick a country first" / "No regions available" empty states. PR5 Schema deltas (migration 0015) — clients.nationality_iso, clientContacts {value_e164, value_country}, clientAddresses {country_iso, subdivision_iso}, residentialClients {phone_e164, phone_country, nationality_iso, timezone, place_of_residence_country_iso, subdivision_iso}, companies {incorporation_ country_iso, incorporation_subdivision_iso}, companyAddresses {country_iso, subdivision_iso}. Plus shared zod validators (validators/i18n.ts) used by every entity validator + route handler. PR6 ClientForm + ClientDetail — CountryCombobox replaces nationality Input, TimezoneCombobox replaces timezone Input (driven by nationalityIso hint), PhoneInput conditionally rendered for phone/whatsapp contacts. Inline editors (InlineCountryField / InlineTimezoneField / InlinePhoneField) for the detail-page overview rows + ContactsEditor. PR7 Residential client form + detail — phone -> PhoneInput, nationality/ timezone/place-of-residence-country/subdivision rows in both create sheet and inline-editable detail view. Subdivision wipes when country flips since codes are country-scoped. PR8 Company form + detail — incorporation country -> CountryCombobox, incorporation region -> SubdivisionCombobox in both modes. PR9 Public inquiry endpoint — accepts pre-normalized phoneE164/phoneCountry and i18n fields from newer website builds, server-side parsePhone() fallback for legacy raw-international submissions. Old Nuxt builds keep working unchanged. Tests: 4 unit suites for the primitives (25 tests), 1 integration spec for the public phone-normalization path (3 tests), 1 smoke spec asserting the combobox triggers render in all three create sheets. Test totals: vitest 713 -> 741 (+28). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:13:08 +02:00
valueE164: string | null;
valueCountry: string | null;
label: string;
isPrimary: boolean;
notes: string;
}>,
_meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
const contact = await db.query.clientContacts.findFirst({
where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
});
if (!contact) throw new NotFoundError('Contact');
const [updated] = await db
.update(clientContacts)
.set({ ...data, updatedAt: new Date() })
.where(eq(clientContacts.id, contactId))
.returning();
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
return updated;
}
export async function removeContact(
contactId: string,
clientId: string,
portId: string,
_meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
const contact = await db.query.clientContacts.findFirst({
where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
});
if (!contact) throw new NotFoundError('Contact');
await db.delete(clientContacts).where(eq(clientContacts.id, contactId));
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
}
// ─── Addresses ────────────────────────────────────────────────────────────────
interface AddressInput {
label?: string;
streetAddress?: string | null;
city?: string | null;
subdivisionIso?: string | null;
postalCode?: string | null;
countryIso?: string | null;
isPrimary?: boolean;
}
export async function listClientAddresses(clientId: string, portId: string) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
return db.query.clientAddresses.findMany({
where: eq(clientAddresses.clientId, clientId),
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
});
}
export async function addClientAddress(
clientId: string,
portId: string,
data: AddressInput,
meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
// The unique partial index requires us to demote any existing primary
// before inserting a new one. We grab a row lock on the client to
// serialize concurrent primary-toggle requests against the same client -
// without this, two simultaneous "isPrimary=true" inserts can both
// observe "no existing primary" and one trips the unique index with a
// 5xx instead of being safely ordered.
const address = await withTransaction(async (tx) => {
await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update');
const wantsPrimary = data.isPrimary ?? false;
if (wantsPrimary) {
await tx
.update(clientAddresses)
.set({ isPrimary: false })
.where(and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)));
}
const [row] = await tx
.insert(clientAddresses)
.values({
clientId,
portId,
label: data.label ?? 'Primary',
streetAddress: data.streetAddress ?? null,
city: data.city ?? null,
subdivisionIso: data.subdivisionIso ?? null,
postalCode: data.postalCode ?? null,
countryIso: data.countryIso ?? null,
isPrimary: wantsPrimary,
})
.returning();
return row!;
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'clientAddress',
entityId: address.id,
newValue: { clientId, label: address.label, countryIso: address.countryIso },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] });
return address;
}
export async function updateClientAddress(
addressId: string,
clientId: string,
portId: string,
data: AddressInput,
_meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
const existing = await db.query.clientAddresses.findFirst({
where: and(eq(clientAddresses.id, addressId), eq(clientAddresses.clientId, clientId)),
});
if (!existing) throw new NotFoundError('Address');
const updated = await withTransaction(async (tx) => {
// Lock the client row to serialize primary-toggle changes - see addClientAddress.
await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update');
if (data.isPrimary === true && !existing.isPrimary) {
await tx
.update(clientAddresses)
.set({ isPrimary: false })
.where(and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)));
}
const [row] = await tx
.update(clientAddresses)
.set({ ...data, updatedAt: new Date() })
.where(eq(clientAddresses.id, addressId))
.returning();
return row!;
});
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] });
return updated;
}
export async function removeClientAddress(
addressId: string,
clientId: string,
portId: string,
_meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
const address = await db.query.clientAddresses.findFirst({
where: and(eq(clientAddresses.id, addressId), eq(clientAddresses.clientId, clientId)),
});
if (!address) throw new NotFoundError('Address');
await db.delete(clientAddresses).where(eq(clientAddresses.id, addressId));
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] });
}
// ─── Tags ─────────────────────────────────────────────────────────────────────
export async function setClientTags(
clientId: string,
portId: string,
tagIds: string[],
meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
await setEntityTags({
joinTable: clientTags,
entityColumn: clientTags.clientId,
tagColumn: clientTags.tagId,
entityId: clientId,
portId,
tagIds,
meta,
entityType: 'client',
});
}
// ─── Relationships ────────────────────────────────────────────────────────────
export async function listRelationships(clientId: string, portId: string) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
return db.query.clientRelationships.findMany({
where: (r, { and, or, eq }) =>
and(eq(r.portId, portId), or(eq(r.clientAId, clientId), eq(r.clientBId, clientId))),
});
}
export async function createRelationship(
clientId: string,
portId: string,
data: { clientBId: string; relationshipType: string; description?: string },
meta: AuditMeta,
) {
if (data.clientBId === clientId) {
throw new ValidationError('A client cannot have a relationship to themselves');
}
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
// Tenant scope: clientBId arrives from the request body. Without this check
// a port-A caller could splice a port-B client UUID onto their own client's
// relationship row; the GET handler joins clientRelationships → clients with
// no port filter and would surface the foreign client's name + email.
const otherClient = await db.query.clients.findFirst({
where: and(eq(clients.id, data.clientBId), eq(clients.portId, portId)),
});
if (!otherClient) throw new ValidationError('clientBId not found in this port');
const [rel] = await db
.insert(clientRelationships)
.values({ portId, clientAId: clientId, ...data })
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'clientRelationship',
entityId: rel!.id,
newValue: { clientAId: clientId, clientBId: data.clientBId, type: data.relationshipType },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return rel!;
}
export async function deleteRelationship(
relId: string,
clientId: string,
portId: string,
meta: AuditMeta,
) {
const rel = await db.query.clientRelationships.findFirst({
where: eq(clientRelationships.id, relId),
});
if (!rel || rel.portId !== portId) throw new NotFoundError('Relationship');
await db
.delete(clientRelationships)
.where(and(eq(clientRelationships.id, relId), eq(clientRelationships.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
entityType: 'clientRelationship',
entityId: relId,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
// ─── Find Duplicates ──────────────────────────────────────────────────────────
export async function findDuplicates(portId: string, fullName: string) {
return db.query.clients.findMany({
where: (c, { and, eq }) => and(eq(c.portId, portId), ilike(c.fullName, `%${fullName}%`)),
limit: 5,
});
}
// ─── Options (for comboboxes) ─────────────────────────────────────────────────
export async function listClientOptions(portId: string, search?: string) {
chore(cleanup): Phase 1 — gap closure across audit, alerts, soft-delete, perms Multi-area cleanup pass closing partial-implementation gaps surfaced by the post-i18n audit. No behavior changes for happy-path users; closes real correctness/security holes. PR1a Public yacht-interest endpoint i18n. /api/public/interests now accepts phoneE164/phoneCountry, nationalityIso, address.{countryIso, subdivisionIso}, and company.{incorporationCountryIso, incorporationSubdivisionIso}. Server-side parsePhone() fallback for legacy raw phone strings. PR1b Alert rule registry trim. Two rule slots ('document.expiring_soon', 'audit.suspicious_login') were registered but evaluators returned []. Both required schema/instrumentation that hadn't landed. Removed from the registry; comments record the dependencies needed to revive them. Effective rule count: 8 active. PR1c vi.mock hoist + flake fix. Hoisted vi.mock calls to top-level in 5 integration test files; webhook-delivery uses vi.hoisted for the queue-add ref. Vitest no longer warns about non-top-level mocks. Deflaked the 'short value' assertion in security-encryption.test.ts by switching plaintext from 'ab' to 'XY' (non-hex chars). 5/5 runs green. PR1d Soft-delete reference audit. listClientOptions and listYachtsForOwner now filter by isNull(archivedAt). Berths use status (no archivedAt). PR1e Permission-matrix audit script + report. scripts/audit-permissions.ts walks every src/app/api/v1/**/route.ts and reports handlers without a withPermission() wrapper. Initial run found 33 violations. - Allow-listed 17 with explicit reasons (self-data, admin, alerts, search, currency, ai, custom-fields — some marked TODO). - Wrapped 7 routes with concrete permissions: clients/options (clients:view), berths/options (berths:view), dashboard/* (reports:view_dashboard), analytics (reports:view_analytics). Audit report at docs/runbooks/permission-audit.md. Script exits non-zero on any unallow-listed violation so it can become a CI gate. Vitest: 741 -> 741 (no new tests; existing suite covers the changes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:48:22 +02:00
// Pickers only surface active rows. Archived clients are still resolvable
// by id (e.g. history views) but should not appear in dropdowns.
const conditions = [eq(clients.portId, portId), isNull(clients.archivedAt)];
if (search) {
refactor(clients): drop deprecated yacht/company/proxy columns PR 13: now that all reads are migrated to the dedicated yacht / company / membership entities, drop the columns that mirrored them on `clients`: companyName, isProxy, proxyType, actualOwnerName, relationshipNotes, yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M}, berthSizeDesired. Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to apply. Caller cleanup (zero behavioral change to remaining flows): - Drops the legacy `generateEoi` flow entirely (route, service function, pdfme template, validator schema). The dual-path generate-and-sign service from PR 11 has fully replaced it; the route was no longer wired to the UI. - `clients.service`: company-name search column / WHERE / audit value removed; search now ranks by full name only. - `interests.service`: `resolveLeadCategory` reads dimensions from `yachts` via `interest.yachtId` instead of the dropped `client.yachtLength{Ft,M}`. - `record-export`: client-summary now lists yachts via owner-side lookup (direct + active company memberships); interest-summary fetches yacht via `interest.yachtId`. Both PDF templates updated to read yacht details from the new entity. - `client-detail-header`, `client-picker`, `command-search`, `search-result-item`, `use-search` hook, `types/domain.ts`, `search.service` — drop the companyName badge / sub-label / typed field everywhere it was rendered or fetched. - `ai.ts` worker: drop the company / yacht context lines from the prompt (will be re-added later sourced from the new entities). - `validators/interests.ts`: remove the deprecated public-form flat yacht/company fields. The route already ignores them. - `factories.ts`: drop the `isProxy: false` default. Tests: 652/652 green; type-check clean. The `security-sensitive-data` tests use `companyName` / `isProxy` as arbitrary record keys for a generic util — left unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
conditions.push(ilike(clients.fullName, `%${search}%`));
}
return db
.select({ id: clients.id, fullName: clients.fullName })
.from(clients)
.where(and(...conditions))
.orderBy(clients.fullName)
.limit(50);
}