140 lines
4.4 KiB
TypeScript
140 lines
4.4 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 SearchResults {
|
||
|
|
clients: ClientResult[];
|
||
|
|
interests: InterestResult[];
|
||
|
|
berths: BerthResult[];
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Search ───────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
export async function search(portId: string, query: string): Promise<SearchResults> {
|
||
|
|
const [clientRows, berthRows, interestRows] = 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
|
||
|
|
`),
|
||
|
|
]);
|
||
|
|
|
||
|
|
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,
|
||
|
|
})),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 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;
|
||
|
|
}
|