feat(search): full-platform search overhaul + view tracking + notes bucket

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 20:58:34 +02:00
parent a0e68eb060
commit 267c2b6d1f
14 changed files with 3821 additions and 378 deletions

View File

@@ -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<BucketType, number>;
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<SearchResults>({
queryKey: ['search', debouncedQuery],
queryFn: () =>
apiFetch<SearchResults>(`/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<SearchResults>(`/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<void> {
try {
await apiFetch('/api/v1/search/recently-viewed', {
method: 'POST',
body: { type, id },
});
} catch {
// Tracking is non-critical — never bubble up to the user.
}
}

View File

@@ -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]);
}