Extend the global search service to include yacht and company results using ILIKE matching on name, hull number, registration, legal name, and tax ID. Results are tenant-scoped and exclude archived rows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
220 lines
6.3 KiB
TypeScript
220 lines
6.3 KiB
TypeScript
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<SearchResults> {
|
|
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<string[]> {
|
|
const key = recentSearchKey(userId, portId);
|
|
const items = await redis.zrevrange(key, 0, RECENT_SEARCH_MAX - 1);
|
|
return items;
|
|
}
|