Files
pn-new-crm/src/app/api/v1/search/recently-viewed/route.ts
Matt 267c2b6d1f feat(search): full-platform search overhaul + view tracking + notes bucket
Service rewrite covers 14 entity buckets (clients, residential clients,
yachts, companies, interests, residential interests, berths, invoices,
expenses, documents, files, reminders, brochures, tags, notes, navigation)
with prefix tsquery + trigram fallback, phone-digit normalization,
and JOINs to client_contacts for email matching.

New `notes` bucket searches across the four note tables (client,
interest, yacht, company) via UNION + parent-entity label resolution
(berth mooring for interests, name for yachts/companies). Renders at
the bottom of the dropdown so broad-content matches don't crowd
entity-specific hits — per the user's "low-noise" preference.

Recently-viewed tracking persists last 20 entity views per user in
Redis sorted set; CommandSearch surfaces them as the dropdown's
default state and applies affinity ranking when the user types.

ID-resolve endpoint accepts pasted UUIDs (or invoice numbers like
`INV-2025-001`) and routes the rep straight to the entity, skipping
the normal search bucket.

Audit search service gains `entityIds[]` array filter for the new
loadClientActivityAggregated() path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:58:34 +02:00

276 lines
9.1 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { sql } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { errorResponse } from '@/lib/errors';
import { getRecentlyViewed, trackView } from '@/lib/services/recently-viewed.service';
import { trackViewSchema } from '@/lib/validators/search';
/**
* Hydrated row returned by GET — contains the same `type` + `id` from
* the Redis sorted set, plus the labels needed to render the row in the
* dropdown ("Client · Jane Smith") without an extra round-trip.
*
* Rows whose underlying entity has been hard-deleted are filtered out
* (Redis keeps the membership; the hydration JOIN drops the orphan).
*/
interface HydratedRow {
type: string;
id: string;
label: string;
sub: string | null;
href: string;
viewedAt: number;
}
/** Batch-resolve labels for a list of (type, id) pairs grouped by type. */
async function hydrate(
portSlug: string,
portId: string,
pairs: Array<{ type: string; id: string; viewedAt: number }>,
): Promise<HydratedRow[]> {
if (pairs.length === 0) return [];
const byType = new Map<string, Array<{ id: string; viewedAt: number }>>();
for (const p of pairs) {
if (!byType.has(p.type)) byType.set(p.type, []);
byType.get(p.type)!.push({ id: p.id, viewedAt: p.viewedAt });
}
const rows: HydratedRow[] = [];
await Promise.all(
Array.from(byType.entries()).map(async ([type, items]) => {
const ids = items.map((i) => i.id);
const idVal = sql`(${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})`;
const idAt = new Map(items.map((i) => [i.id, i.viewedAt] as const));
switch (type) {
case 'client': {
const dbRows = await db.execute<{ id: string; full_name: string }>(sql`
SELECT id, full_name FROM clients
WHERE port_id = ${portId} AND archived_at IS NULL AND id IN ${idVal}
`);
for (const r of Array.from(dbRows)) {
rows.push({
type,
id: r.id,
label: r.full_name,
sub: 'Client',
href: `/${portSlug}/clients/${r.id}`,
viewedAt: idAt.get(r.id) ?? 0,
});
}
return;
}
case 'residential-client': {
const dbRows = await db.execute<{ id: string; full_name: string }>(sql`
SELECT id, full_name FROM residential_clients
WHERE port_id = ${portId} AND archived_at IS NULL AND id IN ${idVal}
`);
for (const r of Array.from(dbRows)) {
rows.push({
type,
id: r.id,
label: r.full_name,
sub: 'Residential client',
href: `/${portSlug}/residential/clients/${r.id}`,
viewedAt: idAt.get(r.id) ?? 0,
});
}
return;
}
case 'yacht': {
const dbRows = await db.execute<{ id: string; name: string; hull_number: string | null }>(
sql`SELECT id, name, hull_number FROM yachts
WHERE port_id = ${portId} AND archived_at IS NULL AND id IN ${idVal}`,
);
for (const r of Array.from(dbRows)) {
rows.push({
type,
id: r.id,
label: r.name,
sub: r.hull_number ? `Yacht · ${r.hull_number}` : 'Yacht',
href: `/${portSlug}/yachts/${r.id}`,
viewedAt: idAt.get(r.id) ?? 0,
});
}
return;
}
case 'company': {
const dbRows = await db.execute<{ id: string; name: string }>(
sql`SELECT id, name FROM companies
WHERE port_id = ${portId} AND archived_at IS NULL AND id IN ${idVal}`,
);
for (const r of Array.from(dbRows)) {
rows.push({
type,
id: r.id,
label: r.name,
sub: 'Company',
href: `/${portSlug}/companies/${r.id}`,
viewedAt: idAt.get(r.id) ?? 0,
});
}
return;
}
case 'interest': {
const dbRows = await db.execute<{
id: string;
full_name: string;
mooring_number: string | null;
}>(sql`
SELECT i.id, c.full_name, b.mooring_number
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
WHERE i.port_id = ${portId} AND i.archived_at IS NULL AND i.id IN ${idVal}
`);
for (const r of Array.from(dbRows)) {
rows.push({
type,
id: r.id,
label: r.full_name,
sub: r.mooring_number ? `Interest · ${r.mooring_number}` : 'Interest',
href: `/${portSlug}/interests/${r.id}`,
viewedAt: idAt.get(r.id) ?? 0,
});
}
return;
}
case 'residential-interest': {
const dbRows = await db.execute<{ id: string; full_name: string }>(sql`
SELECT ri.id, rc.full_name
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 ri.id IN ${idVal}
`);
for (const r of Array.from(dbRows)) {
rows.push({
type,
id: r.id,
label: r.full_name,
sub: 'Residential interest',
href: `/${portSlug}/residential/interests/${r.id}`,
viewedAt: idAt.get(r.id) ?? 0,
});
}
return;
}
case 'berth': {
const dbRows = await db.execute<{ id: string; mooring_number: string }>(sql`
SELECT id, mooring_number FROM berths
WHERE port_id = ${portId} AND id IN ${idVal}
`);
for (const r of Array.from(dbRows)) {
rows.push({
type,
id: r.id,
label: r.mooring_number,
sub: 'Berth',
href: `/${portSlug}/berths/${r.id}`,
viewedAt: idAt.get(r.id) ?? 0,
});
}
return;
}
case 'invoice': {
const dbRows = await db.execute<{
id: string;
invoice_number: string;
client_name: string;
}>(sql`
SELECT id, invoice_number, client_name FROM invoices
WHERE port_id = ${portId} AND id IN ${idVal}
`);
for (const r of Array.from(dbRows)) {
rows.push({
type,
id: r.id,
label: r.invoice_number,
sub: `Invoice · ${r.client_name}`,
href: `/${portSlug}/invoices/${r.id}`,
viewedAt: idAt.get(r.id) ?? 0,
});
}
return;
}
case 'expense': {
const dbRows = await db.execute<{
id: string;
description: string | null;
establishment_name: string | null;
}>(sql`
SELECT id, description, establishment_name FROM expenses
WHERE port_id = ${portId} AND id IN ${idVal}
`);
for (const r of Array.from(dbRows)) {
rows.push({
type,
id: r.id,
label: r.description ?? r.establishment_name ?? 'Expense',
sub: 'Expense',
href: `/${portSlug}/expenses/${r.id}`,
viewedAt: idAt.get(r.id) ?? 0,
});
}
return;
}
case 'document': {
const dbRows = await db.execute<{ id: string; title: string }>(sql`
SELECT id, title FROM documents
WHERE port_id = ${portId} AND id IN ${idVal}
`);
for (const r of Array.from(dbRows)) {
rows.push({
type,
id: r.id,
label: r.title,
sub: 'Document',
href: `/${portSlug}/documents/${r.id}`,
viewedAt: idAt.get(r.id) ?? 0,
});
}
return;
}
}
}),
);
// Re-sort by viewedAt DESC since the per-type queries lose the order.
rows.sort((a, b) => b.viewedAt - a.viewedAt);
return rows;
}
export const GET = withAuth(async (req: NextRequest, ctx) => {
try {
const limitParam = req.nextUrl.searchParams.get('limit');
const limit = limitParam ? Math.min(Math.max(Number(limitParam), 1), 20) : 10;
const pairs = await getRecentlyViewed(ctx.userId, ctx.portId, limit);
const items = await hydrate(ctx.portSlug, ctx.portId, pairs);
return NextResponse.json({ items });
} catch (error) {
return errorResponse(error);
}
});
export const POST = withAuth(async (req: NextRequest, ctx) => {
try {
const body = await req.json();
const parsed = trackViewSchema.parse(body);
trackView(ctx.userId, ctx.portId, parsed.type, parsed.id);
return NextResponse.json({ ok: true });
} catch (error) {
return errorResponse(error);
}
});