Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
139
src/lib/services/search.service.ts
Normal file
139
src/lib/services/search.service.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user