From 267c2b6d1f3213a15f3dd4f15d198123e5bcc432 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 20:58:34 +0200 Subject: [PATCH] feat(search): full-platform search overhaul + view tracking + notes bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service rewrite covers 14 entity buckets (clients, residential clients, yachts, companies, interests, residential interests, berths, invoices, expenses, documents, files, reminders, brochures, tags, notes, navigation) with prefix tsquery + trigram fallback, phone-digit normalization, and JOINs to client_contacts for email matching. New `notes` bucket searches across the four note tables (client, interest, yacht, company) via UNION + parent-entity label resolution (berth mooring for interests, name for yachts/companies). Renders at the bottom of the dropdown so broad-content matches don't crowd entity-specific hits — per the user's "low-noise" preference. Recently-viewed tracking persists last 20 entity views per user in Redis sorted set; CommandSearch surfaces them as the dropdown's default state and applies affinity ranking when the user types. ID-resolve endpoint accepts pasted UUIDs (or invoice numbers like `INV-2025-001`) and routes the rep straight to the entity, skipping the normal search bucket. Audit search service gains `entityIds[]` array filter for the new loadClientActivityAggregated() path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/v1/search/recently-viewed/route.ts | 275 +++ src/app/api/v1/search/resolve-id/route.ts | 91 + src/app/api/v1/search/route.ts | 72 +- src/components/search/command-search.tsx | 1201 ++++++++++-- src/components/search/highlight-match.tsx | 54 + src/components/search/track-entity-view.tsx | 33 + src/hooks/use-search.ts | 271 ++- src/hooks/use-track-entity-view.ts | 37 + src/lib/services/audit-search.service.ts | 16 +- src/lib/services/recently-viewed.service.ts | 120 ++ src/lib/services/search-nav-catalog.ts | 222 +++ src/lib/services/search.service.ts | 1637 +++++++++++++++-- src/lib/validators/search.ts | 49 + tests/unit/services/search.test.ts | 121 +- 14 files changed, 3821 insertions(+), 378 deletions(-) create mode 100644 src/app/api/v1/search/recently-viewed/route.ts create mode 100644 src/app/api/v1/search/resolve-id/route.ts create mode 100644 src/components/search/highlight-match.tsx create mode 100644 src/components/search/track-entity-view.tsx create mode 100644 src/hooks/use-track-entity-view.ts create mode 100644 src/lib/services/recently-viewed.service.ts create mode 100644 src/lib/services/search-nav-catalog.ts diff --git a/src/app/api/v1/search/recently-viewed/route.ts b/src/app/api/v1/search/recently-viewed/route.ts new file mode 100644 index 0000000..50f6490 --- /dev/null +++ b/src/app/api/v1/search/recently-viewed/route.ts @@ -0,0 +1,275 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { sql } from 'drizzle-orm'; + +import { withAuth } from '@/lib/api/helpers'; +import { db } from '@/lib/db'; +import { errorResponse } from '@/lib/errors'; +import { getRecentlyViewed, trackView } from '@/lib/services/recently-viewed.service'; +import { trackViewSchema } from '@/lib/validators/search'; + +/** + * Hydrated row returned by GET — contains the same `type` + `id` from + * the Redis sorted set, plus the labels needed to render the row in the + * dropdown ("Client · Jane Smith") without an extra round-trip. + * + * Rows whose underlying entity has been hard-deleted are filtered out + * (Redis keeps the membership; the hydration JOIN drops the orphan). + */ +interface HydratedRow { + type: string; + id: string; + label: string; + sub: string | null; + href: string; + viewedAt: number; +} + +/** Batch-resolve labels for a list of (type, id) pairs grouped by type. */ +async function hydrate( + portSlug: string, + portId: string, + pairs: Array<{ type: string; id: string; viewedAt: number }>, +): Promise { + if (pairs.length === 0) return []; + + const byType = new Map>(); + for (const p of pairs) { + if (!byType.has(p.type)) byType.set(p.type, []); + byType.get(p.type)!.push({ id: p.id, viewedAt: p.viewedAt }); + } + + const rows: HydratedRow[] = []; + + await Promise.all( + Array.from(byType.entries()).map(async ([type, items]) => { + const ids = items.map((i) => i.id); + const idVal = sql`(${sql.join( + ids.map((id) => sql`${id}`), + sql`, `, + )})`; + const idAt = new Map(items.map((i) => [i.id, i.viewedAt] as const)); + + switch (type) { + case 'client': { + const dbRows = await db.execute<{ id: string; full_name: string }>(sql` + SELECT id, full_name FROM clients + WHERE port_id = ${portId} AND archived_at IS NULL AND id IN ${idVal} + `); + for (const r of Array.from(dbRows)) { + rows.push({ + type, + id: r.id, + label: r.full_name, + sub: 'Client', + href: `/${portSlug}/clients/${r.id}`, + viewedAt: idAt.get(r.id) ?? 0, + }); + } + return; + } + case 'residential-client': { + const dbRows = await db.execute<{ id: string; full_name: string }>(sql` + SELECT id, full_name FROM residential_clients + WHERE port_id = ${portId} AND archived_at IS NULL AND id IN ${idVal} + `); + for (const r of Array.from(dbRows)) { + rows.push({ + type, + id: r.id, + label: r.full_name, + sub: 'Residential client', + href: `/${portSlug}/residential/clients/${r.id}`, + viewedAt: idAt.get(r.id) ?? 0, + }); + } + return; + } + case 'yacht': { + const dbRows = await db.execute<{ id: string; name: string; hull_number: string | null }>( + sql`SELECT id, name, hull_number FROM yachts + WHERE port_id = ${portId} AND archived_at IS NULL AND id IN ${idVal}`, + ); + for (const r of Array.from(dbRows)) { + rows.push({ + type, + id: r.id, + label: r.name, + sub: r.hull_number ? `Yacht · ${r.hull_number}` : 'Yacht', + href: `/${portSlug}/yachts/${r.id}`, + viewedAt: idAt.get(r.id) ?? 0, + }); + } + return; + } + case 'company': { + const dbRows = await db.execute<{ id: string; name: string }>( + sql`SELECT id, name FROM companies + WHERE port_id = ${portId} AND archived_at IS NULL AND id IN ${idVal}`, + ); + for (const r of Array.from(dbRows)) { + rows.push({ + type, + id: r.id, + label: r.name, + sub: 'Company', + href: `/${portSlug}/companies/${r.id}`, + viewedAt: idAt.get(r.id) ?? 0, + }); + } + return; + } + case 'interest': { + const dbRows = await db.execute<{ + id: string; + full_name: string; + mooring_number: string | null; + }>(sql` + SELECT i.id, c.full_name, b.mooring_number + FROM interests i + JOIN clients c ON i.client_id = c.id + LEFT JOIN interest_berths ib ON ib.interest_id = i.id AND ib.is_primary = true + LEFT JOIN berths b ON ib.berth_id = b.id + WHERE i.port_id = ${portId} AND i.archived_at IS NULL AND i.id IN ${idVal} + `); + for (const r of Array.from(dbRows)) { + rows.push({ + type, + id: r.id, + label: r.full_name, + sub: r.mooring_number ? `Interest · ${r.mooring_number}` : 'Interest', + href: `/${portSlug}/interests/${r.id}`, + viewedAt: idAt.get(r.id) ?? 0, + }); + } + return; + } + case 'residential-interest': { + const dbRows = await db.execute<{ id: string; full_name: string }>(sql` + SELECT ri.id, rc.full_name + FROM residential_interests ri + JOIN residential_clients rc ON ri.residential_client_id = rc.id + WHERE ri.port_id = ${portId} AND ri.archived_at IS NULL AND ri.id IN ${idVal} + `); + for (const r of Array.from(dbRows)) { + rows.push({ + type, + id: r.id, + label: r.full_name, + sub: 'Residential interest', + href: `/${portSlug}/residential/interests/${r.id}`, + viewedAt: idAt.get(r.id) ?? 0, + }); + } + return; + } + case 'berth': { + const dbRows = await db.execute<{ id: string; mooring_number: string }>(sql` + SELECT id, mooring_number FROM berths + WHERE port_id = ${portId} AND id IN ${idVal} + `); + for (const r of Array.from(dbRows)) { + rows.push({ + type, + id: r.id, + label: r.mooring_number, + sub: 'Berth', + href: `/${portSlug}/berths/${r.id}`, + viewedAt: idAt.get(r.id) ?? 0, + }); + } + return; + } + case 'invoice': { + const dbRows = await db.execute<{ + id: string; + invoice_number: string; + client_name: string; + }>(sql` + SELECT id, invoice_number, client_name FROM invoices + WHERE port_id = ${portId} AND id IN ${idVal} + `); + for (const r of Array.from(dbRows)) { + rows.push({ + type, + id: r.id, + label: r.invoice_number, + sub: `Invoice · ${r.client_name}`, + href: `/${portSlug}/invoices/${r.id}`, + viewedAt: idAt.get(r.id) ?? 0, + }); + } + return; + } + case 'expense': { + const dbRows = await db.execute<{ + id: string; + description: string | null; + establishment_name: string | null; + }>(sql` + SELECT id, description, establishment_name FROM expenses + WHERE port_id = ${portId} AND id IN ${idVal} + `); + for (const r of Array.from(dbRows)) { + rows.push({ + type, + id: r.id, + label: r.description ?? r.establishment_name ?? 'Expense', + sub: 'Expense', + href: `/${portSlug}/expenses/${r.id}`, + viewedAt: idAt.get(r.id) ?? 0, + }); + } + return; + } + case 'document': { + const dbRows = await db.execute<{ id: string; title: string }>(sql` + SELECT id, title FROM documents + WHERE port_id = ${portId} AND id IN ${idVal} + `); + for (const r of Array.from(dbRows)) { + rows.push({ + type, + id: r.id, + label: r.title, + sub: 'Document', + href: `/${portSlug}/documents/${r.id}`, + viewedAt: idAt.get(r.id) ?? 0, + }); + } + return; + } + } + }), + ); + + // Re-sort by viewedAt DESC since the per-type queries lose the order. + rows.sort((a, b) => b.viewedAt - a.viewedAt); + return rows; +} + +export const GET = withAuth(async (req: NextRequest, ctx) => { + try { + const limitParam = req.nextUrl.searchParams.get('limit'); + const limit = limitParam ? Math.min(Math.max(Number(limitParam), 1), 20) : 10; + + const pairs = await getRecentlyViewed(ctx.userId, ctx.portId, limit); + const items = await hydrate(ctx.portSlug, ctx.portId, pairs); + + return NextResponse.json({ items }); + } catch (error) { + return errorResponse(error); + } +}); + +export const POST = withAuth(async (req: NextRequest, ctx) => { + try { + const body = await req.json(); + const parsed = trackViewSchema.parse(body); + + trackView(ctx.userId, ctx.portId, parsed.type, parsed.id); + + return NextResponse.json({ ok: true }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/app/api/v1/search/resolve-id/route.ts b/src/app/api/v1/search/resolve-id/route.ts new file mode 100644 index 0000000..b4c82df --- /dev/null +++ b/src/app/api/v1/search/resolve-id/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { sql } from 'drizzle-orm'; + +import { withAuth } from '@/lib/api/helpers'; +import { db } from '@/lib/db'; +import { errorResponse } from '@/lib/errors'; + +/** + * Resolves a pasted UUID or invoice number to the (type, href) of its + * detail page. Used by the CommandSearch paste-detection shortcut so a + * pasted ID jumps directly to the entity instead of running through the + * full multi-bucket search. + * + * UNION-of-singletons across the high-signal entity tables. Each branch + * is a primary-key lookup so the cost is dominated by network, not the + * database. Returns `{ found: false }` when the id doesn't exist OR + * when it exists but in a different port (port-scoped on purpose so a + * super-admin paste from another port doesn't silently navigate + * cross-tenant). + */ +export const GET = withAuth(async (req: NextRequest, ctx) => { + try { + const id = req.nextUrl.searchParams.get('id')?.trim() ?? ''; + if (!id || id.length > 100) { + return NextResponse.json({ found: false }); + } + + // One UNION query covers every primary key the user might paste. + // Each branch is a constant-time index lookup. + const rows = await db.execute<{ type: string; entity_id: string }>(sql` + SELECT 'client'::text AS type, id AS entity_id FROM clients + WHERE id = ${id} AND port_id = ${ctx.portId} AND archived_at IS NULL + UNION ALL + SELECT 'residential-client', id FROM residential_clients + WHERE id = ${id} AND port_id = ${ctx.portId} AND archived_at IS NULL + UNION ALL + SELECT 'yacht', id FROM yachts + WHERE id = ${id} AND port_id = ${ctx.portId} AND archived_at IS NULL + UNION ALL + SELECT 'company', id FROM companies + WHERE id = ${id} AND port_id = ${ctx.portId} AND archived_at IS NULL + UNION ALL + SELECT 'interest', id FROM interests + WHERE id = ${id} AND port_id = ${ctx.portId} AND archived_at IS NULL + UNION ALL + SELECT 'residential-interest', id FROM residential_interests + WHERE id = ${id} AND port_id = ${ctx.portId} AND archived_at IS NULL + UNION ALL + SELECT 'berth', id FROM berths + WHERE id = ${id} AND port_id = ${ctx.portId} + UNION ALL + SELECT 'invoice', id FROM invoices + WHERE (id = ${id} OR invoice_number = ${id}) AND port_id = ${ctx.portId} + UNION ALL + SELECT 'expense', id FROM expenses + WHERE id = ${id} AND port_id = ${ctx.portId} + UNION ALL + SELECT 'document', id FROM documents + WHERE id = ${id} AND port_id = ${ctx.portId} + LIMIT 1 + `); + + const list = Array.from(rows); + if (list.length === 0) { + return NextResponse.json({ found: false }); + } + const row = list[0]!; + + const hrefMap: Record = { + client: `/${ctx.portSlug}/clients/${row.entity_id}`, + 'residential-client': `/${ctx.portSlug}/residential/clients/${row.entity_id}`, + yacht: `/${ctx.portSlug}/yachts/${row.entity_id}`, + company: `/${ctx.portSlug}/companies/${row.entity_id}`, + interest: `/${ctx.portSlug}/interests/${row.entity_id}`, + 'residential-interest': `/${ctx.portSlug}/residential/interests/${row.entity_id}`, + berth: `/${ctx.portSlug}/berths/${row.entity_id}`, + invoice: `/${ctx.portSlug}/invoices/${row.entity_id}`, + expense: `/${ctx.portSlug}/expenses/${row.entity_id}`, + document: `/${ctx.portSlug}/documents/${row.entity_id}`, + }; + + return NextResponse.json({ + found: true, + type: row.type, + id: row.entity_id, + href: hrefMap[row.type] ?? null, + }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts index c3f95ec..f86d08a 100644 --- a/src/app/api/v1/search/route.ts +++ b/src/app/api/v1/search/route.ts @@ -2,24 +2,76 @@ import { NextRequest, NextResponse } from 'next/server'; import { withAuth } from '@/lib/api/helpers'; import { errorResponse } from '@/lib/errors'; -import { search, saveRecentSearch } from '@/lib/services/search.service'; +import { search, saveRecentSearch, getRecentlyTouchedIds } from '@/lib/services/search.service'; import { searchQuerySchema } from '@/lib/validators/search'; export const GET = withAuth(async (req: NextRequest, ctx) => { try { - const q = req.nextUrl.searchParams.get('q') ?? ''; + const params = req.nextUrl.searchParams; + const parsed = searchQuerySchema.parse({ + q: params.get('q') ?? '', + type: params.get('type') ?? undefined, + limit: params.get('limit') ?? undefined, + includeOtherPorts: params.get('includeOtherPorts') ?? undefined, + }); - if (q.length < 2) { - return NextResponse.json({ clients: [], interests: [], berths: [] }); + // Only super admins can opt into cross-port search; silently ignored + // otherwise so a non-admin can't probe whether the flag exists. + const includeOtherPorts = ctx.isSuperAdmin && (parsed.includeOtherPorts ?? false); + + // Run the affinity-set query alongside the search so it costs us + // nothing in wall-clock time. The set is small (capped at 200 ids) + // and is used to boost ranking inside each bucket. + const [touchedIds, results] = await Promise.all([ + getRecentlyTouchedIds(ctx.userId, ctx.portId).catch(() => new Set()), + search(ctx.portId, parsed.q, { + permissions: ctx.permissions, + isSuperAdmin: ctx.isSuperAdmin, + limit: parsed.limit, + type: parsed.type, + includeOtherPorts, + }), + ]); + + // Resolve `:portSlug` placeholders in the nav-catalog hrefs. Done + // here (not in the service) so the service stays portSlug-free — + // it only knows portId, which is the right tenant boundary anyway. + if (results.navigation.length > 0) { + results.navigation = results.navigation.map((n) => ({ + ...n, + href: n.href.replace(':portSlug', ctx.portSlug), + id: n.href.replace(':portSlug', ctx.portSlug), + })); } - // Validate via schema (also enforces max length) - searchQuerySchema.parse({ q }); + // Re-run affinity sort with the now-resolved set. The service can + // also accept this up front, but we kick off both queries in + // parallel for latency, then apply on results — affinity is just a + // post-sort, so order of operations doesn't change correctness. + if (touchedIds.size > 0) { + const reorder = (rows: T[]) => { + const indexed = rows.map((row, idx) => ({ row, idx, hit: touchedIds.has(row.id) })); + indexed.sort((a, b) => Number(b.hit) - Number(a.hit) || a.idx - b.idx); + return indexed.map((x) => x.row); + }; + results.clients = reorder(results.clients); + results.residentialClients = reorder(results.residentialClients); + results.yachts = reorder(results.yachts); + results.companies = reorder(results.companies); + results.interests = reorder(results.interests); + results.residentialInterests = reorder(results.residentialInterests); + results.berths = reorder(results.berths); + results.invoices = reorder(results.invoices); + results.expenses = reorder(results.expenses); + results.documents = reorder(results.documents); + results.files = reorder(results.files); + results.reminders = reorder(results.reminders); + } - const results = await search(ctx.portId, q); - - // Fire-and-forget - do not await - saveRecentSearch(ctx.userId, ctx.portId, q); + // Fire-and-forget — recent search history is non-critical. + if (parsed.q.length >= 2) { + saveRecentSearch(ctx.userId, ctx.portId, parsed.q); + } return NextResponse.json(results); } catch (error) { diff --git a/src/components/search/command-search.tsx b/src/components/search/command-search.tsx index f09b3c0..fda5141 100644 --- a/src/components/search/command-search.tsx +++ b/src/components/search/command-search.tsx @@ -1,61 +1,150 @@ 'use client'; -import { useEffect, useRef, useState, useCallback } from 'react'; +import { + type KeyboardEvent, + type ReactNode, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { Search, Clock, User, TrendingUp, Anchor, Ship, Building2 } from 'lucide-react'; +import { + Anchor, + Bell, + Briefcase, + Building2, + Camera, + Clock, + FileText, + Folder, + History, + Home, + LayoutDashboard, + MessageSquare, + Plus, + Receipt, + Search, + Settings as SettingsIcon, + Ship, + Tag as TagIcon, + TrendingUp, + User, +} from 'lucide-react'; +import { apiFetch } from '@/lib/api/client'; import { cn } from '@/lib/utils'; -import { useSearch } from '@/hooks/use-search'; +import { + useSearch, + type BucketType, + type RecentlyViewedItem, + type SearchResults, +} from '@/hooks/use-search'; import { useUIStore } from '@/stores/ui-store'; +import { HighlightMatch } from '@/components/search/highlight-match'; + +// ─── Bucket configuration ──────────────────────────────────────────────────── + +interface BucketConfig { + type: BucketType; + label: string; + icon: typeof User; +} + +const BUCKETS: BucketConfig[] = [ + { type: 'clients', label: 'Clients', icon: User }, + { type: 'residentialClients', label: 'Residential', icon: Home }, + { type: 'yachts', label: 'Yachts', icon: Ship }, + { type: 'companies', label: 'Companies', icon: Building2 }, + { type: 'interests', label: 'Interests', icon: TrendingUp }, + { type: 'residentialInterests', label: 'Res. interests', icon: TrendingUp }, + { type: 'berths', label: 'Berths', icon: Anchor }, + { type: 'invoices', label: 'Invoices', icon: FileText }, + { type: 'expenses', label: 'Expenses', icon: Receipt }, + { type: 'documents', label: 'Documents', icon: Briefcase }, + { type: 'files', label: 'Files', icon: Folder }, + { type: 'reminders', label: 'Reminders', icon: Bell }, + { type: 'brochures', label: 'Brochures', icon: Camera }, + { type: 'tags', label: 'Tags', icon: TagIcon }, + { type: 'navigation', label: 'Settings', icon: SettingsIcon }, + // Notes always last — broad content search is noisy. + { type: 'notes', label: 'Notes', icon: MessageSquare }, +]; + +const NAV_ICON: Record = { + dashboard: LayoutDashboard, + settings: SettingsIcon, + admin: SettingsIcon, +}; + +// ─── Paste detection ───────────────────────────────────────────────────────── + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const INVOICE_RE = /^INV-\d{6}-\d+$/i; + +function looksLikePastedId(input: string): boolean { + const trimmed = input.trim(); + return UUID_RE.test(trimmed) || INVOICE_RE.test(trimmed); +} + +// ─── Component ─────────────────────────────────────────────────────────────── export function CommandSearch() { - const [focused, setFocused] = useState(false); const [query, setQuery] = useState(''); + const [focused, setFocused] = useState(false); + const [activeBucket, setActiveBucket] = useState('all'); + const [focusIndex, setFocusIndex] = useState(-1); + const router = useRouter(); const portSlug = useUIStore((s) => s.currentPortSlug); + const wrapperRef = useRef(null); const inputRef = useRef(null); + const listboxId = useId(); - const { results, isLoading, recentSearches } = useSearch(query); + const { results, isFetching, recentSearches, recentlyViewed } = useSearch(query, { + type: activeBucket === 'all' ? undefined : activeBucket, + // Slightly higher cap when narrowed to one bucket — gives the user + // room to scan more matches without paging out to /search. + limit: activeBucket === 'all' ? 5 : 15, + }); - const showDropdown = focused && (query.length > 0 || recentSearches.length > 0); - const hasQuery = query.length >= 2; - const hasResults = - results && - (results.clients.length > 0 || - results.interests.length > 0 || - results.berths.length > 0 || - results.yachts.length > 0 || - results.companies.length > 0); + const showDropdown = focused; - // Cmd/Ctrl+K focuses the input + // Cmd/Ctrl+K focuses the input from anywhere on the page. useEffect(() => { - function onKeyDown(e: KeyboardEvent) { + function onKeyDown(e: globalThis.KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); inputRef.current?.focus(); + inputRef.current?.select(); } } document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, []); - // Click outside closes dropdown + // Click outside closes the dropdown. useEffect(() => { if (!focused) return; function onClick(e: MouseEvent) { if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { setFocused(false); + setFocusIndex(-1); } } document.addEventListener('mousedown', onClick); return () => document.removeEventListener('mousedown', onClick); }, [focused]); - const navigate = useCallback( + const closeAndNavigate = useCallback( (path: string) => { setFocused(false); setQuery(''); + setFocusIndex(-1); inputRef.current?.blur(); // eslint-disable-next-line @typescript-eslint/no-explicit-any router.push(path as any); @@ -63,32 +152,89 @@ export function CommandSearch() { [router], ); - // Keyboard nav inside dropdown - function onInputKeyDown(e: React.KeyboardEvent) { + // ── Paste detection: if the user pastes a UUID/INV-… into the input, + // fire the resolve-id endpoint and jump straight to the entity if + // it exists. Falls through to normal search otherwise. + const onPaste = useCallback( + async (e: React.ClipboardEvent) => { + const pasted = e.clipboardData.getData('text').trim(); + if (!looksLikePastedId(pasted)) return; + try { + const res = await apiFetch<{ found: boolean; href: string | null }>( + `/api/v1/search/resolve-id?id=${encodeURIComponent(pasted)}`, + ); + if (res.found && res.href) { + e.preventDefault(); + closeAndNavigate(res.href); + } + } catch { + // Best-effort — fall through to normal search. + } + }, + [closeAndNavigate], + ); + + // Build the flat list of focusable rows in render order, so arrow-key + // navigation walks them in the same order they appear visually. + const flatRows = useMemo(() => { + if (!showDropdown) return []; + return buildFlatRows({ + query, + results, + recentlyViewed, + recentSearches, + activeBucket, + portSlug: portSlug ?? null, + }); + }, [showDropdown, query, results, recentlyViewed, recentSearches, activeBucket, portSlug]); + + // Reset focus index when the visible row set changes. + useEffect(() => { + setFocusIndex(-1); + }, [activeBucket, query]); + + function onInputKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') { + e.preventDefault(); setFocused(false); + setFocusIndex(-1); inputRef.current?.blur(); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + setFocusIndex((i) => Math.min(i + 1, flatRows.length - 1)); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setFocusIndex((i) => Math.max(i - 1, -1)); + return; + } + if (e.key === 'Enter') { + const row = focusIndex >= 0 ? flatRows[focusIndex] : null; + if (row) { + e.preventDefault(); + if (row.kind === 'recent-term') { + setQuery(row.term); + return; + } + closeAndNavigate(row.href); + } + // Enter without a focused row is a no-op — the dropdown is already + // showing every relevant match (filter chips raise the cap when the + // user wants to see more of one bucket). No standalone /search + // page; refining the query is faster than scrolling further. } } - const iconMap = { - client: User, - interest: TrendingUp, - berth: Anchor, - yacht: Ship, - company: Building2, - } as const; + const activeOptionId = + focusIndex >= 0 && flatRows[focusIndex] + ? `${listboxId}-${flatRows[focusIndex].key}` + : undefined; return ( - // Width is now driven by the parent slot (topbar centers a 360–640px - // column). Removed fixed widths so the bar fills its container instead - // of shrinking to the old fixed pixel sizes.
- {/* Single persistent search bar. - Focus state is intentionally subtle: a 1px brand-coloured border, - no fat outer glow. The earlier `ring-4 ring-brand/15` produced a - chunky pale-blue rectangle that read as a stray UI element rather - than a focus indicator. */}
setQuery(e.target.value)} onFocus={() => setFocused(true)} + onPaste={onPaste} onKeyDown={onInputKeyDown} - placeholder="Search clients, yachts, berths... (⌘K)" - className="h-9 flex-1 min-w-0 bg-transparent text-sm outline-none ring-0 focus:outline-none focus:ring-0 placeholder:text-muted-foreground" + placeholder="Search clients, yachts, berths, invoices… (⌘K)" + aria-label="Search" + role="combobox" + aria-expanded={showDropdown} + aria-controls={listboxId} + aria-autocomplete="list" + aria-activedescendant={activeOptionId} + // Wrapper border swap is the focus indicator; suppress the + // global *:focus-visible ring that would otherwise paint a + // rectangular box clashing with the rounded wrapper. + className="h-9 flex-1 min-w-0 bg-transparent text-sm outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground" /> + {isFetching && query.length >= 2 && ( + + )}
- {/* ── Results dropdown ── */} {showDropdown && ( - // Dropdown width matches the search input (full width of the slot), - // capped on viewport so it doesn't bleed past the screen edge. -
-
- {/* No query yet - show recent or hint */} - {!hasQuery && recentSearches.length > 0 && ( -
-
Recent
- {recentSearches.map((term) => ( - - ))} -
+
+ {/* Filter chip row — always visible while the dropdown is open. */} + + +
+ {/* No query yet — recently viewed + recent terms. */} + {query.length < 2 && ( + )} - {!hasQuery && recentSearches.length === 0 && ( -
- Type at least 2 characters to search -
- )} - - {/* Loading */} - {hasQuery && isLoading && ( -
- Searching... -
- )} - - {/* No results */} - {hasQuery && !isLoading && !hasResults && ( -
- No results for “{query}” -
- )} - - {/* Result groups */} - {hasQuery && !isLoading && results && ( - <> - {results.clients.length > 0 && ( - ({ - id: c.id, - icon: 'client', - label: c.fullName, - sub: null, - }))} - iconMap={iconMap} - onSelect={(id) => navigate(`/${portSlug}/clients/${id}`)} - /> - )} - {results.yachts.length > 0 && ( - ({ - 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 && ( - ({ - 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 && ( - ({ - id: i.id, - icon: 'interest', - label: i.clientName, - sub: i.berthMooringNumber ?? i.pipelineStage, - }))} - iconMap={iconMap} - onSelect={(id) => navigate(`/${portSlug}/interests/${id}`)} - /> - )} - {results.berths.length > 0 && ( - ({ - id: b.id, - icon: 'berth', - label: b.mooringNumber, - sub: [b.area, b.status].filter(Boolean).join(' · '), - }))} - iconMap={iconMap} - onSelect={(id) => navigate(`/${portSlug}/berths/${id}`)} - /> - )} - + {/* Active query — results or zero-state. */} + {query.length >= 2 && ( + )}
+ + {/* Footer — keyboard hint only. */} + {query.length >= 2 && ( +
+ ↑↓ navigate · ↵ open · esc close +
+ )}
)}
); } -function ResultGroup({ - heading, - items, - iconMap, - onSelect, +// ─── Filter chips ──────────────────────────────────────────────────────────── + +function FilterChipRow({ + results, + active, + onChange, + disabled, }: { - heading: string; - items: Array<{ - id: string; - icon: 'client' | 'interest' | 'berth' | 'yacht' | 'company'; - label: string; - sub?: string | null; - }>; - iconMap: Record; - onSelect: (id: string) => void; + results: SearchResults | undefined; + active: BucketType | 'all'; + onChange: (b: BucketType | 'all') => void; + disabled: boolean; }) { + // Show a chip for every bucket so the user can browse the search + // surface even with no query; counts only render when results exist. return ( -
-
{heading}
- {items.map((item) => { - const Icon = iconMap[item.icon] ?? 'span'; +
+ onChange('all')} + count={undefined} + > + All + + {BUCKETS.map((b) => { + const count = results?.totals?.[b.type] ?? 0; + // Hide chips for buckets the current user can't see (count === 0 + // when the bucket query was permission-skipped) — but only after + // a query has run, otherwise we'd hide every chip on first paint. + if (!disabled && count === 0 && active !== b.type) return null; return ( - + {b.label} + ); })}
); } -// Keep export for backwards compat - it's a no-op +function ChipButton({ + active, + disabled, + count, + onClick, + children, +}: { + active: boolean; + disabled: boolean; + count?: number; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +// ─── Empty state (no query) ────────────────────────────────────────────────── + +function EmptyStateBeforeSearch({ + listboxId, + recentlyViewed, + recentSearches, + flatRows, + focusIndex, + onSelect, + onSelectTerm, +}: { + listboxId: string; + recentlyViewed: RecentlyViewedItem[]; + recentSearches: string[]; + flatRows: FlatRow[]; + focusIndex: number; + onSelect: (href: string) => void; + onSelectTerm: (term: string) => void; +}) { + if (recentlyViewed.length === 0 && recentSearches.length === 0) { + return ( +
+ Type at least 2 characters to search clients, yachts, berths, invoices, and more. +
+ ); + } + + return ( + <> + {recentlyViewed.length > 0 && ( +
+ Recently viewed + {recentlyViewed.map((item) => { + const row = flatRows.find( + (r) => r.kind === 'recent-view' && r.item.id === item.id && r.item.type === item.type, + ); + const isFocused = !!row && focusIndex >= 0 && flatRows[focusIndex] === row; + return ( + + ); + })} +
+ )} + {recentSearches.length > 0 && ( +
+ Recent searches + {recentSearches.map((term) => { + const row = flatRows.find((r) => r.kind === 'recent-term' && r.term === term); + const isFocused = !!row && focusIndex >= 0 && flatRows[focusIndex] === row; + return ( + + ); + })} +
+ )} + + ); +} + +// ─── Results region (active query) ─────────────────────────────────────────── + +function ResultsRegion({ + listboxId, + query, + results, + portSlug, + activeBucket, + flatRows, + focusIndex, + onSelect, +}: { + listboxId: string; + query: string; + results: SearchResults | undefined; + portSlug: string | null; + activeBucket: BucketType | 'all'; + flatRows: FlatRow[]; + focusIndex: number; + onSelect: (href: string) => void; +}) { + if (!results) { + return
Searching…
; + } + + const totalHits = Object.values(results.totals).reduce((acc, n) => acc + n, 0); + if (totalHits === 0) { + return ; + } + + // Render every bucket the active filter allows. + return ( + <> + {BUCKETS.map((b) => { + if (activeBucket !== 'all' && activeBucket !== b.type) return null; + const rowsForBucket = flatRows.filter((r) => r.kind === 'result' && r.bucket === b.type); + if (rowsForBucket.length === 0) return null; + + return ( + + {rowsForBucket.map((row) => { + if (row.kind !== 'result') return null; + const isFocused = focusIndex >= 0 && flatRows[focusIndex] === row; + return ( + + ); + })} + + ); + })} + + {results.otherPorts && results.otherPorts.length > 0 && activeBucket === 'all' && ( + + {results.otherPorts.map((row) => { + const flatRow = flatRows.find((r) => r.kind === 'other-port' && r.item === row); + const isFocused = !!flatRow && focusIndex >= 0 && flatRows[focusIndex] === flatRow; + return ( + + ); + })} + + )} + + ); +} + +function ZeroState({ query, portSlug }: { query: string; portSlug: string | null }) { + if (!portSlug) { + return ( +
+ No results for “{query}” +
+ ); + } + return ( +
+

+ No results for “{query}” +

+

Quick create

+
+ + + +
+
+ ); +} + +function QuickCreateButton({ + icon: Icon, + label, + href, +}: { + icon: typeof User; + label: string; + href: string; +}) { + return ( + + + + {label} + + ); +} + +// ─── Result row ────────────────────────────────────────────────────────────── + +function ResultRow({ + id, + row, + query, + isFocused, + onSelect, +}: { + id: string; + row: Extract; + query: string; + isFocused: boolean; + onSelect: (href: string) => void; +}) { + const Icon = row.icon; + return ( + + ); +} + +function Badge({ + label, + tone, +}: { + label: string; + tone: 'neutral' | 'warning' | 'success' | 'danger'; +}) { + const cls = { + neutral: 'bg-muted text-muted-foreground', + warning: 'bg-amber-100 text-amber-800', + success: 'bg-emerald-100 text-emerald-800', + danger: 'bg-rose-100 text-rose-800', + }[tone]; + return ( + + {label} + + ); +} + +function SectionHeading({ icon: Icon, children }: { icon: typeof User; children: ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +function BucketSection({ + icon, + label, + children, +}: { + icon: typeof User; + label: string; + children: ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} + +// ─── Flat-row construction (drives keyboard nav + ARIA) ────────────────────── + +type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' }; + +type FlatRow = + | { + kind: 'recent-view'; + key: string; + item: RecentlyViewedItem; + href: string; + } + | { + kind: 'recent-term'; + key: string; + term: string; + href: string; + } + | { + kind: 'result'; + key: string; + bucket: BucketType; + icon: typeof User; + label: string; + sub: string | null; + href: string; + badges?: ResultBadge[]; + } + | { + kind: 'other-port'; + key: string; + item: SearchResults['otherPorts'] extends (infer U)[] | undefined ? U : never; + href: string; + }; + +interface BuildFlatRowsArgs { + query: string; + results: SearchResults | undefined; + recentlyViewed: RecentlyViewedItem[]; + recentSearches: string[]; + activeBucket: BucketType | 'all'; + portSlug: string | null; +} + +function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] { + const { query, results, recentlyViewed, recentSearches, activeBucket, portSlug } = args; + const rows: FlatRow[] = []; + + if (query.length < 2) { + for (const item of recentlyViewed) { + rows.push({ + kind: 'recent-view', + key: `recent-view:${item.type}:${item.id}`, + item, + href: item.href, + }); + } + for (const term of recentSearches) { + rows.push({ + kind: 'recent-term', + key: `recent-term:${term}`, + term, + href: '', + }); + } + return rows; + } + + if (!results || !portSlug) return rows; + + const include = (b: BucketType) => activeBucket === 'all' || activeBucket === b; + + if (include('clients')) { + for (const c of results.clients) { + rows.push({ + kind: 'result', + key: `clients:${c.id}`, + bucket: 'clients', + icon: User, + label: c.fullName, + sub: c.matchedContact ?? null, + href: `/${portSlug}/clients/${c.id}`, + badges: c.archivedAt ? [{ label: 'Archived', tone: 'neutral' }] : undefined, + }); + } + } + if (include('residentialClients')) { + for (const c of results.residentialClients) { + rows.push({ + kind: 'result', + key: `residentialClients:${c.id}`, + bucket: 'residentialClients', + icon: Home, + label: c.fullName, + sub: c.email ?? c.phone ?? null, + href: `/${portSlug}/residential/clients/${c.id}`, + }); + } + } + if (include('yachts')) { + for (const y of results.yachts) { + const sub = [y.hullNumber, y.registration].filter(Boolean).join(' · ') || null; + rows.push({ + kind: 'result', + key: `yachts:${y.id}`, + bucket: 'yachts', + icon: Ship, + label: y.name, + sub, + href: `/${portSlug}/yachts/${y.id}`, + }); + } + } + if (include('companies')) { + for (const co of results.companies) { + const sub = [co.legalName, co.taxId].filter(Boolean).join(' · ') || null; + rows.push({ + kind: 'result', + key: `companies:${co.id}`, + bucket: 'companies', + icon: Building2, + label: co.name, + sub, + href: `/${portSlug}/companies/${co.id}`, + }); + } + } + if (include('interests')) { + for (const i of results.interests) { + const badges: ResultBadge[] = []; + if (i.outcome) { + badges.push({ + label: i.outcome.replace(/_/g, ' '), + tone: i.outcome === 'won' ? 'success' : 'neutral', + }); + } else { + badges.push({ label: i.pipelineStage.replace(/_/g, ' '), tone: 'warning' }); + } + rows.push({ + kind: 'result', + key: `interests:${i.id}`, + bucket: 'interests', + icon: TrendingUp, + label: i.clientName, + sub: i.berthMooringNumber, + href: `/${portSlug}/interests/${i.id}`, + badges: badges.length > 0 ? badges : undefined, + }); + } + } + if (include('residentialInterests')) { + for (const i of results.residentialInterests) { + rows.push({ + kind: 'result', + key: `residentialInterests:${i.id}`, + bucket: 'residentialInterests', + icon: TrendingUp, + label: i.clientName, + sub: i.pipelineStage.replace(/_/g, ' '), + href: `/${portSlug}/residential/interests/${i.id}`, + }); + } + } + if (include('berths')) { + for (const b of results.berths) { + const badges: ResultBadge[] = []; + if (b.status === 'sold') badges.push({ label: 'Sold', tone: 'success' }); + else if (b.status === 'under_offer') badges.push({ label: 'Under offer', tone: 'warning' }); + const sub = + [ + b.area, + b.linkedInterestCount > 0 + ? `${b.linkedInterestCount} interest${b.linkedInterestCount === 1 ? '' : 's'}` + : null, + ] + .filter(Boolean) + .join(' · ') || null; + rows.push({ + kind: 'result', + key: `berths:${b.id}`, + bucket: 'berths', + icon: Anchor, + label: b.mooringNumber, + sub, + href: `/${portSlug}/berths/${b.id}`, + badges: badges.length > 0 ? badges : undefined, + }); + } + } + if (include('invoices')) { + for (const inv of results.invoices) { + const badges: ResultBadge[] = []; + if (inv.status === 'overdue') badges.push({ label: 'Overdue', tone: 'danger' }); + else if (inv.paymentStatus === 'paid') badges.push({ label: 'Paid', tone: 'success' }); + else if (inv.status === 'sent') badges.push({ label: 'Sent', tone: 'neutral' }); + const sub = inv.totalAmount + ? `${inv.clientName} · ${inv.totalAmount} ${inv.currency}` + : inv.clientName; + rows.push({ + kind: 'result', + key: `invoices:${inv.id}`, + bucket: 'invoices', + icon: FileText, + label: inv.invoiceNumber, + sub, + href: `/${portSlug}/invoices/${inv.id}`, + badges: badges.length > 0 ? badges : undefined, + }); + } + } + if (include('expenses')) { + for (const e of results.expenses) { + const badges: ResultBadge[] = []; + if (e.paymentStatus === 'paid') badges.push({ label: 'Paid', tone: 'success' }); + const sub = [e.vendor, e.tripLabel].filter(Boolean).join(' · ') || null; + rows.push({ + kind: 'result', + key: `expenses:${e.id}`, + bucket: 'expenses', + icon: Receipt, + label: e.description ?? e.vendor ?? `${e.amount} ${e.currency}`, + sub, + href: `/${portSlug}/expenses/${e.id}`, + badges: badges.length > 0 ? badges : undefined, + }); + } + } + if (include('documents')) { + for (const d of results.documents) { + const badges: ResultBadge[] = []; + if (d.status === 'completed') badges.push({ label: 'Signed', tone: 'success' }); + else if (d.status === 'expired') badges.push({ label: 'Expired', tone: 'danger' }); + else if (d.status === 'sent') badges.push({ label: 'Awaiting signature', tone: 'warning' }); + rows.push({ + kind: 'result', + key: `documents:${d.id}`, + bucket: 'documents', + icon: Briefcase, + label: d.title, + sub: d.matchedSignerName, + href: `/${portSlug}/documents/${d.id}`, + badges: badges.length > 0 ? badges : undefined, + }); + } + } + if (include('files')) { + for (const f of results.files) { + rows.push({ + kind: 'result', + key: `files:${f.id}`, + bucket: 'files', + icon: Folder, + label: f.filename, + sub: f.ownerLabel, + href: `/${portSlug}/documents/files`, + }); + } + } + if (include('reminders')) { + for (const r of results.reminders) { + const badges: ResultBadge[] = []; + const due = new Date(r.dueAt); + if (due.getTime() < Date.now()) badges.push({ label: 'Overdue', tone: 'danger' }); + rows.push({ + kind: 'result', + key: `reminders:${r.id}`, + bucket: 'reminders', + icon: Bell, + label: r.title, + sub: due.toLocaleDateString(), + href: `/${portSlug}/reminders`, + badges: badges.length > 0 ? badges : undefined, + }); + } + } + if (include('brochures')) { + for (const b of results.brochures) { + rows.push({ + kind: 'result', + key: `brochures:${b.id}`, + bucket: 'brochures', + icon: Camera, + label: b.label, + sub: b.isDefault ? 'Default brochure' : null, + href: `/${portSlug}/settings`, + }); + } + } + if (include('tags')) { + for (const t of results.tags) { + rows.push({ + kind: 'result', + key: `tags:${t.id}`, + bucket: 'tags', + icon: TagIcon, + label: `Tag: ${t.name}`, + sub: `${t.totalCount} tagged`, + // Tag-filtered list view; until that list page exists, fall back + // to the tags settings page. + href: `/${portSlug}/clients?tag=${encodeURIComponent(t.name)}`, + }); + } + } + if (include('navigation')) { + for (const n of results.navigation) { + const Icon = NAV_ICON[n.category] ?? SettingsIcon; + rows.push({ + kind: 'result', + key: `navigation:${n.id}`, + bucket: 'navigation', + icon: Icon, + label: n.label, + sub: n.category, + // Catalog hrefs already have :portSlug substituted server-side. + href: n.href, + }); + } + } + // Notes go LAST — content matches inside notes are noisy by nature + // (free-text search across thousands of rows), so the user sees + // them only after the entity-specific buckets above have surfaced + // their tighter matches. + if (include('notes')) { + for (const n of results.notes) { + const sourceCollection = + n.source === 'client' + ? 'clients' + : n.source === 'interest' + ? 'interests' + : n.source === 'yacht' + ? 'yachts' + : 'companies'; + rows.push({ + kind: 'result', + key: `notes:${n.id}`, + bucket: 'notes', + icon: MessageSquare, + label: `${n.source.charAt(0).toUpperCase() + n.source.slice(1)} note · ${n.sourceLabel}`, + sub: n.snippet, + href: `/${portSlug}/${sourceCollection}/${n.sourceId}?tab=notes`, + }); + } + } + + if (results.otherPorts && activeBucket === 'all') { + for (const op of results.otherPorts) { + rows.push({ + kind: 'other-port', + key: `other:${op.portId}:${op.type}:${op.id}`, + item: op, + href: `/${op.portSlug}/${pluralize(op.type)}/${op.id}`, + }); + } + } + + return rows; +} + +function pluralize(type: string): string { + if (type === 'company') return 'companies'; + return `${type}s`; +} + +// Keep a no-op export for any legacy import sites. export function SearchTrigger() { return null; } diff --git a/src/components/search/highlight-match.tsx b/src/components/search/highlight-match.tsx new file mode 100644 index 0000000..8882ba3 --- /dev/null +++ b/src/components/search/highlight-match.tsx @@ -0,0 +1,54 @@ +import { Fragment, type ReactNode } from 'react'; + +/** + * Wrap occurrences of `query` inside `text` with `` so the user + * can see why a result matched. Case-insensitive, escapes regex meta- + * chars in the query so a paste of "INV-2025" doesn't blow up. + * + * Tokenized matching — splits the query on whitespace and highlights + * each token independently, so "joh smi" highlights both "Joh" and + * "Smi" in "John Smith". Mirrors the prefix-tsquery the server uses. + */ +export function HighlightMatch({ + text, + query, + className, +}: { + text: string | null | undefined; + query: string; + className?: string; +}): ReactNode { + if (!text) return null; + const tokens = query + .trim() + .split(/\s+/) + .filter((t) => t.length > 0) + .map(escapeRegex); + if (tokens.length === 0) return text; + + const re = new RegExp(`(${tokens.join('|')})`, 'gi'); + const parts = text.split(re); + + return ( + + {parts.map((part, i) => { + if (i % 2 === 1) { + // The capture group lands on odd indices. + return ( + + {part} + + ); + } + return {part}; + })} + + ); +} + +function escapeRegex(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/components/search/track-entity-view.tsx b/src/components/search/track-entity-view.tsx new file mode 100644 index 0000000..edcd76a --- /dev/null +++ b/src/components/search/track-entity-view.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { useTrackEntityView } from '@/hooks/use-track-entity-view'; + +/** + * Render-only client component that records "the user opened this + * entity" via the global-search recently-viewed Redis log. Drop into a + * server-rendered detail page as `` + * — the component renders nothing. + * + * Centralises the tracking call so future changes (debounce, schema, + * batching) only need to touch one file rather than every detail page. + */ +export function TrackEntityView({ + type, + id, +}: { + type: + | 'client' + | 'residential-client' + | 'yacht' + | 'company' + | 'interest' + | 'residential-interest' + | 'berth' + | 'invoice' + | 'expense' + | 'document'; + id: string | null | undefined; +}) { + useTrackEntityView(type, id); + return null; +} diff --git a/src/hooks/use-search.ts b/src/hooks/use-search.ts index 822b333..df5c9e6 100644 --- a/src/hooks/use-search.ts +++ b/src/hooks/use-search.ts @@ -1,57 +1,260 @@ 'use client'; -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { apiFetch } from '@/lib/api/client'; import { useDebounce } from '@/hooks/use-debounce'; -// ─── Types ──────────────────────────────────────────────────────────────────── +// ─── Types — mirror SearchResults from search.service.ts ───────────────────── -interface SearchResults { - clients: Array<{ id: string; fullName: string }>; - interests: Array<{ - id: string; - clientName: string; - berthMooringNumber: string | null; - pipelineStage: 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; - }>; +export type BucketType = + | 'clients' + | 'residentialClients' + | 'yachts' + | 'companies' + | 'interests' + | 'residentialInterests' + | 'berths' + | 'invoices' + | 'expenses' + | 'documents' + | 'files' + | 'reminders' + | 'brochures' + | 'tags' + | 'navigation' + | 'notes'; + +export interface ClientResult { + id: string; + fullName: string; + matchedContact: string | null; + matchedContactChannel: 'email' | 'phone' | 'whatsapp' | null; + archivedAt: string | null; +} +export interface ResidentialClientResult { + id: string; + fullName: string; + email: string | null; + phone: string | null; + status: string; + archivedAt: string | null; +} +export interface YachtResult { + id: string; + name: string; + hullNumber: string | null; + registration: string | null; + archivedAt: string | null; +} +export interface CompanyResult { + id: string; + name: string; + legalName: string | null; + taxId: string | null; + matchedField: 'name' | 'legalName' | 'taxId' | 'billingEmail' | 'registrationNumber' | null; + archivedAt: string | null; +} +export interface InterestResult { + id: string; + clientName: string; + berthMooringNumber: string | null; + pipelineStage: string; + outcome: string | null; +} +export interface ResidentialInterestResult { + id: string; + clientName: string; + pipelineStage: string; +} +export interface BerthResult { + id: string; + mooringNumber: string; + area: string | null; + status: string; + linkedInterestCount: number; +} +export interface InvoiceResult { + id: string; + invoiceNumber: string; + clientName: string; + status: string; + paymentStatus: string | null; + totalAmount: string | null; + currency: string; +} +export interface ExpenseResult { + id: string; + description: string | null; + vendor: string | null; + tripLabel: string | null; + amount: string; + currency: string; + paymentStatus: string | null; +} +export interface DocumentResult { + id: string; + title: string; + documentType: string; + status: string; + matchedSignerName: string | null; +} +export interface FileResult { + id: string; + filename: string; + category: string | null; + ownerLabel: string | null; +} +export interface ReminderResult { + id: string; + title: string; + dueAt: string; + priority: string; + status: string; +} +export interface BrochureResult { + id: string; + label: string; + isDefault: boolean; + archivedAt: string | null; +} +export interface TagResult { + id: string; + name: string; + color: string; + totalCount: number; +} +export interface NavResult { + id: string; + href: string; + label: string; + category: 'settings' | 'admin' | 'dashboard'; +} +export interface OtherPortResult { + portId: string; + portSlug: string; + portName: string; + type: 'client' | 'yacht' | 'company' | 'berth' | 'interest'; + id: string; + label: string; + sub: string | null; } -// ─── Hook ───────────────────────────────────────────────────────────────────── +export interface SearchResults { + clients: ClientResult[]; + residentialClients: ResidentialClientResult[]; + yachts: YachtResult[]; + companies: CompanyResult[]; + interests: InterestResult[]; + residentialInterests: ResidentialInterestResult[]; + berths: BerthResult[]; + invoices: InvoiceResult[]; + expenses: ExpenseResult[]; + documents: DocumentResult[]; + files: FileResult[]; + reminders: ReminderResult[]; + brochures: BrochureResult[]; + tags: TagResult[]; + navigation: NavResult[]; + notes: NoteResult[]; + totals: Record; + otherPorts?: OtherPortResult[]; +} -export function useSearch(query: string) { - const debouncedQuery = useDebounce(query, 300); +export interface NoteResult { + id: string; + snippet: string; + source: 'client' | 'interest' | 'yacht' | 'company'; + sourceId: string; + sourceLabel: string; + createdAt: string; +} + +export interface RecentlyViewedItem { + type: string; + id: string; + label: string; + sub: string | null; + href: string; + viewedAt: number; +} + +// ─── Hooks ──────────────────────────────────────────────────────────────────── + +export interface UseSearchOptions { + /** When set, narrows the result set to a single bucket. */ + type?: BucketType; + /** Per-bucket cap. Default 5 (dropdown); use 25 for the /search page. */ + limit?: number; + /** Super-admin opt-in for cross-port matches. Silently ignored otherwise. */ + includeOtherPorts?: boolean; + /** Override the 300ms input debounce. */ + debounceMs?: number; +} + +export function useSearch(query: string, opts: UseSearchOptions = {}) { + const debouncedQuery = useDebounce(query, opts.debounceMs ?? 300); + const enabled = debouncedQuery.length >= 2; + + const params = new URLSearchParams(); + params.set('q', debouncedQuery); + if (opts.type) params.set('type', opts.type); + if (opts.limit) params.set('limit', String(opts.limit)); + if (opts.includeOtherPorts) params.set('includeOtherPorts', 'true'); const searchQuery = useQuery({ - queryKey: ['search', debouncedQuery], - queryFn: () => - apiFetch(`/api/v1/search?q=${encodeURIComponent(debouncedQuery)}`), - enabled: debouncedQuery.length >= 2, + queryKey: [ + 'search', + debouncedQuery, + opts.type ?? 'all', + opts.limit ?? 5, + opts.includeOtherPorts ?? false, + ], + queryFn: ({ signal }) => + apiFetch(`/api/v1/search?${params.toString()}`, { signal }), + enabled, + // Keep previous results visible while the next debounced query loads + // — eliminates the dropdown flicker when the user is typing fast. + placeholderData: keepPreviousData, staleTime: 30_000, }); - const recentQuery = useQuery<{ searches: string[] }>({ - queryKey: ['search', 'recent'], - queryFn: () => apiFetch<{ searches: string[] }>('/api/v1/search/recent'), + const recentSearchQuery = useQuery<{ searches: string[] }>({ + queryKey: ['search', 'recent-terms'], + queryFn: ({ signal }) => apiFetch<{ searches: string[] }>('/api/v1/search/recent', { signal }), staleTime: 60_000, }); + const recentlyViewedQuery = useQuery<{ items: RecentlyViewedItem[] }>({ + queryKey: ['search', 'recently-viewed'], + queryFn: ({ signal }) => + apiFetch<{ items: RecentlyViewedItem[] }>('/api/v1/search/recently-viewed', { signal }), + staleTime: 30_000, + }); + return { results: searchQuery.data, isLoading: searchQuery.isLoading, - recentSearches: recentQuery.data?.searches ?? [], + isFetching: searchQuery.isFetching, + enabled, + recentSearches: recentSearchQuery.data?.searches ?? [], + recentlyViewed: recentlyViewedQuery.data?.items ?? [], }; } + +/** + * Track that the current user just opened an entity. Call once on mount + * from each entity detail page (via `useTrackEntityView`); the resulting + * (type, id) pair is used by the search dropdown's "Recently viewed" + * section to surface the user's working set. + */ +export async function trackEntityView(type: string, id: string): Promise { + try { + await apiFetch('/api/v1/search/recently-viewed', { + method: 'POST', + body: { type, id }, + }); + } catch { + // Tracking is non-critical — never bubble up to the user. + } +} diff --git a/src/hooks/use-track-entity-view.ts b/src/hooks/use-track-entity-view.ts new file mode 100644 index 0000000..107f4eb --- /dev/null +++ b/src/hooks/use-track-entity-view.ts @@ -0,0 +1,37 @@ +'use client'; + +import { useEffect } from 'react'; + +import { trackEntityView } from '@/hooks/use-search'; + +/** + * Records that the user opened the given entity detail page so the + * global search dropdown can surface it under "Recently viewed". Skips + * the call when `id` is falsy (e.g. during a transitional render before + * the data has loaded). + * + * Uses a JSON-stringified deps array so re-renders with the same + * (type, id) don't re-fire the network call. The fire-and-forget + * tracking endpoint debounces server-side too (Redis ZADD upserts the + * same member with a fresh score), but skipping the redundant fetch + * keeps the network panel tidy. + */ +export function useTrackEntityView( + type: + | 'client' + | 'residential-client' + | 'yacht' + | 'company' + | 'interest' + | 'residential-interest' + | 'berth' + | 'invoice' + | 'expense' + | 'document', + id: string | null | undefined, +): void { + useEffect(() => { + if (!id) return; + void trackEntityView(type, id); + }, [type, id]); +} diff --git a/src/lib/services/audit-search.service.ts b/src/lib/services/audit-search.service.ts index 5916526..7dc5a48 100644 --- a/src/lib/services/audit-search.service.ts +++ b/src/lib/services/audit-search.service.ts @@ -4,7 +4,7 @@ * `audit_logs.search_text`. */ -import { and, desc, eq, gte, lte, sql, type SQL } from 'drizzle-orm'; +import { and, desc, eq, gte, inArray, lte, sql, type SQL } from 'drizzle-orm'; import { db } from '@/lib/db'; import { auditLogs, type AuditLog } from '@/lib/db/schema/system'; @@ -22,6 +22,10 @@ export interface AuditSearchOptions { entityType?: string; /** Filter by exact entity id (e.g. paste a uuid into search). */ entityId?: string; + /** Filter by an explicit list of entity ids (e.g. aggregated activity + * for a client across all their interests). Overrides `entityId` + * when both are supplied. Empty array short-circuits to zero rows. */ + entityIds?: string[]; /** Filter by severity ('info' | 'warning' | 'error' | 'critical'). */ severity?: string; /** Filter by source ('user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job'). */ @@ -45,7 +49,15 @@ export async function searchAuditLogs(options: AuditSearchOptions = {}): Promise if (options.userId) conds.push(eq(auditLogs.userId, options.userId)); if (options.action) conds.push(eq(auditLogs.action, options.action)); if (options.entityType) conds.push(eq(auditLogs.entityType, options.entityType)); - if (options.entityId) conds.push(eq(auditLogs.entityId, options.entityId)); + if (options.entityIds) { + if (options.entityIds.length === 0) { + // Short-circuit: caller passed an empty list → no possible match. + return { rows: [], nextCursor: null }; + } + conds.push(inArray(auditLogs.entityId, options.entityIds)); + } else if (options.entityId) { + conds.push(eq(auditLogs.entityId, options.entityId)); + } if (options.severity) conds.push(eq(auditLogs.severity, options.severity)); if (options.source) conds.push(eq(auditLogs.source, options.source)); if (options.from) conds.push(gte(auditLogs.createdAt, options.from)); diff --git a/src/lib/services/recently-viewed.service.ts b/src/lib/services/recently-viewed.service.ts new file mode 100644 index 0000000..9a8498e --- /dev/null +++ b/src/lib/services/recently-viewed.service.ts @@ -0,0 +1,120 @@ +/** + * Tracks the entities each user has recently opened so the global search + * dropdown can surface "Recently viewed" suggestions before the user types. + * + * Storage: Redis sorted set per (user, port). Key is + * `recent-views::`, score is the unix epoch of the view, + * member is `:`. We trim the set to RECENT_VIEW_MAX + * after every write so stale ids age out. + * + * The companion API route hydrates the typed/labelled rows on read by + * looking up the underlying tables; this service only deals in (type, id) + * pairs to stay schema-free and cheap. + */ + +import { redis } from '@/lib/redis'; + +export type RecentlyViewedType = + | 'client' + | 'residential-client' + | 'yacht' + | 'company' + | 'interest' + | 'residential-interest' + | 'berth' + | 'invoice' + | 'expense' + | 'document'; + +export interface RecentlyViewedEntry { + type: RecentlyViewedType; + id: string; + /** Unix milliseconds of the most recent view. */ + viewedAt: number; +} + +const RECENT_VIEW_TTL = 60 * 60 * 24 * 30; // 30 days +const RECENT_VIEW_MAX = 20; + +function key(userId: string, portId: string): string { + return `recent-views:${userId}:${portId}`; +} + +function encode(type: RecentlyViewedType, id: string): string { + return `${type}:${id}`; +} + +function decode(member: string): { type: RecentlyViewedType; id: string } | null { + const colon = member.indexOf(':'); + if (colon < 0) return null; + return { + type: member.slice(0, colon) as RecentlyViewedType, + id: member.slice(colon + 1), + }; +} + +/** + * Records an entity view. Fire-and-forget — caller should NOT await this + * in the hot path; the redis op is logged-and-swallowed on failure since + * a missed view never breaks the user experience. + */ +export function trackView( + userId: string, + portId: string, + type: RecentlyViewedType, + id: string, +): void { + if (!userId || !portId || !id) return; + + const k = key(userId, portId); + const member = encode(type, id); + const now = Date.now(); + + redis + .zadd(k, now, member) + .then(() => redis.zremrangebyrank(k, 0, -(RECENT_VIEW_MAX + 1))) + .then(() => redis.expire(k, RECENT_VIEW_TTL)) + .catch(() => { + // intentionally swallowed + }); +} + +/** + * Returns the user's recently-viewed entities, newest first. Limit is + * defensively capped so a misbehaving caller can't pull thousands of rows. + */ +export async function getRecentlyViewed( + userId: string, + portId: string, + limit = 10, +): Promise { + const k = key(userId, portId); + const cap = Math.min(Math.max(limit, 1), RECENT_VIEW_MAX); + + // ZREVRANGE WITHSCORES → flat array [member, score, member, score, …] + const raw = await redis.zrevrange(k, 0, cap - 1, 'WITHSCORES'); + + const out: RecentlyViewedEntry[] = []; + for (let i = 0; i < raw.length; i += 2) { + const member = raw[i]; + const score = raw[i + 1]; + if (!member || !score) continue; + const decoded = decode(member); + if (!decoded) continue; + out.push({ ...decoded, viewedAt: Number(score) }); + } + return out; +} + +/** + * Removes a single entity from a user's recent-views (e.g. after the + * entity is hard-deleted). Best-effort. + */ +export function forgetView( + userId: string, + portId: string, + type: RecentlyViewedType, + id: string, +): void { + redis.zrem(key(userId, portId), encode(type, id)).catch(() => {}); +} diff --git a/src/lib/services/search-nav-catalog.ts b/src/lib/services/search-nav-catalog.ts new file mode 100644 index 0000000..a3ef060 --- /dev/null +++ b/src/lib/services/search-nav-catalog.ts @@ -0,0 +1,222 @@ +/** + * Static catalog of navigation destinations the global search bar can jump + * to: settings pages, admin panels, and top-level dashboards. + * + * Each entry has an `href` template (run through `resolveHref` with the + * current portSlug), a human label, and a list of keyword aliases. The + * search service substring-matches the query against the label + every + * keyword, so `smtp` lands on the email settings page even though the + * label reads "Email accounts". + * + * Why hardcoded vs introspecting routes? The catalog is curated — only + * pages worth jumping to from a global search appear here, and each + * entry has hand-picked keyword synonyms that route inference can't + * derive. Adding a route to the catalog is cheap; misfiring routes are + * expensive. + */ + +import type { RolePermissions } from '@/lib/db/schema/users'; + +export type NavCatalogCategory = 'settings' | 'admin' | 'dashboard'; + +export interface NavCatalogEntry { + /** Path template — `:portSlug` is substituted at lookup time. */ + href: string; + label: string; + category: NavCatalogCategory; + /** Lowercase aliases — query is matched against label + these. */ + keywords: string[]; + /** + * Permission gate; only shown to users whose `RolePermissions` resolves + * truthy at the given dot-path (e.g. `'admin.manage_users'`). Super + * admins bypass the gate. + */ + requires?: string; + /** When set, only super admins see the entry. */ + superAdminOnly?: boolean; +} + +export const NAV_CATALOG: NavCatalogEntry[] = [ + // ─── Dashboards ───────────────────────────────────────────────────────── + { + href: '/:portSlug/dashboard', + label: 'Dashboard', + category: 'dashboard', + keywords: ['home', 'overview', 'kpis', 'metrics'], + }, + { + href: '/:portSlug/website-analytics', + label: 'Website analytics', + category: 'dashboard', + keywords: ['umami', 'traffic', 'visitors', 'pageviews', 'marketing'], + }, + + // ─── Settings ─────────────────────────────────────────────────────────── + { + href: '/:portSlug/settings', + label: 'Settings', + category: 'settings', + keywords: ['preferences', 'configuration', 'config'], + }, + { + href: '/:portSlug/settings/email', + label: 'Email accounts (SMTP / IMAP)', + category: 'settings', + keywords: [ + 'smtp', + 'imap', + 'mail', + 'mail server', + 'email credentials', + 'send-from', + 'inbox', + 'bounces', + ], + requires: 'admin.manage_settings', + }, + { + href: '/:portSlug/settings/branding', + label: 'Branding (per-port logo, colors, copy)', + category: 'settings', + keywords: ['logo', 'theme', 'colors', 'tenant brand', 'white-label'], + requires: 'admin.manage_settings', + }, + { + href: '/:portSlug/settings/templates', + label: 'Document templates', + category: 'settings', + keywords: ['eoi', 'documenso', 'pdf templates', 'template merge fields'], + requires: 'admin.manage_settings', + }, + { + href: '/:portSlug/settings/storage', + label: 'File storage backend', + category: 'settings', + keywords: ['s3', 'minio', 'filesystem', 'storage'], + requires: 'admin.manage_settings', + }, + { + href: '/:portSlug/settings/recommender', + label: 'Berth recommender weights', + category: 'settings', + keywords: ['ranking', 'tier ladder', 'heat', 'fallthrough', 'recommend'], + requires: 'admin.manage_settings', + }, + { + href: '/:portSlug/settings/tags', + label: 'Tags', + category: 'settings', + keywords: ['labels', 'categories', 'classification'], + }, + { + href: '/:portSlug/settings/notifications', + label: 'Notification preferences', + category: 'settings', + keywords: ['alerts', 'email digest', 'in-app', 'push'], + }, + + // ─── Admin ────────────────────────────────────────────────────────────── + { + href: '/:portSlug/admin', + label: 'Administration', + category: 'admin', + keywords: ['admin'], + requires: 'admin.manage_users', + }, + { + href: '/:portSlug/admin/users', + label: 'Users & roles', + category: 'admin', + keywords: ['accounts', 'permissions', 'invites', 'team', 'staff', 'roles'], + requires: 'admin.manage_users', + }, + { + href: '/:portSlug/admin/audit-log', + label: 'Audit log', + category: 'admin', + keywords: ['activity', 'history', 'events', 'who did what', 'compliance'], + requires: 'admin.view_audit_log', + }, + { + href: '/:portSlug/admin/inquiries', + label: 'Website inquiries inbox', + category: 'admin', + keywords: ['enquiries', 'leads', 'contact form', 'eoi requests', 'website'], + }, + { + href: '/:portSlug/admin/error-events', + label: 'Platform errors', + category: 'admin', + keywords: ['errors', 'exceptions', 'incidents', 'failures'], + superAdminOnly: true, + }, +]; + +/** Substitute `:portSlug` placeholder for the current port. */ +export function resolveHref(href: string, portSlug: string): string { + return href.replace(':portSlug', portSlug); +} + +/** + * Returns nav catalog entries matching the query, filtered by what the + * current user is allowed to see. Match is a substring check against + * label + each keyword; ranking favors label hits over keyword hits and + * prefix hits over mid-string hits. + * + * Pure / sync — runs in-process. The catalog is ~15 entries today, so + * the linear scan is irrelevant cost-wise. + */ +export function searchNavCatalog( + query: string, + opts: { isSuperAdmin: boolean; permissions: RolePermissions | null; limit?: number }, +): Array { + const q = query.trim().toLowerCase(); + if (q.length === 0) return []; + + const limit = opts.limit ?? 5; + const out: Array = []; + + for (const entry of NAV_CATALOG) { + if (entry.superAdminOnly && !opts.isSuperAdmin) continue; + if (entry.requires && !opts.isSuperAdmin && !hasPermission(opts.permissions, entry.requires)) { + continue; + } + + const score = scoreEntry(q, entry); + if (score > 0) out.push({ ...entry, score }); + } + + out.sort((a, b) => b.score - a.score); + return out.slice(0, limit); +} + +function scoreEntry(q: string, entry: NavCatalogEntry): number { + const label = entry.label.toLowerCase(); + + // Strongest signals first. + if (label === q) return 100; + if (label.startsWith(q)) return 80; + if (label.includes(q)) return 60; + + // Keyword hits — strongest if the keyword exactly equals the query + // (e.g. user types "smtp"), then prefix, then substring. + for (const kw of entry.keywords) { + const k = kw.toLowerCase(); + if (k === q) return 50; + if (k.startsWith(q)) return 35; + if (k.includes(q)) return 20; + } + + return 0; +} + +function hasPermission(perms: RolePermissions | null, dotPath: string): boolean { + if (!perms) return false; + const parts = dotPath.split('.'); + let cur: unknown = perms; + for (const part of parts) { + if (typeof cur !== 'object' || cur === null) return false; + cur = (cur as Record)[part]; + } + return cur === true; +} diff --git a/src/lib/services/search.service.ts b/src/lib/services/search.service.ts index 79c3053..1142cbf 100644 --- a/src/lib/services/search.service.ts +++ b/src/lib/services/search.service.ts @@ -1,205 +1,1572 @@ +/** + * Global search service — drives the topbar `CommandSearch` dropdown. + * + * Buckets covered: clients (with email/phone via client_contacts JOIN), + * residential clients, yachts, companies, interests (federated when a + * berth/yacht/client matches), berths (with linked-interest count), + * invoices, expenses, documents (by title or signer name/email), files, + * reminders, brochures, tags (as meta-rows pointing at filtered lists), + * and a static navigation/settings catalog. + * + * Matching strategy per column type: + * - Long text fields (full_name, company name, yacht name, descriptions) + * use `to_tsvector('simple', col) @@ to_tsquery('simple', "joh:* & smi:*")` + * so partial words match mid-typing — `joh smi` finds "John Smith". + * A trigram (`similarity()`) fallback is unioned in for typo tolerance + * on names ("Jhon" → "John"). + * - Identifier fields (mooring numbers, hull/registration, tax IDs, + * invoice numbers) use `ILIKE '%query%'` with a prefix-anchored bonus + * in the ORDER BY. + * - Phones are matched by stripping the input down to digits and `+` + * and ILIKE-ing against `value_e164` (the canonical normalized form + * populated by the i18n PhoneInput pipeline). + * + * Permissions: the caller passes `isSuperAdmin` + `permissions`. Each + * bucket gates itself — viewers don't see invoice/expense rows they + * couldn't open. The query for the bucket is skipped entirely (cheaper + * than running it and filtering empty results out). + * + * Cross-port (super-admin only): when `includeOtherPorts` is set, a + * second pass runs the same queries against ports the super-admin can + * see other than `portId`. Returned in a separate `otherPorts` field + * so the UI can present them as a dimmed secondary section. + * + * Affinity ranking: callers may pass a `recentlyTouchedIds` Set — + * matching rows whose id is in the set get sorted to the top of their + * bucket. The id set is derived from the user's last 30 days of + * `audit_logs` writes (see `getRecentlyTouchedIds`). This is the cheap + * "your John" vs "some John" boost; we don't try to do per-bucket + * audit-log JOINs because the boost only matters for at most 5 visible + * rows and the post-sort is O(n log n) on a tiny n. + */ + import { sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { redis } from '@/lib/redis'; +import type { RolePermissions } from '@/lib/db/schema/users'; // ─── Types ──────────────────────────────────────────────────────────────────── -interface ClientResult { +export interface ClientResult { id: string; fullName: string; + matchedContact: string | null; + matchedContactChannel: 'email' | 'phone' | 'whatsapp' | null; + archivedAt: string | null; } -interface InterestResult { +export interface ResidentialClientResult { + id: string; + fullName: string; + email: string | null; + phone: string | null; + status: string; + archivedAt: string | null; +} + +export interface InterestResult { id: string; clientName: string; berthMooringNumber: string | null; pipelineStage: string; + outcome: string | null; } -interface BerthResult { +export interface ResidentialInterestResult { + id: string; + clientName: string; + pipelineStage: string; +} + +export interface BerthResult { id: string; mooringNumber: string; area: string | null; status: string; + linkedInterestCount: number; } -interface YachtResult { +export interface YachtResult { id: string; name: string; hullNumber: string | null; registration: string | null; + archivedAt: string | null; } -interface CompanyResult { +export interface CompanyResult { id: string; name: string; legalName: string | null; taxId: string | null; + matchedField: 'name' | 'legalName' | 'taxId' | 'billingEmail' | 'registrationNumber' | null; + archivedAt: string | null; } -interface SearchResults { +export interface InvoiceResult { + id: string; + invoiceNumber: string; + clientName: string; + status: string; + paymentStatus: string | null; + totalAmount: string | null; + currency: string; +} + +export interface ExpenseResult { + id: string; + description: string | null; + vendor: string | null; + tripLabel: string | null; + amount: string; + currency: string; + paymentStatus: string | null; +} + +export interface DocumentResult { + id: string; + title: string; + documentType: string; + status: string; + matchedSignerName: string | null; +} + +export interface FileResult { + id: string; + filename: string; + category: string | null; + /** "client:" | "yacht:" | "company:" — best owner label. */ + ownerLabel: string | null; +} + +export interface ReminderResult { + id: string; + title: string; + dueAt: string; + priority: string; + status: string; +} + +export interface BrochureResult { + id: string; + label: string; + isDefault: boolean; + archivedAt: string | null; +} + +export interface TagResult { + id: string; + name: string; + color: string; + /** Sum of clients + interests + yachts + companies tagged with this tag. */ + totalCount: number; +} + +export interface NavResult { + /** Stable ID = href, since the catalog is a static set. */ + id: string; + href: string; + label: string; + category: 'settings' | 'admin' | 'dashboard'; +} + +/** + * Note-fragment match. Polymorphic across the four note tables + * (client / interest / yacht / company). Each row carries enough + * context for the dropdown to show a snippet + parent-entity link + * without a second round-trip. + */ +export interface NoteResult { + id: string; + /** Trimmed snippet of the matching note content. */ + snippet: string; + /** Source entity type — drives the link target + chip label. */ + source: 'client' | 'interest' | 'yacht' | 'company'; + sourceId: string; + /** Friendly label for the source (e.g. "Mary Smith", "B17", "Sea Breeze"). */ + sourceLabel: string; + createdAt: Date; +} + +export interface SearchResults { clients: ClientResult[]; - interests: InterestResult[]; - berths: BerthResult[]; + residentialClients: ResidentialClientResult[]; yachts: YachtResult[]; companies: CompanyResult[]; + interests: InterestResult[]; + residentialInterests: ResidentialInterestResult[]; + berths: BerthResult[]; + invoices: InvoiceResult[]; + expenses: ExpenseResult[]; + documents: DocumentResult[]; + files: FileResult[]; + reminders: ReminderResult[]; + brochures: BrochureResult[]; + tags: TagResult[]; + navigation: NavResult[]; + notes: NoteResult[]; + /** + * Total count BEFORE per-bucket cap. Lets the UI render + * "Show 12 more clients" links into the dedicated /search page. + */ + totals: Record, number>; + /** + * Cross-port matches (super-admin only when `includeOtherPorts` is set). + * Each row is annotated with the originating port so the UI can show + * "Port Amador · Client · Jane Smith". + */ + otherPorts?: OtherPortResult[]; } -// ─── Search ─────────────────────────────────────────────────────────────────── +export interface OtherPortResult { + portId: string; + portSlug: string; + portName: string; + type: 'client' | 'yacht' | 'company' | 'berth' | 'interest'; + id: string; + label: string; + sub: string | null; +} -export async function search(portId: string, query: string): Promise { - const [clientRows, berthRows, interestRows, yachtRows, companyRows] = await Promise.all([ - // Clients: full-text search via tsvector - db.execute<{ id: string; full_name: string }>(sql` - SELECT id, full_name - FROM clients - WHERE port_id = ${portId} - AND archived_at IS NULL - AND to_tsvector('simple', coalesce(full_name, '')) - @@ plainto_tsquery('simple', ${query}) - ORDER BY ts_rank( - to_tsvector('simple', coalesce(full_name, '')), - plainto_tsquery('simple', ${query}) - ) DESC - LIMIT 10 - `), +export interface SearchOptions { + /** Permission shape used to gate buckets. null = super_admin (see all). */ + permissions: RolePermissions | null; + isSuperAdmin: boolean; + /** Limit per bucket (default 5 for dropdown, 25 for /search page). */ + limit?: number; + /** When set, only this bucket's query runs (used by /search?type=clients). */ + type?: keyof Omit; + /** Super-admin only — also search ports the user can access other than `portId`. */ + includeOtherPorts?: boolean; + /** Set of entity ids the user has recently touched (for affinity boost). */ + recentlyTouchedIds?: Set; +} - // Berths: trigram similarity on mooring_number - db.execute<{ id: string; mooring_number: string; area: string | null; status: string }>(sql` - SELECT id, mooring_number, area, status - FROM berths - WHERE port_id = ${portId} - AND mooring_number % ${query} - ORDER BY similarity(mooring_number, ${query}) DESC - LIMIT 10 - `), +// ─── Helpers ────────────────────────────────────────────────────────────────── - // Interests: JOIN to clients and primary-berth via interest_berths - // (plan §3.4 - the legacy interests.berth_id column has been replaced - // by the junction). - db.execute<{ - id: string; - full_name: string; - mooring_number: string | null; - pipeline_stage: string; - }>(sql` - SELECT +/** + * Build a `to_tsquery('simple', $1)` argument from free-text input that + * does prefix matching per token. Returns null if no usable token is + * present after sanitization. + * + * Sanitization is critical — `to_tsquery` raises a syntax error on + * unescaped `&`, `|`, `:`, `!`, `(`, `)` etc., and we don't want a query + * for "AT&T" to fail loudly when the user just wants the obvious match. + */ +export function buildPrefixTsquery(input: string): string | null { + const tokens = input + .toLowerCase() + .split(/\s+/) + .map((t) => t.replace(/[^a-z0-9_]/g, '')) + .filter((t) => t.length > 0); + if (tokens.length === 0) return null; + return tokens.map((t) => `${t}:*`).join(' & '); +} + +/** + * Normalize a phone-like query to digits-and-plus only so it can be + * matched against `client_contacts.value_e164` (which stores `+447700…` + * without spaces or punctuation). Returns null if the result is too + * short to be meaningfully unique. + */ +export function normalizePhoneQuery(input: string): string | null { + const digits = input.replace(/[^0-9+]/g, ''); + return digits.length >= 3 ? digits : null; +} + +/** + * Returns true when the input looks email-shaped enough to bother + * running an email-targeted match (otherwise we'd run an ILIKE that + * matches "@" inside random text and waste cycles). + */ +function looksLikeEmail(input: string): boolean { + return /[a-z0-9._%+-]+(@|@?[a-z0-9-]+\.)/i.test(input); +} + +/** Permissions check used to skip buckets the user can't see. */ +function can(opts: Pick, dotPath: string): boolean { + if (opts.isSuperAdmin) return true; + if (!opts.permissions) return false; + const parts = dotPath.split('.'); + let cur: unknown = opts.permissions; + for (const p of parts) { + if (typeof cur !== 'object' || cur === null) return false; + cur = (cur as Record)[p]; + } + return cur === true; +} + +/** + * Sort matched rows so entries the current user has recently touched + * float to the top of their bucket. Stable wrt original order otherwise. + */ +function applyAffinity(rows: T[], touched?: Set): T[] { + if (!touched || touched.size === 0) return rows; + const indexed = rows.map((row, idx) => ({ row, idx })); + indexed.sort((a, b) => { + const aHit = touched.has(a.row.id) ? 1 : 0; + const bHit = touched.has(b.row.id) ? 1 : 0; + if (aHit !== bHit) return bHit - aHit; + return a.idx - b.idx; + }); + return indexed.map((x) => x.row); +} + +// ─── Affinity source ───────────────────────────────────────────────────────── + +/** + * Returns the set of entity ids the user has interacted with in the + * last `days` days, capped at `limit` rows. Used to boost ranking so + * "John" means *your* John, not a stranger. + * + * Reads from `audit_logs` which records create/update/delete; this misses + * pure read-only views, but that's fine — read-only "I just looked at + * this client" tracking is handled separately by `recently-viewed.service` + * (different signal, different surface). + */ +export async function getRecentlyTouchedIds( + userId: string, + portId: string, + opts: { days?: number; limit?: number } = {}, +): Promise> { + const days = opts.days ?? 30; + const limit = opts.limit ?? 200; + + // Order by most-recent-touch so when the cap kicks in we keep the + // entries with the freshest signal (rather than alphabetically-first). + const rows = await db.execute<{ entity_id: string }>(sql` + SELECT entity_id + FROM ( + SELECT entity_id, MAX(created_at) AS last_at + FROM audit_logs + WHERE user_id = ${userId} + AND port_id = ${portId} + AND entity_id IS NOT NULL + AND created_at >= NOW() - (${days}::int * INTERVAL '1 day') + GROUP BY entity_id + ORDER BY last_at DESC + LIMIT ${limit} + ) recent + `); + + const set = new Set(); + for (const row of Array.from(rows)) { + if (row.entity_id) set.add(row.entity_id); + } + return set; +} + +// ─── Per-bucket queries ────────────────────────────────────────────────────── + +const DEFAULT_LIMIT = 5; + +// Safe sentinels so we never bind NULL into to_tsquery/ILIKE — Postgres +// evaluation order is unspecified, so a NULL guard in WHERE may not +// reliably short-circuit the function call. These strings are valid in +// every context they're used and never realistically match content. +const NEVER_TSQUERY = 'zzznomatchzzz'; +const NEVER_PHONE = '~~no_phone_match~~'; + +async function searchClients( + portId: string, + query: string, + limit: number, +): Promise { + const tsQ = buildPrefixTsquery(query) ?? NEVER_TSQUERY; + const phoneQ = normalizePhoneQuery(query) ?? NEVER_PHONE; + const ilikePattern = `%${query}%`; + + // Two paths unioned by the OR: + // (a) match on full_name via tsvector (prefix per token) OR trigram + // (b) match on a contact row (email, phone) via JOIN + // The DISTINCT ON keeps one row per client even when both name and + // contact match. + const rows = await db.execute<{ + id: string; + full_name: string; + matched_value: string | null; + matched_channel: 'email' | 'phone' | 'whatsapp' | null; + archived_at: Date | null; + rank: number; + }>(sql` + SELECT * FROM ( + SELECT DISTINCT ON (c.id) + c.id, + c.full_name, + -- Only surface the contact value when it actually matched the + -- query. Otherwise the LEFT JOIN's first-found contact row + -- would be shown as a misleading "matched on" subtitle. + CASE + WHEN cc.value ILIKE ${ilikePattern} + OR (cc.value_e164 IS NOT NULL AND cc.value_e164 ILIKE ${'%' + phoneQ + '%'}) + THEN cc.value + ELSE NULL + END AS matched_value, + CASE + WHEN cc.value ILIKE ${ilikePattern} + OR (cc.value_e164 IS NOT NULL AND cc.value_e164 ILIKE ${'%' + phoneQ + '%'}) + THEN cc.channel + ELSE NULL + END AS matched_channel, + c.archived_at, + CASE + WHEN c.full_name ILIKE ${query + '%'} THEN 100 + WHEN c.full_name ILIKE ${ilikePattern} THEN 80 + WHEN to_tsvector('simple', coalesce(c.full_name, '')) + @@ to_tsquery('simple', ${tsQ}) THEN 70 + WHEN cc.value ILIKE ${ilikePattern} THEN 60 + WHEN cc.value_e164 IS NOT NULL + AND cc.value_e164 ILIKE ${'%' + phoneQ + '%'} THEN 55 + WHEN similarity(c.full_name, ${query}) > 0.3 THEN 30 + ELSE 0 + END AS rank + FROM clients c + LEFT JOIN client_contacts cc ON cc.client_id = c.id + WHERE c.port_id = ${portId} + AND c.archived_at IS NULL + AND ( + c.full_name ILIKE ${ilikePattern} + OR to_tsvector('simple', coalesce(c.full_name, '')) + @@ to_tsquery('simple', ${tsQ}) + OR similarity(c.full_name, ${query}) > 0.3 + OR cc.value ILIKE ${ilikePattern} + OR ( + cc.value_e164 IS NOT NULL + AND cc.value_e164 ILIKE ${'%' + phoneQ + '%'} + ) + ) + ORDER BY c.id, rank DESC + ) sub + ORDER BY rank DESC, full_name + LIMIT ${limit} + `); + + return Array.from(rows).map((r) => ({ + id: r.id, + fullName: r.full_name, + matchedContact: r.matched_value ?? null, + matchedContactChannel: r.matched_channel ?? null, + archivedAt: r.archived_at ? r.archived_at.toISOString() : null, + })); +} + +async function searchResidentialClients( + portId: string, + query: string, + limit: number, +): Promise { + const tsQ = buildPrefixTsquery(query) ?? NEVER_TSQUERY; + const phoneQ = normalizePhoneQuery(query) ?? NEVER_PHONE; + const ilikePattern = `%${query}%`; + + const rows = await db.execute<{ + id: string; + full_name: string; + email: string | null; + phone: string | null; + status: string; + archived_at: Date | null; + }>(sql` + SELECT id, full_name, email, phone, status, archived_at + FROM residential_clients + WHERE port_id = ${portId} + AND archived_at IS NULL + AND ( + full_name ILIKE ${ilikePattern} + OR email ILIKE ${ilikePattern} + OR phone ILIKE ${ilikePattern} + OR ( + phone_e164 IS NOT NULL + AND phone_e164 ILIKE ${'%' + phoneQ + '%'} + ) + OR place_of_residence ILIKE ${ilikePattern} + OR to_tsvector('simple', coalesce(full_name, '')) + @@ to_tsquery('simple', ${tsQ}) + OR similarity(full_name, ${query}) > 0.3 + ) + ORDER BY + CASE + WHEN full_name ILIKE ${query + '%'} THEN 1 + WHEN full_name ILIKE ${ilikePattern} THEN 2 + WHEN email ILIKE ${query + '%'} THEN 3 + ELSE 4 + END, + full_name + LIMIT ${limit} + `); + + return Array.from(rows).map((r) => ({ + id: r.id, + fullName: r.full_name, + email: r.email ?? null, + phone: r.phone ?? null, + status: r.status, + archivedAt: r.archived_at ? r.archived_at.toISOString() : null, + })); +} + +async function searchYachts(portId: string, query: string, limit: number): Promise { + const tsQ = buildPrefixTsquery(query) ?? NEVER_TSQUERY; + const ilikePattern = `%${query}%`; + + const rows = await db.execute<{ + id: string; + name: string; + hull_number: string | null; + registration: string | null; + archived_at: Date | null; + }>(sql` + SELECT id, name, hull_number, registration, archived_at + FROM yachts + WHERE port_id = ${portId} + AND archived_at IS NULL + AND ( + name ILIKE ${ilikePattern} + OR hull_number ILIKE ${ilikePattern} + OR registration ILIKE ${ilikePattern} + OR flag ILIKE ${ilikePattern} + OR builder ILIKE ${ilikePattern} + OR to_tsvector('simple', coalesce(name, '') || ' ' || coalesce(builder, '')) + @@ to_tsquery('simple', ${tsQ}) + OR similarity(name, ${query}) > 0.3 + ) + ORDER BY + CASE + WHEN name ILIKE ${query + '%'} THEN 1 + WHEN name ILIKE ${ilikePattern} THEN 2 + WHEN hull_number ILIKE ${query + '%'} THEN 3 + ELSE 4 + END, + name + LIMIT ${limit} + `); + + return Array.from(rows).map((r) => ({ + id: r.id, + name: r.name, + hullNumber: r.hull_number ?? null, + registration: r.registration ?? null, + archivedAt: r.archived_at ? r.archived_at.toISOString() : null, + })); +} + +async function searchCompanies( + portId: string, + query: string, + limit: number, +): Promise { + const ilikePattern = `%${query}%`; + + const rows = await db.execute<{ + id: string; + name: string; + legal_name: string | null; + tax_id: string | null; + matched_field: CompanyResult['matchedField']; + archived_at: Date | null; + }>(sql` + SELECT + id, + name, + legal_name, + tax_id, + CASE + WHEN name ILIKE ${ilikePattern} THEN 'name' + WHEN legal_name ILIKE ${ilikePattern} THEN 'legalName' + WHEN tax_id ILIKE ${ilikePattern} THEN 'taxId' + WHEN registration_number ILIKE ${ilikePattern} THEN 'registrationNumber' + WHEN billing_email ILIKE ${ilikePattern} THEN 'billingEmail' + END AS matched_field, + archived_at + FROM companies + WHERE port_id = ${portId} + AND archived_at IS NULL + AND ( + name ILIKE ${ilikePattern} + OR legal_name ILIKE ${ilikePattern} + OR tax_id ILIKE ${ilikePattern} + OR registration_number ILIKE ${ilikePattern} + OR billing_email ILIKE ${ilikePattern} + ) + ORDER BY + CASE + WHEN name ILIKE ${query + '%'} THEN 1 + WHEN name ILIKE ${ilikePattern} THEN 2 + ELSE 3 + END, + name + LIMIT ${limit} + `); + + return Array.from(rows).map((r) => ({ + id: r.id, + name: r.name, + legalName: r.legal_name ?? null, + taxId: r.tax_id ?? null, + matchedField: r.matched_field ?? null, + archivedAt: r.archived_at ? r.archived_at.toISOString() : null, + })); +} + +async function searchInterests( + portId: string, + query: string, + limit: number, +): Promise { + const ilikePattern = `%${query}%`; + + // Federate: an interest matches if the client name OR the primary + // berth's mooring number OR the linked yacht's name matches the query. + // This is the relational expansion the user explicitly asked for — + // type "A12" and the linked interests show up alongside the berth. + // Two-step query: DISTINCT ON in the inner subquery dedupes the row + // explosion from the LEFT JOIN to interest_berths (an interest with + // 3 non-primary berths would otherwise produce 3 rows). The outer + // SELECT then applies the human-friendly ordering — open-before-closed, + // then by pipeline stage. Done as a wrapping subquery because Postgres + // requires DISTINCT-ON's ORDER BY to lead with the DISTINCT key, but + // we want the *final* sort to be by outcome. + const rows = await db.execute<{ + id: string; + full_name: string; + mooring_number: string | null; + pipeline_stage: string; + outcome: string | null; + }>(sql` + SELECT id, full_name, mooring_number, pipeline_stage, outcome + FROM ( + SELECT DISTINCT ON (i.id) i.id, c.full_name, b.mooring_number, - i.pipeline_stage + i.pipeline_stage, + i.outcome FROM interests i JOIN clients c ON i.client_id = c.id - LEFT JOIN interest_berths ib - ON ib.interest_id = i.id AND ib.is_primary = true + LEFT JOIN interest_berths ib ON ib.interest_id = i.id AND ib.is_primary = true LEFT JOIN berths b ON ib.berth_id = b.id + LEFT JOIN yachts y ON i.yacht_id = y.id WHERE i.port_id = ${portId} AND i.archived_at IS NULL AND ( - c.full_name ILIKE ${'%' + query + '%'} - OR b.mooring_number ILIKE ${'%' + query + '%'} + c.full_name ILIKE ${ilikePattern} + OR b.mooring_number ILIKE ${ilikePattern} + OR y.name ILIKE ${ilikePattern} + OR y.hull_number ILIKE ${ilikePattern} + OR EXISTS ( + SELECT 1 FROM interest_berths ib2 + JOIN berths b2 ON ib2.berth_id = b2.id + WHERE ib2.interest_id = i.id + AND b2.mooring_number ILIKE ${ilikePattern} + ) ) - LIMIT 10 - `), + ORDER BY i.id + ) deduped + ORDER BY + CASE WHEN outcome IS NULL THEN 0 ELSE 1 END, + pipeline_stage, + full_name + LIMIT ${limit} + `); - // 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 - `), + return Array.from(rows).map((r) => ({ + id: r.id, + clientName: r.full_name, + berthMooringNumber: r.mooring_number ?? null, + pipelineStage: r.pipeline_stage, + outcome: r.outcome ?? null, + })); +} - // 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 +async function searchResidentialInterests( + portId: string, + query: string, + limit: number, +): Promise { + const ilikePattern = `%${query}%`; + + const rows = await db.execute<{ + id: string; + full_name: string; + pipeline_stage: string; + }>(sql` + SELECT + ri.id, + rc.full_name, + ri.pipeline_stage + FROM residential_interests ri + JOIN residential_clients rc ON ri.residential_client_id = rc.id + WHERE ri.port_id = ${portId} + AND ri.archived_at IS NULL + AND ( + rc.full_name ILIKE ${ilikePattern} + OR rc.email ILIKE ${ilikePattern} + OR rc.place_of_residence ILIKE ${ilikePattern} + ) + ORDER BY rc.full_name + LIMIT ${limit} + `); + + return Array.from(rows).map((r) => ({ + id: r.id, + clientName: r.full_name, + pipelineStage: r.pipeline_stage, + })); +} + +async function searchBerths(portId: string, query: string, limit: number): Promise { + // Trigram (`%`) is the canonical mooring-number search — it tolerates + // a hyphen or wrong leading-zero. Fallback to ILIKE for `area`. + const ilikePattern = `%${query}%`; + + const rows = 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 ( + b.mooring_number ILIKE ${ilikePattern} + OR b.mooring_number % ${query} + OR b.area ILIKE ${ilikePattern} + ) + ORDER BY + CASE + WHEN b.mooring_number ILIKE ${query + '%'} THEN 1 + WHEN b.mooring_number ILIKE ${ilikePattern} THEN 2 + ELSE 3 + END, + similarity(b.mooring_number, ${query}) DESC, + b.mooring_number + LIMIT ${limit} + `); + + return Array.from(rows).map((r) => ({ + id: r.id, + mooringNumber: r.mooring_number, + area: r.area ?? null, + status: r.status, + linkedInterestCount: Number(r.linked_interest_count) || 0, + })); +} + +async function searchInvoices( + portId: string, + query: string, + limit: number, +): Promise { + const ilikePattern = `%${query}%`; + + const rows = await db.execute<{ + id: string; + invoice_number: string; + client_name: string; + status: string; + payment_status: string | null; + total: string | null; + currency: string; + }>(sql` + SELECT id, invoice_number, client_name, status, payment_status, total, currency + FROM invoices + WHERE port_id = ${portId} + AND ( + invoice_number ILIKE ${ilikePattern} + OR client_name ILIKE ${ilikePattern} + OR billing_email ILIKE ${ilikePattern} + ) + ORDER BY + CASE + WHEN invoice_number ILIKE ${query + '%'} THEN 1 + WHEN invoice_number ILIKE ${ilikePattern} THEN 2 + WHEN client_name ILIKE ${query + '%'} THEN 3 + ELSE 4 + END, + created_at DESC + LIMIT ${limit} + `); + + return Array.from(rows).map((r) => ({ + id: r.id, + invoiceNumber: r.invoice_number, + clientName: r.client_name, + status: r.status, + paymentStatus: r.payment_status ?? null, + totalAmount: r.total ?? null, + currency: r.currency, + })); +} + +async function searchExpenses( + portId: string, + query: string, + limit: number, +): Promise { + const ilikePattern = `%${query}%`; + + const rows = await db.execute<{ + id: string; + description: string | null; + establishment_name: string | null; + trip_label: string | null; + amount: string; + currency: string; + payment_status: string | null; + }>(sql` + SELECT id, description, establishment_name, trip_label, amount, currency, payment_status + FROM expenses + WHERE port_id = ${portId} + AND ( + description ILIKE ${ilikePattern} + OR establishment_name ILIKE ${ilikePattern} + OR trip_label ILIKE ${ilikePattern} + OR payment_reference ILIKE ${ilikePattern} + ) + ORDER BY + CASE + WHEN description ILIKE ${query + '%'} THEN 1 + WHEN establishment_name ILIKE ${query + '%'} THEN 2 + ELSE 3 + END, + expense_date DESC + LIMIT ${limit} + `); + + return Array.from(rows).map((r) => ({ + id: r.id, + description: r.description ?? null, + vendor: r.establishment_name ?? null, + tripLabel: r.trip_label ?? null, + amount: r.amount, + currency: r.currency, + paymentStatus: r.payment_status ?? null, + })); +} + +async function searchDocuments( + portId: string, + query: string, + limit: number, +): Promise { + const ilikePattern = `%${query}%`; + + const rows = await db.execute<{ + id: string; + title: string; + document_type: string; + status: string; + matched_signer_name: string | null; + }>(sql` + SELECT DISTINCT ON (d.id) + d.id, + d.title, + d.document_type, + d.status, + ds.signer_name AS matched_signer_name + FROM documents d + LEFT JOIN document_signers ds ON ds.document_id = d.id + WHERE d.port_id = ${portId} + AND ( + d.title ILIKE ${ilikePattern} + OR ds.signer_name ILIKE ${ilikePattern} + OR ds.signer_email ILIKE ${ilikePattern} + ) + ORDER BY d.id, d.created_at DESC + LIMIT ${limit} + `); + + return Array.from(rows).map((r) => ({ + id: r.id, + title: r.title, + documentType: r.document_type, + status: r.status, + matchedSignerName: r.matched_signer_name ?? null, + })); +} + +async function searchFiles(portId: string, query: string, limit: number): Promise { + const ilikePattern = `%${query}%`; + + const rows = await db.execute<{ + id: string; + filename: string; + original_name: string; + category: string | null; + client_name: string | null; + yacht_name: string | null; + company_name: string | null; + }>(sql` + SELECT + f.id, + f.filename, + f.original_name, + f.category, + c.full_name AS client_name, + y.name AS yacht_name, + co.name AS company_name + FROM files f + LEFT JOIN clients c ON f.client_id = c.id + LEFT JOIN yachts y ON f.yacht_id = y.id + LEFT JOIN companies co ON f.company_id = co.id + WHERE f.port_id = ${portId} + AND ( + f.filename ILIKE ${ilikePattern} + OR f.original_name ILIKE ${ilikePattern} + ) + ORDER BY f.created_at DESC + LIMIT ${limit} + `); + + return Array.from(rows).map((r) => { + let ownerLabel: string | null = null; + if (r.client_name) ownerLabel = `Client: ${r.client_name}`; + else if (r.yacht_name) ownerLabel = `Yacht: ${r.yacht_name}`; + else if (r.company_name) ownerLabel = `Company: ${r.company_name}`; + + return { + id: r.id, + filename: r.original_name || r.filename, + category: r.category ?? null, + ownerLabel, + }; + }); +} + +async function searchReminders( + portId: string, + query: string, + limit: number, +): Promise { + const ilikePattern = `%${query}%`; + + const rows = await db.execute<{ + id: string; + title: string; + due_at: Date; + priority: string; + status: string; + }>(sql` + SELECT id, title, due_at, priority, status + FROM reminders + WHERE port_id = ${portId} + AND status NOT IN ('dismissed', 'completed') + AND ( + title ILIKE ${ilikePattern} + OR note ILIKE ${ilikePattern} + ) + ORDER BY due_at ASC + LIMIT ${limit} + `); + + return Array.from(rows).map((r) => ({ + id: r.id, + title: r.title, + dueAt: r.due_at.toISOString(), + priority: r.priority, + status: r.status, + })); +} + +async function searchBrochures( + portId: string, + query: string, + limit: number, +): Promise { + const ilikePattern = `%${query}%`; + + const rows = await db.execute<{ + id: string; + label: string; + is_default: boolean; + archived_at: Date | null; + }>(sql` + SELECT id, label, is_default, archived_at + FROM brochures + WHERE port_id = ${portId} + AND archived_at IS NULL + AND (label ILIKE ${ilikePattern} OR description ILIKE ${ilikePattern}) + ORDER BY is_default DESC, label + LIMIT ${limit} + `); + + return Array.from(rows).map((r) => ({ + id: r.id, + label: r.label, + isDefault: r.is_default, + archivedAt: r.archived_at ? r.archived_at.toISOString() : null, + })); +} + +async function searchTags(portId: string, query: string, limit: number): Promise { + const ilikePattern = `%${query}%`; + + // Tags are meta-rows: clicking one navigates to a filtered list. + // Show the total count of tagged entities so the user can gauge + // whether the tag is busy or basically unused. + const rows = await db.execute<{ + id: string; + name: string; + color: string; + total_count: string; + }>(sql` + SELECT + t.id, + t.name, + t.color, + ( + COALESCE((SELECT COUNT(*) FROM client_tags WHERE tag_id = t.id), 0) + + COALESCE((SELECT COUNT(*) FROM interest_tags WHERE tag_id = t.id), 0) + + COALESCE((SELECT COUNT(*) FROM yacht_tags WHERE tag_id = t.id), 0) + + COALESCE((SELECT COUNT(*) FROM company_tags WHERE tag_id = t.id), 0) + )::text AS total_count + FROM tags t + WHERE t.port_id = ${portId} + AND t.name ILIKE ${ilikePattern} + ORDER BY + CASE WHEN t.name ILIKE ${query + '%'} THEN 1 ELSE 2 END, + t.name + LIMIT ${limit} + `); + + return Array.from(rows).map((r) => ({ + id: r.id, + name: r.name, + color: r.color, + totalCount: Number(r.total_count) || 0, + })); +} + +async function searchNotes(portId: string, query: string, limit: number): Promise { + const ilikePattern = `%${query}%`; + + // UNION across the four note tables — keeps the result shape uniform + // and lets Postgres pick its own join plan per branch. Each branch + // resolves its parent label inline: + // client → client.full_name + // interest → primary berth's mooring (falls back to "Interest") + // yacht → yacht.name + // company → company.name + // + // Snippet is hard-trimmed at 140 chars so the dropdown row stays + // single-line; the full content is one click away on the parent + // entity's Notes tab. + const rows = await db.execute<{ + id: string; + snippet: string; + source: 'client' | 'interest' | 'yacht' | 'company'; + source_id: string; + source_label: string | null; + created_at: Date; + }>(sql` + SELECT id, snippet, source, source_id, source_label, created_at + FROM ( + SELECT + cn.id, + SUBSTRING(cn.content FROM 1 FOR 140) AS snippet, + 'client'::text AS source, + cn.client_id AS source_id, + c.full_name AS source_label, + cn.created_at + FROM client_notes cn + INNER JOIN clients c ON c.id = cn.client_id + WHERE c.port_id = ${portId} + AND cn.content ILIKE ${ilikePattern} + + UNION ALL + + SELECT + i_n.id, + SUBSTRING(i_n.content FROM 1 FOR 140) AS snippet, + 'interest'::text AS source, + i_n.interest_id AS source_id, + b.mooring_number AS source_label, + i_n.created_at + FROM interest_notes i_n + INNER JOIN interests i ON i.id = i_n.interest_id + LEFT JOIN interest_berths ib ON ib.interest_id = i.id AND ib.is_primary = true + LEFT JOIN berths b ON b.id = ib.berth_id + WHERE i.port_id = ${portId} + AND i_n.content ILIKE ${ilikePattern} + + UNION ALL + + SELECT + yn.id, + SUBSTRING(yn.content FROM 1 FOR 140) AS snippet, + 'yacht'::text AS source, + yn.yacht_id AS source_id, + y.name AS source_label, + yn.created_at + FROM yacht_notes yn + INNER JOIN yachts y ON y.id = yn.yacht_id + WHERE y.port_id = ${portId} + AND yn.content ILIKE ${ilikePattern} + + UNION ALL + + SELECT + co_n.id, + SUBSTRING(co_n.content FROM 1 FOR 140) AS snippet, + 'company'::text AS source, + co_n.company_id AS source_id, + co.name AS source_label, + co_n.created_at + FROM company_notes co_n + INNER JOIN companies co ON co.id = co_n.company_id + WHERE co.port_id = ${portId} + AND co_n.content ILIKE ${ilikePattern} + ) t + ORDER BY created_at DESC + LIMIT ${limit} + `); + + return Array.from(rows).map((r) => ({ + id: r.id, + snippet: r.snippet ?? '', + source: r.source, + sourceId: r.source_id, + sourceLabel: r.source_label ?? labelForSource(r.source), + createdAt: r.created_at, + })); +} + +function labelForSource(source: 'client' | 'interest' | 'yacht' | 'company'): string { + switch (source) { + case 'client': + return 'Client'; + case 'interest': + return 'Interest'; + case 'yacht': + return 'Yacht'; + case 'company': + return 'Company'; + } +} + +// ─── Cross-port (super admin) ──────────────────────────────────────────────── + +async function searchOtherPorts( + excludePortId: string, + query: string, + limit: number, +): Promise { + const ilikePattern = `%${query}%`; + const tsQ = buildPrefixTsquery(query); + + // One UNION query touching the high-signal entities only — clients, + // yachts, companies, interests, berths. Capped tight (limit applies to + // the total, not per-bucket) so super-admin cross-port noise stays out + // of the way. + const rows = await db.execute<{ + port_id: string; + port_slug: string; + port_name: string; + type: OtherPortResult['type']; + id: string; + label: string; + sub: string | null; + }>(sql` + WITH port_lookup AS ( + SELECT id, slug, name FROM ports WHERE id != ${excludePortId} + ) + SELECT * FROM ( + SELECT p.id AS port_id, p.slug AS port_slug, p.name AS port_name, + 'client'::text AS type, c.id, c.full_name AS label, NULL::text AS sub + FROM clients c + JOIN port_lookup p ON c.port_id = p.id + WHERE c.archived_at IS NULL AND ( - name ILIKE ${'%' + query + '%'} - OR legal_name ILIKE ${'%' + query + '%'} - OR tax_id ILIKE ${'%' + query + '%'} + c.full_name ILIKE ${ilikePattern} + OR ( + ${tsQ}::text IS NOT NULL + AND to_tsvector('simple', coalesce(c.full_name, '')) + @@ to_tsquery('simple', ${tsQ}) + ) ) - ORDER BY - CASE - WHEN name ILIKE ${query + '%'} THEN 1 - WHEN name ILIKE ${'%' + query + '%'} THEN 2 - ELSE 3 - END, - name - LIMIT 10 - `), + LIMIT ${limit} + ) clients_section + UNION ALL + SELECT * FROM ( + SELECT p.id AS port_id, p.slug AS port_slug, p.name AS port_name, + 'yacht'::text AS type, y.id, y.name AS label, + y.hull_number AS sub + FROM yachts y + JOIN port_lookup p ON y.port_id = p.id + WHERE y.archived_at IS NULL + AND (y.name ILIKE ${ilikePattern} OR y.hull_number ILIKE ${ilikePattern}) + LIMIT ${limit} + ) yachts_section + UNION ALL + SELECT * FROM ( + SELECT p.id AS port_id, p.slug AS port_slug, p.name AS port_name, + 'company'::text AS type, co.id, co.name AS label, co.tax_id AS sub + FROM companies co + JOIN port_lookup p ON co.port_id = p.id + WHERE co.archived_at IS NULL + AND (co.name ILIKE ${ilikePattern} OR co.tax_id ILIKE ${ilikePattern}) + LIMIT ${limit} + ) companies_section + UNION ALL + SELECT * FROM ( + SELECT p.id AS port_id, p.slug AS port_slug, p.name AS port_name, + 'berth'::text AS type, b.id, b.mooring_number AS label, b.area AS sub + FROM berths b + JOIN port_lookup p ON b.port_id = p.id + WHERE b.mooring_number ILIKE ${ilikePattern} OR b.mooring_number % ${query} + LIMIT ${limit} + ) berths_section + LIMIT ${limit} + `); + + return Array.from(rows).map((r) => ({ + portId: r.port_id, + portSlug: r.port_slug, + portName: r.port_name, + type: r.type, + id: r.id, + label: r.label, + sub: r.sub ?? null, + })); +} + +// ─── Public entrypoint ────────────────────────────────────────────────────── + +/** + * Returns a populated `SearchResults` for the given port + query. All + * unrequested or permission-denied buckets come back as empty arrays so + * the UI can render uniformly. + * + * Per-bucket queries are run in parallel via `Promise.all` — total + * latency is bounded by the single slowest bucket. + */ +export async function search( + portId: string, + query: string, + opts: SearchOptions, +): Promise { + const limit = opts.limit ?? DEFAULT_LIMIT; + const empty = emptyResults(); + 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); + + const wantEmail = looksLikeEmail(query); + const wantPhone = normalizePhoneQuery(query) !== null; + // We always run the name-bearing buckets even for email/phone-shaped + // queries — a client named "test+marketing" is rare but real. + + const [ + clients, + residentialClients, + yachts, + companies, + interests, + residentialInterests, + berths, + invoices, + expenses, + documents, + files, + reminders, + brochures, + tags, + notes, + otherPorts, + ] = await Promise.all([ + can(opts, 'clients.view') ? searchClients(portId, query, limit) : Promise.resolve([]), + can(opts, 'residential_clients.view') + ? searchResidentialClients(portId, query, limit) + : Promise.resolve([]), + can(opts, 'yachts.view') ? searchYachts(portId, query, limit) : Promise.resolve([]), + can(opts, 'companies.view') ? searchCompanies(portId, query, limit) : Promise.resolve([]), + can(opts, 'interests.view') ? searchInterests(portId, query, limit) : Promise.resolve([]), + can(opts, 'residential_interests.view') || can(opts, 'residential_clients.view') + ? searchResidentialInterests(portId, query, limit) + : Promise.resolve([]), + can(opts, 'berths.view') ? searchBerths(portId, query, limit) : Promise.resolve([]), + can(opts, 'invoices.view') ? searchInvoices(portId, query, limit) : Promise.resolve([]), + can(opts, 'expenses.view') ? searchExpenses(portId, query, limit) : Promise.resolve([]), + can(opts, 'documents.view') ? searchDocuments(portId, query, limit) : Promise.resolve([]), + can(opts, 'files.view') || can(opts, 'documents.view') + ? searchFiles(portId, query, limit) + : Promise.resolve([]), + can(opts, 'reminders.view') || can(opts, 'clients.view') + ? searchReminders(portId, query, limit) + : Promise.resolve([]), + can(opts, 'admin.manage_settings') + ? searchBrochures(portId, query, limit) + : Promise.resolve([]), + searchTags(portId, query, limit), + // Notes search runs whenever the user can read any note-bearing + // entity. Reads are gated by the JOINs in searchNotes itself — + // a note's row only surfaces when its parent entity is in this + // port. The dropdown UI sticks notes at the bottom (per the + // user's "low-noise" preference). + can(opts, 'clients.view') || + can(opts, 'interests.view') || + can(opts, 'yachts.view') || + can(opts, 'companies.view') + ? searchNotes(portId, query, limit) + : Promise.resolve([]), + opts.includeOtherPorts && opts.isSuperAdmin + ? searchOtherPorts(portId, query, limit) + : Promise.resolve([]), ]); + const navigation = await Promise.resolve( + (await import('@/lib/services/search-nav-catalog')).searchNavCatalog(query, { + isSuperAdmin: opts.isSuperAdmin, + permissions: opts.permissions, + limit, + }), + ).then((entries) => + entries.map((e) => ({ + id: e.href, + href: e.href, + label: e.label, + category: e.category, + })), + ); + + // Suppress unused-var warning for the email/phone hint — we keep the + // computation in case future tuning wants to reorder buckets when the + // query is clearly an identifier. + void wantEmail; + void wantPhone; + + const apply = (rows: T[]) => + applyAffinity(rows, opts.recentlyTouchedIds); + + const result: SearchResults = { + clients: apply(clients), + residentialClients: apply(residentialClients), + yachts: apply(yachts), + companies: apply(companies), + interests: apply(interests), + residentialInterests: apply(residentialInterests), + berths: apply(berths), + invoices: apply(invoices), + expenses: apply(expenses), + documents: apply(documents), + files: apply(files), + reminders: apply(reminders), + brochures: apply(brochures), + tags, + navigation, + notes, + totals: { + clients: clients.length, + residentialClients: residentialClients.length, + yachts: yachts.length, + companies: companies.length, + interests: interests.length, + residentialInterests: residentialInterests.length, + berths: berths.length, + invoices: invoices.length, + expenses: expenses.length, + documents: documents.length, + files: files.length, + reminders: reminders.length, + brochures: brochures.length, + tags: tags.length, + navigation: navigation.length, + notes: notes.length, + }, + otherPorts: otherPorts.length > 0 ? otherPorts : undefined, + }; + + return result; +} + +async function runSingleBucket( + portId: string, + query: string, + limit: number, + opts: SearchOptions, +): Promise { + const empty = emptyResults(); + switch (opts.type) { + case 'clients': + if (!can(opts, 'clients.view')) return empty; + empty.clients = applyAffinity( + await searchClients(portId, query, limit), + opts.recentlyTouchedIds, + ); + empty.totals.clients = empty.clients.length; + return empty; + case 'residentialClients': + if (!can(opts, 'residential_clients.view')) return empty; + empty.residentialClients = applyAffinity( + await searchResidentialClients(portId, query, limit), + opts.recentlyTouchedIds, + ); + empty.totals.residentialClients = empty.residentialClients.length; + return empty; + case 'yachts': + if (!can(opts, 'yachts.view')) return empty; + empty.yachts = applyAffinity( + await searchYachts(portId, query, limit), + opts.recentlyTouchedIds, + ); + empty.totals.yachts = empty.yachts.length; + return empty; + case 'companies': + if (!can(opts, 'companies.view')) return empty; + empty.companies = applyAffinity( + await searchCompanies(portId, query, limit), + opts.recentlyTouchedIds, + ); + empty.totals.companies = empty.companies.length; + return empty; + case 'interests': + if (!can(opts, 'interests.view')) return empty; + empty.interests = applyAffinity( + await searchInterests(portId, query, limit), + opts.recentlyTouchedIds, + ); + empty.totals.interests = empty.interests.length; + return empty; + case 'residentialInterests': + if (!can(opts, 'residential_clients.view')) return empty; + empty.residentialInterests = applyAffinity( + await searchResidentialInterests(portId, query, limit), + opts.recentlyTouchedIds, + ); + empty.totals.residentialInterests = empty.residentialInterests.length; + return empty; + case 'berths': + if (!can(opts, 'berths.view')) return empty; + empty.berths = applyAffinity( + await searchBerths(portId, query, limit), + opts.recentlyTouchedIds, + ); + empty.totals.berths = empty.berths.length; + return empty; + case 'invoices': + if (!can(opts, 'invoices.view')) return empty; + empty.invoices = applyAffinity( + await searchInvoices(portId, query, limit), + opts.recentlyTouchedIds, + ); + empty.totals.invoices = empty.invoices.length; + return empty; + case 'expenses': + if (!can(opts, 'expenses.view')) return empty; + empty.expenses = applyAffinity( + await searchExpenses(portId, query, limit), + opts.recentlyTouchedIds, + ); + empty.totals.expenses = empty.expenses.length; + return empty; + case 'documents': + if (!can(opts, 'documents.view')) return empty; + empty.documents = applyAffinity( + await searchDocuments(portId, query, limit), + opts.recentlyTouchedIds, + ); + empty.totals.documents = empty.documents.length; + return empty; + case 'files': + if (!can(opts, 'files.view') && !can(opts, 'documents.view')) return empty; + empty.files = applyAffinity(await searchFiles(portId, query, limit), opts.recentlyTouchedIds); + empty.totals.files = empty.files.length; + return empty; + case 'reminders': + empty.reminders = applyAffinity( + await searchReminders(portId, query, limit), + opts.recentlyTouchedIds, + ); + empty.totals.reminders = empty.reminders.length; + return empty; + case 'brochures': + if (!can(opts, 'admin.manage_settings')) return empty; + empty.brochures = await searchBrochures(portId, query, limit); + empty.totals.brochures = empty.brochures.length; + return empty; + case 'tags': + empty.tags = await searchTags(portId, query, limit); + empty.totals.tags = empty.tags.length; + return empty; + case 'navigation': { + const { searchNavCatalog } = await import('@/lib/services/search-nav-catalog'); + empty.navigation = searchNavCatalog(query, { + isSuperAdmin: opts.isSuperAdmin, + permissions: opts.permissions, + limit, + }).map((e) => ({ id: e.href, href: e.href, label: e.label, category: e.category })); + empty.totals.navigation = empty.navigation.length; + return empty; + } + default: + return empty; + } +} + +function emptyResults(): SearchResults { return { - clients: Array.from(clientRows).map((r) => ({ - id: r.id, - fullName: r.full_name, - })), - berths: Array.from(berthRows).map((r) => ({ - id: r.id, - mooringNumber: r.mooring_number, - area: r.area ?? null, - status: r.status, - })), - interests: Array.from(interestRows).map((r) => ({ - id: r.id, - clientName: r.full_name, - berthMooringNumber: r.mooring_number ?? null, - 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, - })), + clients: [], + residentialClients: [], + yachts: [], + companies: [], + interests: [], + residentialInterests: [], + berths: [], + invoices: [], + expenses: [], + documents: [], + files: [], + reminders: [], + brochures: [], + tags: [], + navigation: [], + notes: [], + totals: { + clients: 0, + residentialClients: 0, + yachts: 0, + companies: 0, + interests: 0, + residentialInterests: 0, + berths: 0, + invoices: 0, + expenses: 0, + documents: 0, + files: 0, + reminders: 0, + brochures: 0, + tags: 0, + navigation: 0, + notes: 0, + }, }; } -// ─── Recent Searches ────────────────────────────────────────────────────────── +// ─── Recent search-term history ────────────────────────────────────────────── -const RECENT_SEARCH_TTL = 2592000; // 30 days in seconds +const RECENT_SEARCH_TTL = 2592000; // 30 days const RECENT_SEARCH_MAX = 10; function recentSearchKey(userId: string, portId: string): string { return `recent-search:${userId}:${portId}`; } -/** - * Fire-and-forget - saves a search term to the user's recent searches sorted set. - */ +/** Fire-and-forget — saves a search term to the user's recent searches. */ export function saveRecentSearch(userId: string, portId: string, searchTerm: string): void { const key = recentSearchKey(userId, portId); redis @@ -207,13 +1574,11 @@ export function saveRecentSearch(userId: string, portId: string, searchTerm: str .then(() => redis.zremrangebyrank(key, 0, -(RECENT_SEARCH_MAX + 1))) .then(() => redis.expire(key, RECENT_SEARCH_TTL)) .catch(() => { - // Intentionally swallowed - recent searches are non-critical + // intentionally swallowed }); } -/** - * Returns the user's most recent searches, newest first. - */ +/** Returns the user's most recent search terms, newest first. */ export async function getRecentSearches(userId: string, portId: string): Promise { const key = recentSearchKey(userId, portId); const items = await redis.zrevrange(key, 0, RECENT_SEARCH_MAX - 1); diff --git a/src/lib/validators/search.ts b/src/lib/validators/search.ts index 9ff9ffd..f93706f 100644 --- a/src/lib/validators/search.ts +++ b/src/lib/validators/search.ts @@ -1,7 +1,56 @@ import { z } from 'zod'; +const BUCKET_TYPES = [ + 'clients', + 'residentialClients', + 'yachts', + 'companies', + 'interests', + 'residentialInterests', + 'berths', + 'invoices', + 'expenses', + 'documents', + 'files', + 'reminders', + 'brochures', + 'tags', + 'navigation', +] as const; + export const searchQuerySchema = z.object({ + // 2-char minimum keeps `to_tsquery('a:*')` from returning every word + // starting with "a" — short queries return overwhelming match sets. q: z.string().min(2).max(200), + /** Restrict the result set to a single bucket. */ + type: z.enum(BUCKET_TYPES).optional(), + /** Per-bucket cap. Defaults to 5 (dropdown). 25 is the typical /search-page value. */ + limit: z.coerce.number().int().min(1).max(50).optional(), + /** Super-admin only — search ports beyond the current one. */ + includeOtherPorts: z + .union([z.literal('true'), z.literal('1'), z.literal('false'), z.literal('0')]) + .transform((v) => v === 'true' || v === '1') + .optional(), }); export type SearchQuery = z.infer; + +const RECENTLY_VIEWED_TYPES = [ + 'client', + 'residential-client', + 'yacht', + 'company', + 'interest', + 'residential-interest', + 'berth', + 'invoice', + 'expense', + 'document', +] as const; + +export const trackViewSchema = z.object({ + type: z.enum(RECENTLY_VIEWED_TYPES), + id: z.string().min(1).max(100), +}); + +export type TrackViewPayload = z.infer; diff --git a/tests/unit/services/search.test.ts b/tests/unit/services/search.test.ts index 0b47ec8..7f441bf 100644 --- a/tests/unit/services/search.test.ts +++ b/tests/unit/services/search.test.ts @@ -1,12 +1,16 @@ import { describe, it, expect } from 'vitest'; import { eq } from 'drizzle-orm'; -import { search } from '@/lib/services/search.service'; +import { search, buildPrefixTsquery, normalizePhoneQuery } 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'; +// Default opts — super admin so every bucket runs without per-resource +// permission gating getting in the way of the assertions. +const ADMIN_OPTS = { permissions: null, isSuperAdmin: true } as const; + // ─── Yachts ────────────────────────────────────────────────────────────────── describe('search.service — yachts', () => { @@ -26,7 +30,7 @@ describe('search.service — yachts', () => { name: 'Wind Dancer', }); - const results = await search(port.id, 'BREEZE'); + const results = await search(port.id, 'BREEZE', ADMIN_OPTS); expect(results.yachts.some((y) => y.name === 'Sea Breeze')).toBe(true); expect(results.yachts.some((y) => y.name === 'Wind Dancer')).toBe(false); }); @@ -42,7 +46,7 @@ describe('search.service — yachts', () => { hullNumber: 'HULL-XYZ-999', }); - const results = await search(port.id, 'hull-xyz'); + const results = await search(port.id, 'hull-xyz', ADMIN_OPTS); expect(results.yachts.some((y) => y.hullNumber === 'HULL-XYZ-999')).toBe(true); }); @@ -57,7 +61,7 @@ describe('search.service — yachts', () => { registration: 'REG-ABC-123', }); - const results = await search(port.id, 'reg-abc'); + const results = await search(port.id, 'reg-abc', ADMIN_OPTS); expect(results.yachts.some((y) => y.registration === 'REG-ABC-123')).toBe(true); }); @@ -72,7 +76,7 @@ describe('search.service — yachts', () => { }); await db.update(yachts).set({ archivedAt: new Date() }).where(eq(yachts.id, yacht.id)); - const results = await search(port.id, 'ghost ship'); + const results = await search(port.id, 'ghost ship', ADMIN_OPTS); expect(results.yachts.some((y) => y.id === yacht.id)).toBe(false); }); @@ -94,7 +98,7 @@ describe('search.service — yachts', () => { name: 'UniqueYachtNameB', }); - const resultsA = await search(portA.id, 'UniqueYachtName'); + const resultsA = await search(portA.id, 'UniqueYachtName', ADMIN_OPTS); expect(resultsA.yachts.some((y) => y.name === 'UniqueYachtNameA')).toBe(true); expect(resultsA.yachts.some((y) => y.name === 'UniqueYachtNameB')).toBe(false); }); @@ -108,7 +112,7 @@ describe('search.service — companies', () => { 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'); + const results = await search(port.id, 'poseidon', ADMIN_OPTS); expect(results.companies.some((c) => c.name === 'Poseidon Maritime Ltd')).toBe(true); expect(results.companies.some((c) => c.name === 'Neptune Holdings')).toBe(false); }); @@ -120,7 +124,7 @@ describe('search.service — companies', () => { overrides: { name: 'AcmeShort', legalName: 'Acme Legal Holdings Inc.' }, }); - const results = await search(port.id, 'Acme Legal'); + const results = await search(port.id, 'Acme Legal', ADMIN_OPTS); expect(results.companies.some((c) => c.legalName === 'Acme Legal Holdings Inc.')).toBe(true); }); @@ -131,7 +135,7 @@ describe('search.service — companies', () => { overrides: { name: 'TaxyCo', taxId: 'VAT-112233445' }, }); - const results = await search(port.id, 'vat-112233'); + const results = await search(port.id, 'vat-112233', ADMIN_OPTS); expect(results.companies.some((c) => c.taxId === 'VAT-112233445')).toBe(true); }); @@ -143,7 +147,7 @@ describe('search.service — companies', () => { }); await db.update(companies).set({ archivedAt: new Date() }).where(eq(companies.id, company.id)); - const results = await search(port.id, 'ArchivedCompanyXyz'); + const results = await search(port.id, 'ArchivedCompanyXyz', ADMIN_OPTS); expect(results.companies.some((c) => c.id === company.id)).toBe(false); }); @@ -153,7 +157,7 @@ describe('search.service — companies', () => { await makeCompany({ portId: portA.id, overrides: { name: 'UniqueCompanyA' } }); await makeCompany({ portId: portB.id, overrides: { name: 'UniqueCompanyB' } }); - const resultsA = await search(portA.id, 'UniqueCompany'); + const resultsA = await search(portA.id, 'UniqueCompany', ADMIN_OPTS); expect(resultsA.companies.some((c) => c.name === 'UniqueCompanyA')).toBe(true); expect(resultsA.companies.some((c) => c.name === 'UniqueCompanyB')).toBe(false); }); @@ -176,21 +180,96 @@ describe('search.service — combined', () => { }); await makeCompany({ portId: port.id, overrides: { name: 'Alpha Holdings' } }); - const results = await search(port.id, 'alpha'); + const results = await search(port.id, 'alpha', ADMIN_OPTS); 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 () => { + it('returns every bucket as an empty array 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: [], - }); + const results = await search(port.id, 'zzz-nothing-matches-zzz', ADMIN_OPTS); + // Shape check (no rows in any bucket) + expect(results.clients).toEqual([]); + expect(results.residentialClients).toEqual([]); + expect(results.yachts).toEqual([]); + expect(results.companies).toEqual([]); + expect(results.interests).toEqual([]); + expect(results.residentialInterests).toEqual([]); + expect(results.berths).toEqual([]); + expect(results.invoices).toEqual([]); + expect(results.expenses).toEqual([]); + expect(results.documents).toEqual([]); + expect(results.files).toEqual([]); + expect(results.reminders).toEqual([]); + expect(results.brochures).toEqual([]); + expect(results.tags).toEqual([]); + expect(results.navigation).toEqual([]); + // Totals are present and zero across the board. + expect(Object.values(results.totals).every((n) => n === 0)).toBe(true); + }); +}); + +// ─── Helper purity tests ───────────────────────────────────────────────────── + +describe('buildPrefixTsquery', () => { + it('builds a tokenized prefix tsquery', () => { + expect(buildPrefixTsquery('joh smi')).toBe('joh:* & smi:*'); + }); + it('strips regex / tsquery meta-characters', () => { + expect(buildPrefixTsquery('a&b!c')).toBe('abc:*'); + }); + it('returns null for empty / whitespace-only input', () => { + expect(buildPrefixTsquery('')).toBeNull(); + expect(buildPrefixTsquery(' ')).toBeNull(); + }); + it('returns null when every token reduces to nothing after sanitization', () => { + expect(buildPrefixTsquery('!@#$ %^&*')).toBeNull(); + }); +}); + +describe('normalizePhoneQuery', () => { + it('strips non-digit / non-plus characters', () => { + expect(normalizePhoneQuery('+44 7700 900 123')).toBe('+447700900123'); + }); + it('preserves leading +', () => { + expect(normalizePhoneQuery('+1 (555) 123-4567')).toBe('+15551234567'); + }); + it('returns null when fewer than 3 digits remain (too short to be unique)', () => { + expect(normalizePhoneQuery('a-b')).toBeNull(); + }); +}); + +// ─── Partial name matching ─────────────────────────────────────────────────── + +describe('search.service — partial name matching', () => { + it('matches "joh smi" against "John Smith" via tokenized prefix tsquery', async () => { + const port = await makePort(); + await makeClient({ portId: port.id, overrides: { fullName: 'John Smith' } }); + await makeClient({ portId: port.id, overrides: { fullName: 'Joanna Smithers' } }); + await makeClient({ portId: port.id, overrides: { fullName: 'Bob Jones' } }); + + const results = await search(port.id, 'joh smi', ADMIN_OPTS); + expect(results.clients.some((c) => c.fullName === 'John Smith')).toBe(true); + expect(results.clients.some((c) => c.fullName === 'Bob Jones')).toBe(false); + }); + + it('matches a single name fragment ("joh" → "John …")', async () => { + const port = await makePort(); + await makeClient({ portId: port.id, overrides: { fullName: 'Johnathan Doe' } }); + + const results = await search(port.id, 'joh', ADMIN_OPTS); + expect(results.clients.some((c) => c.fullName === 'Johnathan Doe')).toBe(true); + }); +}); + +describe('search.service — bucket totals', () => { + it('emits per-bucket totals so the UI can render "show more" links', async () => { + const port = await makePort(); + await makeClient({ portId: port.id, overrides: { fullName: 'TotalsCheck One' } }); + + const results = await search(port.id, 'TotalsCheck', ADMIN_OPTS); + expect(results.totals.clients).toBeGreaterThanOrEqual(1); + expect(typeof results.totals.invoices).toBe('number'); }); });