Files
pn-new-crm/src/hooks/use-search.ts

261 lines
6.8 KiB
TypeScript
Raw Normal View History

'use client';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { useDebounce } from '@/hooks/use-debounce';
// ─── Types — mirror SearchResults from search.service.ts ─────────────────────
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;
}
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 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,
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 recentSearchQuery = useQuery<{ searches: string[] }>({
queryKey: ['search', 'recent-terms'],
queryFn: ({ signal }) => apiFetch<{ searches: string[] }>('/api/v1/search/recent', { signal }),
staleTime: 60_000,
});
fix(audit): non-Documenso backlog sweep — port-binding, NULLS NOT DISTINCT, custom merge tokens, company docs Wave through the remaining audit-final-deferred items that aren't blocked on the back-burnered Documenso work. Multi-tenant isolation: - Storage proxy ProxyTokenPayload gains optional `p` (port slug) claim; verifier asserts `key.startsWith(${p}/)`. Defense-in-depth against a buggy issuer in some future code path that mixes port scopes — every storage key generated by generateStorageKey() already prefixes the slug. document-sends opts in for 24h emailed download links; other callers continue working unchanged via the optional field. DB schema reconciliation: - Migration 0047 rebuilds system_settings unique index with NULLS NOT DISTINCT (Postgres 15+) so global settings (port_id IS NULL) are uniquely keyed by `key` alone. Surfaced + dedupe'd 65 duplicate (storage_backend, NULL) rows that had accumulated from race-prone delete-then-insert patterns in ocr-config / settings / residential- stages / ai-budget services. All four services converted to true onConflictDoUpdate upserts so the race window is closed. API uniformity: - Response shape standardization: 16 routes converted from `{ success: true }` to 204 No Content. CLAUDE.md documents the convention (`{ data: <T> }` for content, 204 for empty mutations, portal-auth retains `{ success: true }` for the frontend's auth chain). - req.json() → parseBody() migration across 9 admin/CRM routes (custom-fields, expenses/export ×3, currency convert, search/recently-viewed, admin/duplicates, berths/pdf-{upload-url, versions, parse-results}). Uniform 400 error shapes for ZodError-flagged bodies. Custom-fields merge tokens (shipped end-to-end): - merge-fields.ts gains CUSTOM_MERGE_TOKEN_RE + helpers for the `{{custom.<fieldName>}}` shape. - document-templates validator accepts the dynamic shape alongside the static catalog tokens. - document-sends.service mergeCustomFieldValues resolver fetches per-port custom_field_definitions for client/interest/berth contexts and substitutes stored values keyed by `{{custom.fieldName}}`. - custom-fields-manager amber banner updated to reflect that merge tokens now expand (search index + entity-diff remain documented design limitations). /api/v1/files cross-entity filtering: - Validator + listFiles + uploadFile accept companyId AND yachtId alongside clientId. file-upload-zone propagates both. - New CompanyFilesTab component mirrors ClientFilesTab; restored as a visible Documents tab in company-tabs.tsx (was a hidden stub). Inline TODOs: - Reviewed remaining two TODOs (per-user reminder schedule, import worker handlers). Both are placeholders for future feature surfaces, not bugs — per-port digest works for every customer; nothing currently enqueues import jobs (verified). Annotated in BACKLOG. BACKLOG.md updated to reflect what landed and what's still pending (Documenso-related items still bundled with the back-burnered phases). Tests: 1185/1185 vitest, tsc clean.
2026-05-08 02:20:27 +02:00
const recentlyViewedQuery = useQuery<{ data: RecentlyViewedItem[] }>({
queryKey: ['search', 'recently-viewed'],
queryFn: ({ signal }) =>
fix(audit): non-Documenso backlog sweep — port-binding, NULLS NOT DISTINCT, custom merge tokens, company docs Wave through the remaining audit-final-deferred items that aren't blocked on the back-burnered Documenso work. Multi-tenant isolation: - Storage proxy ProxyTokenPayload gains optional `p` (port slug) claim; verifier asserts `key.startsWith(${p}/)`. Defense-in-depth against a buggy issuer in some future code path that mixes port scopes — every storage key generated by generateStorageKey() already prefixes the slug. document-sends opts in for 24h emailed download links; other callers continue working unchanged via the optional field. DB schema reconciliation: - Migration 0047 rebuilds system_settings unique index with NULLS NOT DISTINCT (Postgres 15+) so global settings (port_id IS NULL) are uniquely keyed by `key` alone. Surfaced + dedupe'd 65 duplicate (storage_backend, NULL) rows that had accumulated from race-prone delete-then-insert patterns in ocr-config / settings / residential- stages / ai-budget services. All four services converted to true onConflictDoUpdate upserts so the race window is closed. API uniformity: - Response shape standardization: 16 routes converted from `{ success: true }` to 204 No Content. CLAUDE.md documents the convention (`{ data: <T> }` for content, 204 for empty mutations, portal-auth retains `{ success: true }` for the frontend's auth chain). - req.json() → parseBody() migration across 9 admin/CRM routes (custom-fields, expenses/export ×3, currency convert, search/recently-viewed, admin/duplicates, berths/pdf-{upload-url, versions, parse-results}). Uniform 400 error shapes for ZodError-flagged bodies. Custom-fields merge tokens (shipped end-to-end): - merge-fields.ts gains CUSTOM_MERGE_TOKEN_RE + helpers for the `{{custom.<fieldName>}}` shape. - document-templates validator accepts the dynamic shape alongside the static catalog tokens. - document-sends.service mergeCustomFieldValues resolver fetches per-port custom_field_definitions for client/interest/berth contexts and substitutes stored values keyed by `{{custom.fieldName}}`. - custom-fields-manager amber banner updated to reflect that merge tokens now expand (search index + entity-diff remain documented design limitations). /api/v1/files cross-entity filtering: - Validator + listFiles + uploadFile accept companyId AND yachtId alongside clientId. file-upload-zone propagates both. - New CompanyFilesTab component mirrors ClientFilesTab; restored as a visible Documents tab in company-tabs.tsx (was a hidden stub). Inline TODOs: - Reviewed remaining two TODOs (per-user reminder schedule, import worker handlers). Both are placeholders for future feature surfaces, not bugs — per-port digest works for every customer; nothing currently enqueues import jobs (verified). Annotated in BACKLOG. BACKLOG.md updated to reflect what landed and what's still pending (Documenso-related items still bundled with the back-burnered phases). Tests: 1185/1185 vitest, tsc clean.
2026-05-08 02:20:27 +02:00
apiFetch<{ data: RecentlyViewedItem[] }>('/api/v1/search/recently-viewed', { signal }),
staleTime: 30_000,
});
return {
results: searchQuery.data,
isLoading: searchQuery.isLoading,
isFetching: searchQuery.isFetching,
enabled,
recentSearches: recentSearchQuery.data?.searches ?? [],
fix(audit): non-Documenso backlog sweep — port-binding, NULLS NOT DISTINCT, custom merge tokens, company docs Wave through the remaining audit-final-deferred items that aren't blocked on the back-burnered Documenso work. Multi-tenant isolation: - Storage proxy ProxyTokenPayload gains optional `p` (port slug) claim; verifier asserts `key.startsWith(${p}/)`. Defense-in-depth against a buggy issuer in some future code path that mixes port scopes — every storage key generated by generateStorageKey() already prefixes the slug. document-sends opts in for 24h emailed download links; other callers continue working unchanged via the optional field. DB schema reconciliation: - Migration 0047 rebuilds system_settings unique index with NULLS NOT DISTINCT (Postgres 15+) so global settings (port_id IS NULL) are uniquely keyed by `key` alone. Surfaced + dedupe'd 65 duplicate (storage_backend, NULL) rows that had accumulated from race-prone delete-then-insert patterns in ocr-config / settings / residential- stages / ai-budget services. All four services converted to true onConflictDoUpdate upserts so the race window is closed. API uniformity: - Response shape standardization: 16 routes converted from `{ success: true }` to 204 No Content. CLAUDE.md documents the convention (`{ data: <T> }` for content, 204 for empty mutations, portal-auth retains `{ success: true }` for the frontend's auth chain). - req.json() → parseBody() migration across 9 admin/CRM routes (custom-fields, expenses/export ×3, currency convert, search/recently-viewed, admin/duplicates, berths/pdf-{upload-url, versions, parse-results}). Uniform 400 error shapes for ZodError-flagged bodies. Custom-fields merge tokens (shipped end-to-end): - merge-fields.ts gains CUSTOM_MERGE_TOKEN_RE + helpers for the `{{custom.<fieldName>}}` shape. - document-templates validator accepts the dynamic shape alongside the static catalog tokens. - document-sends.service mergeCustomFieldValues resolver fetches per-port custom_field_definitions for client/interest/berth contexts and substitutes stored values keyed by `{{custom.fieldName}}`. - custom-fields-manager amber banner updated to reflect that merge tokens now expand (search index + entity-diff remain documented design limitations). /api/v1/files cross-entity filtering: - Validator + listFiles + uploadFile accept companyId AND yachtId alongside clientId. file-upload-zone propagates both. - New CompanyFilesTab component mirrors ClientFilesTab; restored as a visible Documents tab in company-tabs.tsx (was a hidden stub). Inline TODOs: - Reviewed remaining two TODOs (per-user reminder schedule, import worker handlers). Both are placeholders for future feature surfaces, not bugs — per-port digest works for every customer; nothing currently enqueues import jobs (verified). Annotated in BACKLOG. BACKLOG.md updated to reflect what landed and what's still pending (Documenso-related items still bundled with the back-burnered phases). Tests: 1185/1185 vitest, tsc clean.
2026-05-08 02:20:27 +02:00
recentlyViewed: recentlyViewedQuery.data?.data ?? [],
};
}
/**
* 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.
}
}