import { sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { redis } from '@/lib/redis'; // ─── Types ──────────────────────────────────────────────────────────────────── interface ClientResult { id: string; fullName: string; companyName: string | null; } interface InterestResult { id: string; clientName: string; berthMooringNumber: string | null; pipelineStage: string; } interface BerthResult { id: string; mooringNumber: string; area: string | null; status: string; } interface YachtResult { id: string; name: string; hullNumber: string | null; registration: string | null; } interface CompanyResult { id: string; name: string; legalName: string | null; taxId: string | null; } interface SearchResults { clients: ClientResult[]; interests: InterestResult[]; berths: BerthResult[]; yachts: YachtResult[]; companies: CompanyResult[]; } // ─── Search ─────────────────────────────────────────────────────────────────── 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; company_name: string | null }>(sql` SELECT id, full_name, company_name FROM clients WHERE port_id = ${portId} AND archived_at IS NULL AND to_tsvector('simple', coalesce(full_name, '') || ' ' || coalesce(company_name, '')) @@ plainto_tsquery('simple', ${query}) ORDER BY ts_rank( to_tsvector('simple', coalesce(full_name, '') || ' ' || coalesce(company_name, '')), plainto_tsquery('simple', ${query}) ) DESC LIMIT 10 `), // 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 `), // Interests: JOIN to clients and berths, ILIKE search db.execute<{ id: string; full_name: string; mooring_number: string | null; pipeline_stage: string; }>(sql` SELECT i.id, c.full_name, b.mooring_number, i.pipeline_stage FROM interests i JOIN clients c ON i.client_id = c.id LEFT JOIN berths b ON i.berth_id = b.id WHERE i.port_id = ${portId} AND i.archived_at IS NULL AND ( c.full_name ILIKE ${'%' + query + '%'} OR b.mooring_number ILIKE ${'%' + query + '%'} ) LIMIT 10 `), // Yachts: ILIKE on name, hull_number, registration db.execute<{ id: string; name: string; hull_number: string | null; registration: string | null; }>(sql` SELECT id, name, hull_number, registration FROM yachts WHERE port_id = ${portId} AND archived_at IS NULL AND ( name ILIKE ${'%' + query + '%'} OR hull_number ILIKE ${'%' + query + '%'} OR registration ILIKE ${'%' + query + '%'} ) ORDER BY CASE WHEN name ILIKE ${query + '%'} THEN 1 WHEN name ILIKE ${'%' + query + '%'} THEN 2 ELSE 3 END, name LIMIT 10 `), // Companies: ILIKE on name, legal_name, tax_id db.execute<{ id: string; name: string; legal_name: string | null; tax_id: string | null; }>(sql` SELECT id, name, legal_name, tax_id FROM companies WHERE port_id = ${portId} AND archived_at IS NULL AND ( name ILIKE ${'%' + query + '%'} OR legal_name ILIKE ${'%' + query + '%'} OR tax_id ILIKE ${'%' + query + '%'} ) ORDER BY CASE WHEN name ILIKE ${query + '%'} THEN 1 WHEN name ILIKE ${'%' + query + '%'} THEN 2 ELSE 3 END, name LIMIT 10 `), ]); return { clients: Array.from(clientRows).map((r) => ({ id: r.id, fullName: r.full_name, companyName: r.company_name ?? null, })), 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, })), }; } // ─── Recent Searches ────────────────────────────────────────────────────────── const RECENT_SEARCH_TTL = 2592000; // 30 days in seconds 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. */ 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 — recent searches are non-critical }); } /** * Returns the user's most recent searches, 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; }