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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user