/** * 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 ──────────────────────────────────────────────────────────────────── 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 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 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 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[]; 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[]; } export interface OtherPortResult { portId: string; portSlug: string; portName: string; type: 'client' | 'yacht' | 'company' | 'berth' | 'interest'; id: string; label: string; sub: string | null; } 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; } // ─── Helpers ────────────────────────────────────────────────────────────────── /** * 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.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 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 ${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} ) ) ORDER BY i.id ) deduped ORDER BY CASE WHEN outcome IS NULL THEN 0 ELSE 1 END, pipeline_stage, full_name LIMIT ${limit} `); 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, })); } 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 ( c.full_name ILIKE ${ilikePattern} OR ( ${tsQ}::text IS NOT NULL AND to_tsvector('simple', coalesce(c.full_name, '')) @@ to_tsquery('simple', ${tsQ}) ) ) 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: [], 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 search-term history ────────────────────────────────────────────── 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. */ export function saveRecentSearch(userId: string, portId: string, searchTerm: string): void { const key = recentSearchKey(userId, portId); redis .zadd(key, Date.now(), searchTerm) .then(() => redis.zremrangebyrank(key, 0, -(RECENT_SEARCH_MAX + 1))) .then(() => redis.expire(key, RECENT_SEARCH_TTL)) .catch(() => { // intentionally swallowed }); } /** 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); return items; }