feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones

Mobile + responsive
- berth-form full-width on phones (was 480px fixed → overflowed iPhone)
- currency-input switched to inputMode=decimal with live thousands separator
- client-form Country/Timezone/Source/Preferred-Contact full-width <sm
- contacts row restructured so Primary toggle + Remove get their own strip
- customize-dashboard footer stacks vertically on mobile; Done full-width
- interest-form client/berth pickers no longer cmdk-filter on UUID (typing
  "Carlos" now returns Carlos Vega instead of "No clients found")

Data + consistency
- SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces
  now resolve interest/client source from one place
- INTEREST_OUTCOMES adds lost_other (picker, badge, timeline)
- Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort
- archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles
- TableBody last-row uses border-b-0 (not border-0); colored left-accent
  on the bottom berth row now renders
- Hide Invite-to-Portal until port setting === true (was !== false default-show)
- OwnerPicker primer query resolves entity name on first paint (no more
  UUID flash before the popover opens)

Terminology
- Replaced user-facing "Documenso" with "signing service" / "Generated EOI" /
  "Manual EOI" in 8 components (admin/internal references kept)
- Plainer status-change copy on berth-detail-header

Forms + editing
- InlineEditableField gained a `date` variant (native picker); applied to
  company incorporation date and ready for other YYYY-MM-DD plaintext fields
- Inline source picker on interest-tabs detail (was free text)
- TagPicker self-hides when port has no tags AND nothing is selected
- New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom)
- Compose dialog follow-up is now a toggle that reveals datetime picker

Pipeline milestones
- changeStageSchema accepts optional milestoneDate; service stamps it on the
  matching date column instead of always using now
- MilestoneAdvanceButton popover collects a back-date before stage advance
- Applied to every "Mark X manually" surface on the interest overview

EOI / linked-berths polish
- Add-bypass row aligned inline with toggle descriptions
- Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their
  legal vs. public-map consequences

Surfaces
- Companies list now has the column picker + persisted hidden-column prefs
- NotesList aggregate flag enabled on clients, companies, residential_clients
  (yachts already aggregated)

ft/m unit toggle (interim, before drift fix)
- "Berth size desired" gets a section-level ft/m toggle; per-field hint shows
  the converted value. Storage stays canonical-ft for now; the drift-safe
  persistence migration is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:50:58 +02:00
parent 638000bb58
commit 3ffee79f3f
132 changed files with 5784 additions and 997 deletions

View File

@@ -10,7 +10,9 @@ import { NotFoundError, ValidationError } from '@/lib/errors';
import { buildListQuery } from '@/lib/db/query-builder';
import { emitToRoom } from '@/lib/socket/server';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
import { getPortBerthsDefaultCurrency } from '@/lib/services/port-config';
import { ConflictError } from '@/lib/errors';
import { sortByMooring } from '@/lib/utils/mooring-sort';
import type {
CreateBerthInput,
UpdateBerthInput,
@@ -487,6 +489,12 @@ export async function createBerth(portId: string, data: CreateBerthInput, meta:
throw new ConflictError(`Berth "${data.mooringNumber}" already exists in this port`);
}
// Caller-specified currency wins; otherwise inherit the port's admin-
// configured default (system_settings.berths_default_currency, USD if
// unset). Lets a multi-currency portfolio be modelled cleanly without
// forcing reps to pick a currency on every new-berth form.
const resolvedCurrency = data.priceCurrency ?? (await getPortBerthsDefaultCurrency(portId));
const [berth] = await db
.insert(berths)
.values({
@@ -501,7 +509,7 @@ export async function createBerth(portId: string, data: CreateBerthInput, meta:
draftFt: data.draftFt?.toString(),
draftM: data.draftM?.toString(),
price: data.price?.toString(),
priceCurrency: data.priceCurrency ?? 'USD',
priceCurrency: resolvedCurrency,
tenureType: data.tenureType ?? 'permanent',
mooringType: data.mooringType,
powerCapacity: data.powerCapacity?.toString(),
@@ -563,7 +571,10 @@ export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
// ─── Options ──────────────────────────────────────────────────────────────────
export async function getBerthOptions(portId: string) {
return db
// DB-side `ORDER BY mooring_number` is lexicographic (A1, A10, A11, A2…).
// Natural-sort in JS so dropdowns surface them as reps read them: A1, A2,
// …, A10, A11. See compareMooringNumbers for the prefix/index split.
const rows = await db
.select({
id: berths.id,
mooringNumber: berths.mooringNumber,
@@ -571,6 +582,6 @@ export async function getBerthOptions(portId: string) {
status: berths.status,
})
.from(berths)
.where(eq(berths.portId, portId))
.orderBy(berths.mooringNumber);
.where(eq(berths.portId, portId));
return sortByMooring(rows, (r) => r.mooringNumber);
}

View File

@@ -27,7 +27,7 @@ import type {
ListCompaniesInput,
} from '@/lib/validators/companies';
type CreateCompanyInput = z.input<typeof createCompanySchema>;
type CreateCompanyInput = z.output<typeof createCompanySchema>;
export type { Company };

View File

@@ -1,9 +1,14 @@
import { and, count, desc, eq, isNull, sql } from 'drizzle-orm';
import { and, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
import { yachts } from '@/lib/db/schema/yachts';
import { companies } from '@/lib/db/schema/companies';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { invoices, expenses } from '@/lib/db/schema/financial';
import { documents } from '@/lib/db/schema/documents';
import { reminders } from '@/lib/db/schema/operations';
import { systemSettings, auditLogs } from '@/lib/db/schema/system';
import { PIPELINE_STAGES, STAGE_WEIGHTS } from '@/lib/constants';
@@ -156,6 +161,119 @@ export async function getRevenueForecast(portId: string) {
};
}
// ─── Compact widget queries ───────────────────────────────────────────────────
/**
* Berth status split for the donut widget. Returns counts plus the total
* so the chart can show "12 of 47 sold" alongside the segment percentage.
*/
export async function getBerthStatusDistribution(portId: string) {
const rows = await db
.select({ status: berths.status, c: sql<number>`count(*)::int` })
.from(berths)
.where(eq(berths.portId, portId))
.groupBy(berths.status);
const counts: Record<string, number> = {};
for (const r of rows) counts[r.status] = r.c;
const total = Object.values(counts).reduce((a, b) => a + b, 0);
return {
total,
available: counts['available'] ?? 0,
underOffer: counts['under_offer'] ?? 0,
sold: counts['sold'] ?? 0,
maintenance: counts['maintenance'] ?? 0,
};
}
/**
* Top 5 active interests closest to closing — ranked by pipeline stage
* (further = closer to closing) with most-recent activity as a
* tiebreaker. Surfaces the deals reps should actually be chasing on the
* dashboard without making them open the pipeline board.
*/
export async function getHotDeals(portId: string, limit = 5) {
// Stage rank: bigger = closer to closing.
const rank = sql<number>`CASE ${interests.pipelineStage}
WHEN 'completed' THEN 8
WHEN 'contract_signed' THEN 7
WHEN 'contract_sent' THEN 6
WHEN 'deposit_10' THEN 5
WHEN 'eoi_signed' THEN 4
WHEN 'eoi_sent' THEN 3
WHEN 'in_comms' THEN 2
WHEN 'details_sent' THEN 1
ELSE 0
END`;
const rows = await db
.select({
id: interests.id,
stage: interests.pipelineStage,
clientName: clients.fullName,
mooring: berths.mooringNumber,
lastContact: interests.dateLastContact,
updatedAt: interests.updatedAt,
rank,
})
.from(interests)
.innerJoin(clients, eq(interests.clientId, clients.id))
.leftJoin(
interestBerths,
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
)
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
.where(
and(
eq(interests.portId, portId),
isNull(interests.archivedAt),
isNull(interests.outcome), // exclude won/lost — they're not "closing"
),
)
.orderBy(desc(rank), desc(interests.updatedAt))
.limit(limit);
return rows.map((r) => ({
id: r.id,
stage: r.stage,
clientName: r.clientName,
mooringNumber: r.mooring,
lastContact: r.lastContact ? r.lastContact.toISOString() : null,
}));
}
/**
* Source-conversion breakdown for the marketing widget. Returns per-
* source totals (active + won + lost) and a derived conversion rate so
* reps see which channels deliver buyers vs tire-kickers — orthogonal
* to the existing "lead source attribution" chart which only counts
* inbound volume.
*/
export async function getSourceConversion(portId: string) {
const rows = await db
.select({
source: interests.source,
total: sql<number>`count(*)::int`,
won: sql<number>`sum(case when ${interests.outcome} = 'won' then 1 else 0 end)::int`,
lost: sql<number>`sum(case when ${interests.outcome} = 'lost' then 1 else 0 end)::int`,
})
.from(interests)
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt)))
.groupBy(interests.source);
return rows
.filter((r) => r.source)
.map((r) => ({
source: r.source!,
total: r.total,
won: r.won,
lost: r.lost,
conversionRate: r.total > 0 ? r.won / r.total : 0,
}))
.sort((a, b) => b.total - a.total);
}
// ─── Recent Activity ──────────────────────────────────────────────────────────
export async function getRecentActivity(portId: string, limit = 20) {
@@ -166,6 +284,9 @@ export async function getRecentActivity(portId: string, limit = 20) {
entityType: auditLogs.entityType,
entityId: auditLogs.entityId,
userId: auditLogs.userId,
fieldChanged: auditLogs.fieldChanged,
oldValue: auditLogs.oldValue,
newValue: auditLogs.newValue,
metadata: auditLogs.metadata,
createdAt: auditLogs.createdAt,
})
@@ -174,5 +295,121 @@ export async function getRecentActivity(portId: string, limit = 20) {
.orderBy(desc(auditLogs.createdAt))
.limit(limit);
return rows;
// Resolve a human label per row (client name, yacht name, invoice number,
// …). The dashboard widget previously rendered the bare UUID prefix which
// told reps nothing about which entity was touched. We batch one SELECT
// per entityType, capping at the row set's natural size (<= `limit`).
const byType = new Map<string, Set<string>>();
for (const r of rows) {
if (!r.entityId) continue;
if (!byType.has(r.entityType)) byType.set(r.entityType, new Set());
byType.get(r.entityType)!.add(r.entityId);
}
const labels = new Map<string, string>(); // `${type}:${id}` → label
async function loadLabels<T extends { id: string }>(
type: string,
fetcher: (ids: string[]) => Promise<T[]>,
pick: (row: T) => string,
) {
const ids = Array.from(byType.get(type) ?? []);
if (ids.length === 0) return;
const fetched = await fetcher(ids);
for (const row of fetched) labels.set(`${type}:${row.id}`, pick(row));
}
await Promise.all([
loadLabels(
'client',
(ids) =>
db
.select({ id: clients.id, name: clients.fullName })
.from(clients)
.where(and(eq(clients.portId, portId), inArray(clients.id, ids))),
(r) => r.name,
),
loadLabels(
'yacht',
(ids) =>
db
.select({ id: yachts.id, name: yachts.name })
.from(yachts)
.where(and(eq(yachts.portId, portId), inArray(yachts.id, ids))),
(r) => r.name,
),
loadLabels(
'company',
(ids) =>
db
.select({ id: companies.id, name: companies.name })
.from(companies)
.where(and(eq(companies.portId, portId), inArray(companies.id, ids))),
(r) => r.name,
),
loadLabels(
'interest',
(ids) =>
db
.select({ id: interests.id, clientName: clients.fullName })
.from(interests)
.innerJoin(clients, eq(interests.clientId, clients.id))
.where(and(eq(interests.portId, portId), inArray(interests.id, ids))),
(r) => r.clientName,
),
loadLabels(
'berth',
(ids) =>
db
.select({ id: berths.id, mooring: berths.mooringNumber })
.from(berths)
.where(and(eq(berths.portId, portId), inArray(berths.id, ids))),
(r) => `Berth ${r.mooring}`,
),
loadLabels(
'invoice',
(ids) =>
db
.select({ id: invoices.id, num: invoices.invoiceNumber })
.from(invoices)
.where(and(eq(invoices.portId, portId), inArray(invoices.id, ids))),
(r) => r.num,
),
loadLabels(
'expense',
(ids) =>
db
.select({
id: expenses.id,
desc: expenses.description,
vendor: expenses.establishmentName,
})
.from(expenses)
.where(and(eq(expenses.portId, portId), inArray(expenses.id, ids))),
(r) => r.desc ?? r.vendor ?? 'Expense',
),
loadLabels(
'document',
(ids) =>
db
.select({ id: documents.id, title: documents.title })
.from(documents)
.where(and(eq(documents.portId, portId), inArray(documents.id, ids))),
(r) => r.title,
),
loadLabels(
'reminder',
(ids) =>
db
.select({ id: reminders.id, title: reminders.title })
.from(reminders)
.where(and(eq(reminders.portId, portId), inArray(reminders.id, ids))),
(r) => r.title,
),
]);
return rows.map((r) => ({
...r,
label: r.entityId ? (labels.get(`${r.entityType}:${r.entityId}`) ?? null) : null,
}));
}

View File

@@ -778,13 +778,16 @@ export async function changeInterestStage(
.where(and(eq(interests.id, id), eq(interests.portId, portId)))
.returning();
// BR-133: Auto-populate milestones based on stage
// BR-133: Auto-populate milestones based on stage. The rep can override the
// stamp via `milestoneDate` when they're back-dating a real event (e.g.
// "deposit landed yesterday"); we still default to now when omitted.
const milestoneDate = data.milestoneDate ? new Date(data.milestoneDate) : new Date();
const milestoneUpdates: Record<string, unknown> = {};
if (data.pipelineStage === 'eoi_sent') milestoneUpdates.dateEoiSent = new Date();
if (data.pipelineStage === 'eoi_signed') milestoneUpdates.dateEoiSigned = new Date();
if (data.pipelineStage === 'deposit_10pct') milestoneUpdates.dateDepositReceived = new Date();
if (data.pipelineStage === 'contract_sent') milestoneUpdates.dateContractSent = new Date();
if (data.pipelineStage === 'contract_signed') milestoneUpdates.dateContractSigned = new Date();
if (data.pipelineStage === 'eoi_sent') milestoneUpdates.dateEoiSent = milestoneDate;
if (data.pipelineStage === 'eoi_signed') milestoneUpdates.dateEoiSigned = milestoneDate;
if (data.pipelineStage === 'deposit_10pct') milestoneUpdates.dateDepositReceived = milestoneDate;
if (data.pipelineStage === 'contract_sent') milestoneUpdates.dateContractSent = milestoneDate;
if (data.pipelineStage === 'contract_signed') milestoneUpdates.dateContractSigned = milestoneDate;
if (Object.keys(milestoneUpdates).length > 0) {
await db
.update(interests)

View File

@@ -101,6 +101,9 @@ export const SETTING_KEYS = {
reminderDigestEnabled: 'reminder_digest_enabled',
reminderDigestTime: 'reminder_digest_time',
reminderDigestTimezone: 'reminder_digest_timezone',
// Berths
berthsDefaultCurrency: 'berths_default_currency',
} as const;
// ─── Helper ──────────────────────────────────────────────────────────────────
@@ -442,6 +445,16 @@ const DEFAULT_REMINDER: PortReminderConfig = {
digestTimezone: 'Europe/Warsaw',
};
/**
* Port-level default currency for newly-created berths. Per-berth
* `priceCurrency` overrides this when set. Defaults to USD because
* 95% of marinas in the rollout target are USD-denominated.
*/
export async function getPortBerthsDefaultCurrency(portId: string): Promise<string> {
const value = await readSetting<string>(SETTING_KEYS.berthsDefaultCurrency, portId);
return (value ?? 'USD').trim().toUpperCase() || 'USD';
}
export async function getPortReminderConfig(portId: string): Promise<PortReminderConfig> {
const [defaultDays, defaultEnabled, digestEnabled, digestTime, digestTimezone] =
await Promise.all([

View File

@@ -48,12 +48,25 @@ import type { RolePermissions } from '@/lib/db/schema/users';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Provenance hint for a result row that was surfaced via graph expansion
* rather than a direct query match. The frontend renders this as a
* subtitle, e.g. "via Berth A10". `null` (or absent) means the row is
* a direct match against the user's query.
*/
export interface RelatedVia {
type: 'berth' | 'interest' | 'client' | 'yacht' | 'company';
id: string;
label: string;
}
export interface ClientResult {
id: string;
fullName: string;
matchedContact: string | null;
matchedContactChannel: 'email' | 'phone' | 'whatsapp' | null;
archivedAt: string | null;
relatedVia?: RelatedVia | null;
}
export interface ResidentialClientResult {
@@ -71,6 +84,7 @@ export interface InterestResult {
berthMooringNumber: string | null;
pipelineStage: string;
outcome: string | null;
relatedVia?: RelatedVia | null;
}
export interface ResidentialInterestResult {
@@ -85,6 +99,7 @@ export interface BerthResult {
area: string | null;
status: string;
linkedInterestCount: number;
relatedVia?: RelatedVia | null;
}
export interface YachtResult {
@@ -93,6 +108,7 @@ export interface YachtResult {
hullNumber: string | null;
registration: string | null;
archivedAt: string | null;
relatedVia?: RelatedVia | null;
}
export interface CompanyResult {
@@ -102,6 +118,7 @@ export interface CompanyResult {
taxId: string | null;
matchedField: 'name' | 'legalName' | 'taxId' | 'billingEmail' | 'registrationNumber' | null;
archivedAt: string | null;
relatedVia?: RelatedVia | null;
}
export interface InvoiceResult {
@@ -716,10 +733,60 @@ async function searchResidentialInterests(
}
async function searchBerths(portId: string, query: string, limit: number): Promise<BerthResult[]> {
// Trigram (`%`) is the canonical mooring-number search — it tolerates
// a hyphen or wrong leading-zero. Fallback to ILIKE for `area`.
const ilikePattern = `%${query}%`;
// Mooring numbers are short alphanumeric codes (A1, B12, E18) where
// prefix-on-number expansion produces confusing UX — typing "A1"
// when A1 exists shouldn't *also* surface A10, A11, A12. Reps know
// mooring numbers and almost always type them in full.
//
// Strategy: if an exact mooring-number match exists for the query,
// return ONLY that one row. Otherwise fall back to letter-prefix +
// number-prefix matching (so typing "A" returns the whole A dock,
// typing "A1" with no A1 in the DB returns A10/A11/A12, etc.).
// Area-name matches are also folded into the fallback.
const trimmed = query.trim();
const m = /^([A-Za-z]*)(\d*)$/.exec(trimmed);
const letterPart = (m?.[1] ?? '').toUpperCase();
const numberPart = m?.[2] ?? '';
const isStructured = letterPart.length > 0 || numberPart.length > 0;
const ilikePattern = `%${trimmed}%`;
const prefixPattern = `${trimmed}%`;
// First: try for an exact match. Cheap — uses the unique-index on
// (port_id, mooring_number).
const exact = await db.execute<{
id: string;
mooring_number: string;
area: string | null;
status: string;
linked_interest_count: string;
}>(sql`
SELECT
b.id, b.mooring_number, b.area, b.status,
(
SELECT COUNT(*)::text FROM interest_berths ib
JOIN interests i ON ib.interest_id = i.id
WHERE ib.berth_id = b.id AND i.archived_at IS NULL
) AS linked_interest_count
FROM berths b
WHERE b.port_id = ${portId}
AND UPPER(b.mooring_number) = ${trimmed.toUpperCase()}
LIMIT 1
`);
const exactRows = Array.from(exact);
if (exactRows.length > 0) {
return exactRows.map((r) => ({
id: r.id,
mooringNumber: r.mooring_number,
area: r.area ?? null,
status: r.status,
linkedInterestCount: Number(r.linked_interest_count) || 0,
}));
}
// No exact match — fall back to letter+number-prefix matching plus
// a generic area/ILIKE fallback for non-structured queries.
const rows = await db.execute<{
id: string;
mooring_number: string;
@@ -728,30 +795,33 @@ async function searchBerths(portId: string, query: string, limit: number): Promi
linked_interest_count: string;
}>(sql`
SELECT
b.id,
b.mooring_number,
b.area,
b.status,
b.id, b.mooring_number, b.area, b.status,
(
SELECT COUNT(*)::text
FROM interest_berths ib
SELECT COUNT(*)::text FROM interest_berths ib
JOIN interests i ON ib.interest_id = i.id
WHERE ib.berth_id = b.id AND i.archived_at IS NULL
) AS linked_interest_count
FROM berths b
WHERE b.port_id = ${portId}
AND (
b.mooring_number ILIKE ${ilikePattern}
OR b.mooring_number % ${query}
${
isStructured
? sql`(
regexp_replace(b.mooring_number, '[0-9]+$', '') = ${letterPart}
AND regexp_replace(b.mooring_number, '^[A-Za-z]+', '') LIKE ${numberPart + '%'}
)`
: sql`FALSE`
}
OR b.mooring_number ILIKE ${prefixPattern}
OR b.area ILIKE ${ilikePattern}
)
ORDER BY
CASE
WHEN b.mooring_number ILIKE ${query + '%'} THEN 1
WHEN b.mooring_number ILIKE ${ilikePattern} THEN 2
WHEN b.mooring_number ILIKE ${prefixPattern} THEN 1
WHEN b.area ILIKE ${prefixPattern} THEN 2
ELSE 3
END,
similarity(b.mooring_number, ${query}) DESC,
length(b.mooring_number),
b.mooring_number
LIMIT ${limit}
`);
@@ -1245,6 +1315,429 @@ async function searchOtherPorts(
// ─── Public entrypoint ──────────────────────────────────────────────────────
/**
* Graph expansion — for every direct match in a search, fetch the
* 1-hop related entities and add them to the appropriate bucket.
*
* Berth match → its interests + their clients + their yachts
* Interest match → its berth + client + yacht
* Client match → their interests + their owned yachts + companies
* they're members of
* Yacht match → its interests + its owner (client/company)
* Company match → its members (clients) + their interests
*
* Depth limited to 1 hop to avoid quadratic fan-out. Each expansion row
* carries a `relatedVia` hint so the UI can show "via Berth A10" beneath
* the row's title.
*
* Rows that are already a direct match are NOT duplicated — the dedupe
* runs on `id`. Direct matches always take precedence (their relatedVia
* stays unset).
*/
async function expandGraph(
portId: string,
direct: {
berthIds: string[];
interestIds: string[];
clientIds: string[];
yachtIds: string[];
companyIds: string[];
},
perBucketCap: number,
): Promise<{
interests: InterestResult[];
clients: ClientResult[];
yachts: YachtResult[];
companies: CompanyResult[];
berths: BerthResult[];
}> {
// Helper: SQL-safe ANY() needs a non-empty array; bail early otherwise.
const hasAny = (arr: string[]) => arr.length > 0;
// ─── Berth → Interests (and their clients + yachts) ─────────────────
const interestsFromBerths = hasAny(direct.berthIds)
? await db.execute<{
id: string;
client_name: string;
mooring_number: string;
pipeline_stage: string;
outcome: string | null;
via_berth_id: string;
via_berth_label: string;
}>(sql`
SELECT
i.id,
c.full_name AS client_name,
b.mooring_number,
i.pipeline_stage,
i.outcome,
b.id AS via_berth_id,
b.mooring_number AS via_berth_label
FROM interest_berths ib
JOIN interests i ON ib.interest_id = i.id
JOIN clients c ON i.client_id = c.id
JOIN berths b ON ib.berth_id = b.id
WHERE ib.berth_id IN (${sql.join(direct.berthIds.map((id) => sql`${id}`), sql`, `)})
AND i.port_id = ${portId}
AND i.archived_at IS NULL
ORDER BY ib.is_primary DESC, i.created_at DESC
LIMIT ${perBucketCap * direct.berthIds.length}
`)
: [];
// ─── Interest → Berth, Client, Yacht ─────────────────────────────────
// For interests that matched directly, surface their connected berth +
// client + yacht as related entries in those buckets.
const fromInterests = hasAny(direct.interestIds)
? await db.execute<{
interest_id: string;
client_id: string;
client_name: string;
yacht_id: string | null;
yacht_name: string | null;
berth_id: string | null;
mooring_number: string | null;
berth_area: string | null;
berth_status: string | null;
}>(sql`
SELECT
i.id AS interest_id,
c.id AS client_id,
c.full_name AS client_name,
y.id AS yacht_id,
y.name AS yacht_name,
b.id AS berth_id,
b.mooring_number,
b.area AS berth_area,
b.status AS berth_status
FROM interests i
JOIN clients c ON i.client_id = c.id
LEFT JOIN yachts y ON i.yacht_id = y.id
LEFT JOIN LATERAL (
SELECT b.* FROM interest_berths ib2
JOIN berths b ON ib2.berth_id = b.id
WHERE ib2.interest_id = i.id
ORDER BY ib2.is_primary DESC
LIMIT 1
) b ON TRUE
WHERE i.id IN (${sql.join(direct.interestIds.map((id) => sql`${id}`), sql`, `)})
AND i.port_id = ${portId}
`)
: [];
// ─── Client → Interests, Owned Yachts, Member Companies ──────────────
const fromClients = hasAny(direct.clientIds)
? await Promise.all([
// Their interests
db.execute<{
id: string;
client_id: string;
client_name: string;
mooring_number: string | null;
pipeline_stage: string;
outcome: string | null;
}>(sql`
SELECT i.id, i.client_id, c.full_name AS client_name,
b.mooring_number, i.pipeline_stage, i.outcome
FROM interests i
JOIN clients c ON i.client_id = c.id
LEFT JOIN LATERAL (
SELECT b.mooring_number FROM interest_berths ib
JOIN berths b ON ib.berth_id = b.id
WHERE ib.interest_id = i.id
ORDER BY ib.is_primary DESC LIMIT 1
) b ON TRUE
WHERE i.client_id IN (${sql.join(direct.clientIds.map((id) => sql`${id}`), sql`, `)})
AND i.port_id = ${portId}
AND i.archived_at IS NULL
ORDER BY i.created_at DESC
LIMIT ${perBucketCap * direct.clientIds.length}
`),
// Yachts they own (current_owner_type='client')
db.execute<{
id: string;
name: string;
hull_number: string | null;
registration: string | null;
archived_at: string | null;
owner_id: string;
owner_name: string;
}>(sql`
SELECT y.id, y.name, y.hull_number, y.registration, y.archived_at::text,
c.id AS owner_id, c.full_name AS owner_name
FROM yachts y
JOIN clients c ON y.current_owner_id = c.id
WHERE y.current_owner_type = 'client'
AND y.current_owner_id IN (${sql.join(direct.clientIds.map((id) => sql`${id}`), sql`, `)})
AND y.port_id = ${portId}
ORDER BY y.name
LIMIT ${perBucketCap * direct.clientIds.length}
`),
// Companies they're members of
db.execute<{
id: string;
name: string;
legal_name: string | null;
tax_id: string | null;
archived_at: string | null;
via_client_id: string;
via_client_name: string;
}>(sql`
SELECT co.id, co.name, co.legal_name, co.tax_id, co.archived_at::text,
c.id AS via_client_id, c.full_name AS via_client_name
FROM company_memberships cm
JOIN companies co ON cm.company_id = co.id
JOIN clients c ON cm.client_id = c.id
WHERE cm.client_id IN (${sql.join(direct.clientIds.map((id) => sql`${id}`), sql`, `)})
AND cm.end_date IS NULL
AND co.port_id = ${portId}
ORDER BY co.name
LIMIT ${perBucketCap * direct.clientIds.length}
`),
])
: [[], [], []];
// ─── Yacht → Interests, Owner ───────────────────────────────────────
const fromYachts = hasAny(direct.yachtIds)
? await Promise.all([
// Interests on these yachts
db.execute<{
id: string;
client_name: string;
mooring_number: string | null;
pipeline_stage: string;
outcome: string | null;
via_yacht_id: string;
via_yacht_name: string;
}>(sql`
SELECT i.id, c.full_name AS client_name,
b.mooring_number, i.pipeline_stage, i.outcome,
y.id AS via_yacht_id, y.name AS via_yacht_name
FROM interests i
JOIN clients c ON i.client_id = c.id
JOIN yachts y ON i.yacht_id = y.id
LEFT JOIN LATERAL (
SELECT b.mooring_number FROM interest_berths ib
JOIN berths b ON ib.berth_id = b.id
WHERE ib.interest_id = i.id
ORDER BY ib.is_primary DESC LIMIT 1
) b ON TRUE
WHERE i.yacht_id IN (${sql.join(direct.yachtIds.map((id) => sql`${id}`), sql`, `)})
AND i.port_id = ${portId}
AND i.archived_at IS NULL
ORDER BY i.created_at DESC
LIMIT ${perBucketCap * direct.yachtIds.length}
`),
// Owners (client + company variants via polymorphic FKs)
db.execute<{
yacht_id: string;
yacht_name: string;
owner_type: string;
owner_id: string;
owner_label: string;
}>(sql`
SELECT y.id AS yacht_id, y.name AS yacht_name,
y.current_owner_type AS owner_type,
COALESCE(c.id, co.id) AS owner_id,
COALESCE(c.full_name, co.name) AS owner_label
FROM yachts y
LEFT JOIN clients c
ON y.current_owner_type = 'client' AND y.current_owner_id = c.id
LEFT JOIN companies co
ON y.current_owner_type = 'company' AND y.current_owner_id = co.id
WHERE y.id IN (${sql.join(direct.yachtIds.map((id) => sql`${id}`), sql`, `)})
AND y.port_id = ${portId}
AND y.current_owner_id IS NOT NULL
`),
])
: [[], []];
// ─── Company → Members (Clients), their Interests ────────────────────
const fromCompanies = hasAny(direct.companyIds)
? await Promise.all([
db.execute<{
id: string;
full_name: string;
archived_at: string | null;
via_company_id: string;
via_company_name: string;
}>(sql`
SELECT c.id, c.full_name, c.archived_at::text,
co.id AS via_company_id, co.name AS via_company_name
FROM company_memberships cm
JOIN clients c ON cm.client_id = c.id
JOIN companies co ON cm.company_id = co.id
WHERE cm.company_id IN (${sql.join(direct.companyIds.map((id) => sql`${id}`), sql`, `)})
AND cm.end_date IS NULL
AND c.port_id = ${portId}
ORDER BY c.full_name
LIMIT ${perBucketCap * direct.companyIds.length}
`),
])
: [[]];
// ─── Marshal into bucket-shaped result rows ──────────────────────────
const expandedInterests: InterestResult[] = [];
const expandedClients: ClientResult[] = [];
const expandedYachts: YachtResult[] = [];
const expandedCompanies: CompanyResult[] = [];
const expandedBerths: BerthResult[] = [];
// From berths
for (const r of Array.from(interestsFromBerths)) {
expandedInterests.push({
id: r.id,
clientName: r.client_name,
berthMooringNumber: r.mooring_number,
pipelineStage: r.pipeline_stage,
outcome: r.outcome,
relatedVia: { type: 'berth', id: r.via_berth_id, label: `Berth ${r.via_berth_label}` },
});
}
// From interests (the matched row's client, yacht, berth)
for (const r of Array.from(fromInterests)) {
if (r.client_id) {
expandedClients.push({
id: r.client_id,
fullName: r.client_name,
matchedContact: null,
matchedContactChannel: null,
archivedAt: null,
relatedVia: { type: 'interest', id: r.interest_id, label: `Interest · ${r.client_name}` },
});
}
if (r.yacht_id) {
expandedYachts.push({
id: r.yacht_id,
name: r.yacht_name ?? '(unnamed yacht)',
hullNumber: null,
registration: null,
archivedAt: null,
relatedVia: { type: 'interest', id: r.interest_id, label: `Interest · ${r.client_name}` },
});
}
if (r.berth_id) {
expandedBerths.push({
id: r.berth_id,
mooringNumber: r.mooring_number ?? '',
area: r.berth_area,
status: r.berth_status ?? 'available',
linkedInterestCount: 0,
relatedVia: { type: 'interest', id: r.interest_id, label: `Interest · ${r.client_name}` },
});
}
}
// From clients
const [clientInterests, clientYachts, clientCompanies] = fromClients;
for (const r of Array.from(clientInterests)) {
expandedInterests.push({
id: r.id,
clientName: r.client_name,
berthMooringNumber: r.mooring_number,
pipelineStage: r.pipeline_stage,
outcome: r.outcome,
relatedVia: { type: 'client', id: r.client_id, label: r.client_name },
});
}
for (const r of Array.from(clientYachts)) {
expandedYachts.push({
id: r.id,
name: r.name,
hullNumber: r.hull_number,
registration: r.registration,
archivedAt: r.archived_at,
relatedVia: { type: 'client', id: r.owner_id, label: r.owner_name },
});
}
for (const r of Array.from(clientCompanies)) {
expandedCompanies.push({
id: r.id,
name: r.name,
legalName: r.legal_name,
taxId: r.tax_id,
matchedField: null,
archivedAt: r.archived_at,
relatedVia: { type: 'client', id: r.via_client_id, label: r.via_client_name },
});
}
// From yachts
const [yachtInterests, yachtOwners] = fromYachts;
for (const r of Array.from(yachtInterests)) {
expandedInterests.push({
id: r.id,
clientName: r.client_name,
berthMooringNumber: r.mooring_number,
pipelineStage: r.pipeline_stage,
outcome: r.outcome,
relatedVia: { type: 'yacht', id: r.via_yacht_id, label: r.via_yacht_name },
});
}
for (const r of Array.from(yachtOwners)) {
if (!r.owner_id) continue;
if (r.owner_type === 'client') {
expandedClients.push({
id: r.owner_id,
fullName: r.owner_label,
matchedContact: null,
matchedContactChannel: null,
archivedAt: null,
relatedVia: { type: 'yacht', id: r.yacht_id, label: r.yacht_name },
});
} else if (r.owner_type === 'company') {
expandedCompanies.push({
id: r.owner_id,
name: r.owner_label,
legalName: null,
taxId: null,
matchedField: null,
archivedAt: null,
relatedVia: { type: 'yacht', id: r.yacht_id, label: r.yacht_name },
});
}
}
// From companies
const [companyMembers] = fromCompanies;
for (const r of Array.from(companyMembers)) {
expandedClients.push({
id: r.id,
fullName: r.full_name,
matchedContact: null,
matchedContactChannel: null,
archivedAt: r.archived_at,
relatedVia: { type: 'company', id: r.via_company_id, label: r.via_company_name },
});
}
return {
interests: expandedInterests,
clients: expandedClients,
yachts: expandedYachts,
companies: expandedCompanies,
berths: expandedBerths,
};
}
/**
* Merge direct-match rows with graph-expansion rows. Direct matches
* (those without `relatedVia` set) take precedence — if a row appears
* in both, the direct version wins. Direct matches sort before
* related matches.
*/
function mergeWithExpansion<
T extends { id: string; relatedVia?: RelatedVia | null },
>(direct: T[], expansion: T[], cap: number): T[] {
const seen = new Set(direct.map((r) => r.id));
const merged = [
...direct.map((r) => ({ ...r, relatedVia: null as RelatedVia | null })),
...expansion.filter((r) => !seen.has(r.id) && (seen.add(r.id), true)),
];
return merged.slice(0, cap);
}
/**
* Returns a populated `SearchResults` for the given port + query. All
* unrequested or permission-denied buckets come back as empty arrays so
@@ -1252,6 +1745,10 @@ async function searchOtherPorts(
*
* Per-bucket queries are run in parallel via `Promise.all` — total
* latency is bounded by the single slowest bucket.
*
* Graph expansion: after the direct-match phase, related entities are
* fetched in a single second pass (`expandGraph`) so reps searching for
* one entity see everything connected to it. See expandGraph docstring.
*/
export async function search(
portId: string,
@@ -1263,8 +1760,20 @@ export async function search(
if (!query || query.trim().length < 1) return empty;
// Single-bucket mode (used by /search?type=clients) — skip everything
// else for speed.
if (opts.type) return runSingleBucket(portId, query, limit, opts);
// else for speed. Graph-expansion buckets (clients, yachts, companies,
// interests, berths) fall through to the full pipeline below so that
// related-via matches survive the chip narrow — otherwise typing
// "carlos vega" with the Yachts chip selected would return zero rows
// even though the All chip shows "Yachts (1)" (the yacht owned by
// Carlos, surfaced through expandGraph). We trim to the requested
// bucket at the end.
type GraphBucket = 'clients' | 'yachts' | 'companies' | 'interests' | 'berths';
const GRAPH_BUCKETS: GraphBucket[] = ['clients', 'yachts', 'companies', 'interests', 'berths'];
const narrowTo: GraphBucket | null =
opts.type && (GRAPH_BUCKETS as readonly string[]).includes(opts.type)
? (opts.type as GraphBucket)
: null;
if (opts.type && !narrowTo) return runSingleBucket(portId, query, limit, opts);
const wantEmail = looksLikeEmail(query);
const wantPhone = normalizePhoneQuery(query) !== null;
@@ -1350,17 +1859,43 @@ export async function search(
void wantEmail;
void wantPhone;
// ─── Phase 2: graph expansion ───────────────────────────────────────
// For every direct match, fetch its 1-hop related entities so reps
// who search "A10" see the linked interests/clients/yachts/companies
// surface alongside the berth. See `expandGraph` docstring for the
// relationship map and per-bucket caps.
const expanded = await expandGraph(
portId,
{
berthIds: berths.map((b) => b.id),
interestIds: interests.map((i) => i.id),
clientIds: clients.map((c) => c.id),
yachtIds: yachts.map((y) => y.id),
companyIds: companies.map((c) => c.id),
},
limit,
);
const apply = <T extends { id: string }>(rows: T[]) =>
applyAffinity(rows, opts.recentlyTouchedIds);
// Merge direct matches with expansion rows; direct rows always win
// ties and sort first. Each bucket caps at `limit * 2` so reps still
// see the full direct-match set plus a healthy expansion tail.
const mergedClients = mergeWithExpansion(clients, expanded.clients, limit * 2);
const mergedInterests = mergeWithExpansion(interests, expanded.interests, limit * 2);
const mergedYachts = mergeWithExpansion(yachts, expanded.yachts, limit * 2);
const mergedCompanies = mergeWithExpansion(companies, expanded.companies, limit * 2);
const mergedBerths = mergeWithExpansion(berths, expanded.berths, limit * 2);
const result: SearchResults = {
clients: apply(clients),
clients: apply(mergedClients),
residentialClients: apply(residentialClients),
yachts: apply(yachts),
companies: apply(companies),
interests: apply(interests),
yachts: apply(mergedYachts),
companies: apply(mergedCompanies),
interests: apply(mergedInterests),
residentialInterests: apply(residentialInterests),
berths: apply(berths),
berths: apply(mergedBerths),
invoices: apply(invoices),
expenses: apply(expenses),
documents: apply(documents),
@@ -1371,13 +1906,13 @@ export async function search(
navigation,
notes,
totals: {
clients: clients.length,
clients: mergedClients.length,
residentialClients: residentialClients.length,
yachts: yachts.length,
companies: companies.length,
interests: interests.length,
yachts: mergedYachts.length,
companies: mergedCompanies.length,
interests: mergedInterests.length,
residentialInterests: residentialInterests.length,
berths: berths.length,
berths: mergedBerths.length,
invoices: invoices.length,
expenses: expenses.length,
documents: documents.length,
@@ -1391,6 +1926,22 @@ export async function search(
otherPorts: otherPorts.length > 0 ? otherPorts : undefined,
};
// When narrowing to a graph bucket, zero out every other bucket so the
// dropdown only renders the chosen one. Totals for the other buckets
// stay populated so the chip row still shows their counts — the client
// already snapshots the last "all" totals separately, but keeping them
// here means a direct API hit with ?type=yachts still sees all chip
// counts for free.
if (narrowTo) {
const keep = narrowTo;
return {
...emptyResults(),
[keep]: result[keep],
totals: result.totals,
otherPorts: result.otherPorts,
} as SearchResults;
}
return result;
}