feat(search): index yachts and companies alongside clients

Extend the global search service to include yacht and company results
using ILIKE matching on name, hull number, registration, legal name,
and tax ID. Results are tenant-scoped and exclude archived rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-24 15:47:54 +02:00
parent 1fd05a886d
commit 71d7daf1ae
5 changed files with 384 additions and 7 deletions

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Search, Clock, User, TrendingUp, Anchor } from 'lucide-react'; import { Search, Clock, User, TrendingUp, Anchor, Ship, Building2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSearch } from '@/hooks/use-search'; import { useSearch } from '@/hooks/use-search';
@@ -22,7 +22,11 @@ export function CommandSearch() {
const hasQuery = query.length >= 2; const hasQuery = query.length >= 2;
const hasResults = const hasResults =
results && results &&
(results.clients.length > 0 || results.interests.length > 0 || results.berths.length > 0); (results.clients.length > 0 ||
results.interests.length > 0 ||
results.berths.length > 0 ||
results.yachts.length > 0 ||
results.companies.length > 0);
// Cmd/Ctrl+K focuses the input // Cmd/Ctrl+K focuses the input
useEffect(() => { useEffect(() => {
@@ -67,7 +71,13 @@ export function CommandSearch() {
} }
} }
const iconMap = { client: User, interest: TrendingUp, berth: Anchor } as const; const iconMap = {
client: User,
interest: TrendingUp,
berth: Anchor,
yacht: Ship,
company: Building2,
} as const;
return ( return (
<div ref={wrapperRef} className="relative"> <div ref={wrapperRef} className="relative">
@@ -148,6 +158,32 @@ export function CommandSearch() {
onSelect={(id) => navigate(`/${portSlug}/clients/${id}`)} onSelect={(id) => navigate(`/${portSlug}/clients/${id}`)}
/> />
)} )}
{results.yachts.length > 0 && (
<ResultGroup
heading="Yachts"
items={results.yachts.map((y) => ({
id: y.id,
icon: 'yacht',
label: y.name,
sub: [y.hullNumber, y.registration].filter(Boolean).join(' · ') || null,
}))}
iconMap={iconMap}
onSelect={(id) => navigate(`/${portSlug}/yachts/${id}`)}
/>
)}
{results.companies.length > 0 && (
<ResultGroup
heading="Companies"
items={results.companies.map((c) => ({
id: c.id,
icon: 'company',
label: c.name,
sub: [c.legalName, c.taxId].filter(Boolean).join(' · ') || null,
}))}
iconMap={iconMap}
onSelect={(id) => navigate(`/${portSlug}/companies/${id}`)}
/>
)}
{results.interests.length > 0 && ( {results.interests.length > 0 && (
<ResultGroup <ResultGroup
heading="Interests" heading="Interests"
@@ -190,7 +226,12 @@ function ResultGroup({
onSelect, onSelect,
}: { }: {
heading: string; heading: string;
items: Array<{ id: string; icon: 'client' | 'interest' | 'berth'; label: string; sub?: string | null }>; items: Array<{
id: string;
icon: 'client' | 'interest' | 'berth' | 'yacht' | 'company';
label: string;
sub?: string | null;
}>;
iconMap: Record<string, React.ElementType | undefined>; iconMap: Record<string, React.ElementType | undefined>;
onSelect: (id: string) => void; onSelect: (id: string) => void;
}) { }) {

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { User, Anchor, TrendingUp } from 'lucide-react'; import { User, Anchor, TrendingUp, Ship, Building2 } from 'lucide-react';
import { CommandItem } from '@/components/ui/command'; import { CommandItem } from '@/components/ui/command';
@@ -26,10 +26,26 @@ interface BerthItem {
status: string; status: string;
} }
interface YachtItem {
id: string;
name: string;
hullNumber: string | null;
registration: string | null;
}
interface CompanyItem {
id: string;
name: string;
legalName: string | null;
taxId: string | null;
}
type SearchResultItemProps = type SearchResultItemProps =
| { type: 'client'; item: ClientItem; onSelect: () => void } | { type: 'client'; item: ClientItem; onSelect: () => void }
| { type: 'interest'; item: InterestItem; onSelect: () => void } | { type: 'interest'; item: InterestItem; onSelect: () => void }
| { type: 'berth'; item: BerthItem; onSelect: () => void }; | { type: 'berth'; item: BerthItem; onSelect: () => void }
| { type: 'yacht'; item: YachtItem; onSelect: () => void }
| { type: 'company'; item: CompanyItem; onSelect: () => void };
// ─── Component ──────────────────────────────────────────────────────────────── // ─── Component ────────────────────────────────────────────────────────────────
@@ -63,6 +79,38 @@ export function SearchResultItem({ type, item, onSelect }: SearchResultItemProps
); );
} }
if (type === 'yacht') {
return (
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
<Ship className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col">
<span className="text-sm font-medium">{item.name}</span>
{(item.hullNumber || item.registration) && (
<span className="text-xs text-muted-foreground">
{[item.hullNumber, item.registration].filter(Boolean).join(' · ')}
</span>
)}
</div>
</CommandItem>
);
}
if (type === 'company') {
return (
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
<Building2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col">
<span className="text-sm font-medium">{item.name}</span>
{(item.legalName || item.taxId) && (
<span className="text-xs text-muted-foreground">
{[item.legalName, item.taxId].filter(Boolean).join(' · ')}
</span>
)}
</div>
</CommandItem>
);
}
// berth // berth
return ( return (
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2"> <CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">

View File

@@ -16,6 +16,18 @@ interface SearchResults {
pipelineStage: string; pipelineStage: string;
}>; }>;
berths: Array<{ id: string; mooringNumber: string; area: string | null; status: string }>; berths: Array<{ id: string; mooringNumber: string; area: string | null; status: string }>;
yachts: Array<{
id: string;
name: string;
hullNumber: string | null;
registration: string | null;
}>;
companies: Array<{
id: string;
name: string;
legalName: string | null;
taxId: string | null;
}>;
} }
// ─── Hook ───────────────────────────────────────────────────────────────────── // ─── Hook ─────────────────────────────────────────────────────────────────────

View File

@@ -25,16 +25,32 @@ interface BerthResult {
status: string; status: string;
} }
interface YachtResult {
id: string;
name: string;
hullNumber: string | null;
registration: string | null;
}
interface CompanyResult {
id: string;
name: string;
legalName: string | null;
taxId: string | null;
}
interface SearchResults { interface SearchResults {
clients: ClientResult[]; clients: ClientResult[];
interests: InterestResult[]; interests: InterestResult[];
berths: BerthResult[]; berths: BerthResult[];
yachts: YachtResult[];
companies: CompanyResult[];
} }
// ─── Search ─────────────────────────────────────────────────────────────────── // ─── Search ───────────────────────────────────────────────────────────────────
export async function search(portId: string, query: string): Promise<SearchResults> { export async function search(portId: string, query: string): Promise<SearchResults> {
const [clientRows, berthRows, interestRows] = await Promise.all([ const [clientRows, berthRows, interestRows, yachtRows, companyRows] = await Promise.all([
// Clients: full-text search via tsvector // Clients: full-text search via tsvector
db.execute<{ id: string; full_name: string; company_name: string | null }>(sql` db.execute<{ id: string; full_name: string; company_name: string | null }>(sql`
SELECT id, full_name, company_name SELECT id, full_name, company_name
@@ -83,6 +99,58 @@ export async function search(portId: string, query: string): Promise<SearchResul
) )
LIMIT 10 LIMIT 10
`), `),
// Yachts: ILIKE on name, hull_number, registration
db.execute<{
id: string;
name: string;
hull_number: string | null;
registration: string | null;
}>(sql`
SELECT id, name, hull_number, registration
FROM yachts
WHERE port_id = ${portId}
AND archived_at IS NULL
AND (
name ILIKE ${'%' + query + '%'}
OR hull_number ILIKE ${'%' + query + '%'}
OR registration ILIKE ${'%' + query + '%'}
)
ORDER BY
CASE
WHEN name ILIKE ${query + '%'} THEN 1
WHEN name ILIKE ${'%' + query + '%'} THEN 2
ELSE 3
END,
name
LIMIT 10
`),
// Companies: ILIKE on name, legal_name, tax_id
db.execute<{
id: string;
name: string;
legal_name: string | null;
tax_id: string | null;
}>(sql`
SELECT id, name, legal_name, tax_id
FROM companies
WHERE port_id = ${portId}
AND archived_at IS NULL
AND (
name ILIKE ${'%' + query + '%'}
OR legal_name ILIKE ${'%' + query + '%'}
OR tax_id ILIKE ${'%' + query + '%'}
)
ORDER BY
CASE
WHEN name ILIKE ${query + '%'} THEN 1
WHEN name ILIKE ${'%' + query + '%'} THEN 2
ELSE 3
END,
name
LIMIT 10
`),
]); ]);
return { return {
@@ -103,6 +171,18 @@ export async function search(portId: string, query: string): Promise<SearchResul
berthMooringNumber: r.mooring_number ?? null, berthMooringNumber: r.mooring_number ?? null,
pipelineStage: r.pipeline_stage, pipelineStage: r.pipeline_stage,
})), })),
yachts: Array.from(yachtRows).map((r) => ({
id: r.id,
name: r.name,
hullNumber: r.hull_number ?? null,
registration: r.registration ?? null,
})),
companies: Array.from(companyRows).map((r) => ({
id: r.id,
name: r.name,
legalName: r.legal_name ?? null,
taxId: r.tax_id ?? null,
})),
}; };
} }

View File

@@ -0,0 +1,196 @@
import { describe, it, expect } from 'vitest';
import { eq } from 'drizzle-orm';
import { search } from '@/lib/services/search.service';
import { db } from '@/lib/db';
import { yachts, companies } from '@/lib/db/schema';
import { makePort, makeClient, makeYacht, makeCompany } from '../../helpers/factories';
// ─── Yachts ──────────────────────────────────────────────────────────────────
describe('search.service — yachts', () => {
it('matches yachts by name (case-insensitive)', async () => {
const port = await makePort();
const owner = await makeClient({ portId: port.id });
await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: owner.id,
name: 'Sea Breeze',
});
await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: owner.id,
name: 'Wind Dancer',
});
const results = await search(port.id, 'BREEZE');
expect(results.yachts.some((y) => y.name === 'Sea Breeze')).toBe(true);
expect(results.yachts.some((y) => y.name === 'Wind Dancer')).toBe(false);
});
it('matches yachts by hull number', async () => {
const port = await makePort();
const owner = await makeClient({ portId: port.id });
await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: owner.id,
name: 'Nomad',
hullNumber: 'HULL-XYZ-999',
});
const results = await search(port.id, 'hull-xyz');
expect(results.yachts.some((y) => y.hullNumber === 'HULL-XYZ-999')).toBe(true);
});
it('matches yachts by registration', async () => {
const port = await makePort();
const owner = await makeClient({ portId: port.id });
await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: owner.id,
name: 'Registered One',
registration: 'REG-ABC-123',
});
const results = await search(port.id, 'reg-abc');
expect(results.yachts.some((y) => y.registration === 'REG-ABC-123')).toBe(true);
});
it('excludes archived yachts', async () => {
const port = await makePort();
const owner = await makeClient({ portId: port.id });
const yacht = await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: owner.id,
name: 'Ghost Ship',
});
await db.update(yachts).set({ archivedAt: new Date() }).where(eq(yachts.id, yacht.id));
const results = await search(port.id, 'ghost ship');
expect(results.yachts.some((y) => y.id === yacht.id)).toBe(false);
});
it('is tenant-scoped', async () => {
const portA = await makePort();
const portB = await makePort();
const ownerA = await makeClient({ portId: portA.id });
const ownerB = await makeClient({ portId: portB.id });
await makeYacht({
portId: portA.id,
ownerType: 'client',
ownerId: ownerA.id,
name: 'UniqueYachtNameA',
});
await makeYacht({
portId: portB.id,
ownerType: 'client',
ownerId: ownerB.id,
name: 'UniqueYachtNameB',
});
const resultsA = await search(portA.id, 'UniqueYachtName');
expect(resultsA.yachts.some((y) => y.name === 'UniqueYachtNameA')).toBe(true);
expect(resultsA.yachts.some((y) => y.name === 'UniqueYachtNameB')).toBe(false);
});
});
// ─── Companies ───────────────────────────────────────────────────────────────
describe('search.service — companies', () => {
it('matches companies by name', async () => {
const port = await makePort();
await makeCompany({ portId: port.id, overrides: { name: 'Poseidon Maritime Ltd' } });
await makeCompany({ portId: port.id, overrides: { name: 'Neptune Holdings' } });
const results = await search(port.id, 'poseidon');
expect(results.companies.some((c) => c.name === 'Poseidon Maritime Ltd')).toBe(true);
expect(results.companies.some((c) => c.name === 'Neptune Holdings')).toBe(false);
});
it('matches companies by legal name', async () => {
const port = await makePort();
await makeCompany({
portId: port.id,
overrides: { name: 'AcmeShort', legalName: 'Acme Legal Holdings Inc.' },
});
const results = await search(port.id, 'Acme Legal');
expect(results.companies.some((c) => c.legalName === 'Acme Legal Holdings Inc.')).toBe(true);
});
it('matches companies by tax ID', async () => {
const port = await makePort();
await makeCompany({
portId: port.id,
overrides: { name: 'TaxyCo', taxId: 'VAT-112233445' },
});
const results = await search(port.id, 'vat-112233');
expect(results.companies.some((c) => c.taxId === 'VAT-112233445')).toBe(true);
});
it('excludes archived companies', async () => {
const port = await makePort();
const company = await makeCompany({
portId: port.id,
overrides: { name: 'ArchivedCompanyXyz' },
});
await db.update(companies).set({ archivedAt: new Date() }).where(eq(companies.id, company.id));
const results = await search(port.id, 'ArchivedCompanyXyz');
expect(results.companies.some((c) => c.id === company.id)).toBe(false);
});
it('is tenant-scoped', async () => {
const portA = await makePort();
const portB = await makePort();
await makeCompany({ portId: portA.id, overrides: { name: 'UniqueCompanyA' } });
await makeCompany({ portId: portB.id, overrides: { name: 'UniqueCompanyB' } });
const resultsA = await search(portA.id, 'UniqueCompany');
expect(resultsA.companies.some((c) => c.name === 'UniqueCompanyA')).toBe(true);
expect(resultsA.companies.some((c) => c.name === 'UniqueCompanyB')).toBe(false);
});
});
// ─── Combined ────────────────────────────────────────────────────────────────
describe('search.service — combined', () => {
it('returns clients, yachts, and companies for a query that matches multiple', async () => {
const port = await makePort();
const client = await makeClient({
portId: port.id,
overrides: { fullName: 'Alpha Person' },
});
await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: client.id,
name: 'Alpha Yacht',
});
await makeCompany({ portId: port.id, overrides: { name: 'Alpha Holdings' } });
const results = await search(port.id, 'alpha');
expect(results.clients.some((c) => c.fullName === 'Alpha Person')).toBe(true);
expect(results.yachts.some((y) => y.name === 'Alpha Yacht')).toBe(true);
expect(results.companies.some((c) => c.name === 'Alpha Holdings')).toBe(true);
});
it('returns all result keys even when no matches exist', async () => {
const port = await makePort();
const results = await search(port.id, 'zzz-nothing-matches-zzz');
expect(results).toEqual({
clients: [],
interests: [],
berths: [],
yachts: [],
companies: [],
});
});
});