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>
This commit is contained in:
275
src/app/api/v1/search/recently-viewed/route.ts
Normal file
275
src/app/api/v1/search/recently-viewed/route.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
91
src/app/api/v1/search/resolve-id/route.ts
Normal file
91
src/app/api/v1/search/resolve-id/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* Resolves a pasted UUID or invoice number to the (type, href) of its
|
||||
* detail page. Used by the CommandSearch paste-detection shortcut so a
|
||||
* pasted ID jumps directly to the entity instead of running through the
|
||||
* full multi-bucket search.
|
||||
*
|
||||
* UNION-of-singletons across the high-signal entity tables. Each branch
|
||||
* is a primary-key lookup so the cost is dominated by network, not the
|
||||
* database. Returns `{ found: false }` when the id doesn't exist OR
|
||||
* when it exists but in a different port (port-scoped on purpose so a
|
||||
* super-admin paste from another port doesn't silently navigate
|
||||
* cross-tenant).
|
||||
*/
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
try {
|
||||
const id = req.nextUrl.searchParams.get('id')?.trim() ?? '';
|
||||
if (!id || id.length > 100) {
|
||||
return NextResponse.json({ found: false });
|
||||
}
|
||||
|
||||
// One UNION query covers every primary key the user might paste.
|
||||
// Each branch is a constant-time index lookup.
|
||||
const rows = await db.execute<{ type: string; entity_id: string }>(sql`
|
||||
SELECT 'client'::text AS type, id AS entity_id FROM clients
|
||||
WHERE id = ${id} AND port_id = ${ctx.portId} AND archived_at IS NULL
|
||||
UNION ALL
|
||||
SELECT 'residential-client', id FROM residential_clients
|
||||
WHERE id = ${id} AND port_id = ${ctx.portId} AND archived_at IS NULL
|
||||
UNION ALL
|
||||
SELECT 'yacht', id FROM yachts
|
||||
WHERE id = ${id} AND port_id = ${ctx.portId} AND archived_at IS NULL
|
||||
UNION ALL
|
||||
SELECT 'company', id FROM companies
|
||||
WHERE id = ${id} AND port_id = ${ctx.portId} AND archived_at IS NULL
|
||||
UNION ALL
|
||||
SELECT 'interest', id FROM interests
|
||||
WHERE id = ${id} AND port_id = ${ctx.portId} AND archived_at IS NULL
|
||||
UNION ALL
|
||||
SELECT 'residential-interest', id FROM residential_interests
|
||||
WHERE id = ${id} AND port_id = ${ctx.portId} AND archived_at IS NULL
|
||||
UNION ALL
|
||||
SELECT 'berth', id FROM berths
|
||||
WHERE id = ${id} AND port_id = ${ctx.portId}
|
||||
UNION ALL
|
||||
SELECT 'invoice', id FROM invoices
|
||||
WHERE (id = ${id} OR invoice_number = ${id}) AND port_id = ${ctx.portId}
|
||||
UNION ALL
|
||||
SELECT 'expense', id FROM expenses
|
||||
WHERE id = ${id} AND port_id = ${ctx.portId}
|
||||
UNION ALL
|
||||
SELECT 'document', id FROM documents
|
||||
WHERE id = ${id} AND port_id = ${ctx.portId}
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const list = Array.from(rows);
|
||||
if (list.length === 0) {
|
||||
return NextResponse.json({ found: false });
|
||||
}
|
||||
const row = list[0]!;
|
||||
|
||||
const hrefMap: Record<string, string> = {
|
||||
client: `/${ctx.portSlug}/clients/${row.entity_id}`,
|
||||
'residential-client': `/${ctx.portSlug}/residential/clients/${row.entity_id}`,
|
||||
yacht: `/${ctx.portSlug}/yachts/${row.entity_id}`,
|
||||
company: `/${ctx.portSlug}/companies/${row.entity_id}`,
|
||||
interest: `/${ctx.portSlug}/interests/${row.entity_id}`,
|
||||
'residential-interest': `/${ctx.portSlug}/residential/interests/${row.entity_id}`,
|
||||
berth: `/${ctx.portSlug}/berths/${row.entity_id}`,
|
||||
invoice: `/${ctx.portSlug}/invoices/${row.entity_id}`,
|
||||
expense: `/${ctx.portSlug}/expenses/${row.entity_id}`,
|
||||
document: `/${ctx.portSlug}/documents/${row.entity_id}`,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
found: true,
|
||||
type: row.type,
|
||||
id: row.entity_id,
|
||||
href: hrefMap[row.type] ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
@@ -2,24 +2,76 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { search, saveRecentSearch } from '@/lib/services/search.service';
|
||||
import { search, saveRecentSearch, getRecentlyTouchedIds } from '@/lib/services/search.service';
|
||||
import { searchQuerySchema } from '@/lib/validators/search';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
try {
|
||||
const q = req.nextUrl.searchParams.get('q') ?? '';
|
||||
const params = req.nextUrl.searchParams;
|
||||
const parsed = searchQuerySchema.parse({
|
||||
q: params.get('q') ?? '',
|
||||
type: params.get('type') ?? undefined,
|
||||
limit: params.get('limit') ?? undefined,
|
||||
includeOtherPorts: params.get('includeOtherPorts') ?? undefined,
|
||||
});
|
||||
|
||||
if (q.length < 2) {
|
||||
return NextResponse.json({ clients: [], interests: [], berths: [] });
|
||||
// Only super admins can opt into cross-port search; silently ignored
|
||||
// otherwise so a non-admin can't probe whether the flag exists.
|
||||
const includeOtherPorts = ctx.isSuperAdmin && (parsed.includeOtherPorts ?? false);
|
||||
|
||||
// Run the affinity-set query alongside the search so it costs us
|
||||
// nothing in wall-clock time. The set is small (capped at 200 ids)
|
||||
// and is used to boost ranking inside each bucket.
|
||||
const [touchedIds, results] = await Promise.all([
|
||||
getRecentlyTouchedIds(ctx.userId, ctx.portId).catch(() => new Set<string>()),
|
||||
search(ctx.portId, parsed.q, {
|
||||
permissions: ctx.permissions,
|
||||
isSuperAdmin: ctx.isSuperAdmin,
|
||||
limit: parsed.limit,
|
||||
type: parsed.type,
|
||||
includeOtherPorts,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Resolve `:portSlug` placeholders in the nav-catalog hrefs. Done
|
||||
// here (not in the service) so the service stays portSlug-free —
|
||||
// it only knows portId, which is the right tenant boundary anyway.
|
||||
if (results.navigation.length > 0) {
|
||||
results.navigation = results.navigation.map((n) => ({
|
||||
...n,
|
||||
href: n.href.replace(':portSlug', ctx.portSlug),
|
||||
id: n.href.replace(':portSlug', ctx.portSlug),
|
||||
}));
|
||||
}
|
||||
|
||||
// Validate via schema (also enforces max length)
|
||||
searchQuerySchema.parse({ q });
|
||||
// Re-run affinity sort with the now-resolved set. The service can
|
||||
// also accept this up front, but we kick off both queries in
|
||||
// parallel for latency, then apply on results — affinity is just a
|
||||
// post-sort, so order of operations doesn't change correctness.
|
||||
if (touchedIds.size > 0) {
|
||||
const reorder = <T extends { id: string }>(rows: T[]) => {
|
||||
const indexed = rows.map((row, idx) => ({ row, idx, hit: touchedIds.has(row.id) }));
|
||||
indexed.sort((a, b) => Number(b.hit) - Number(a.hit) || a.idx - b.idx);
|
||||
return indexed.map((x) => x.row);
|
||||
};
|
||||
results.clients = reorder(results.clients);
|
||||
results.residentialClients = reorder(results.residentialClients);
|
||||
results.yachts = reorder(results.yachts);
|
||||
results.companies = reorder(results.companies);
|
||||
results.interests = reorder(results.interests);
|
||||
results.residentialInterests = reorder(results.residentialInterests);
|
||||
results.berths = reorder(results.berths);
|
||||
results.invoices = reorder(results.invoices);
|
||||
results.expenses = reorder(results.expenses);
|
||||
results.documents = reorder(results.documents);
|
||||
results.files = reorder(results.files);
|
||||
results.reminders = reorder(results.reminders);
|
||||
}
|
||||
|
||||
const results = await search(ctx.portId, q);
|
||||
|
||||
// Fire-and-forget - do not await
|
||||
saveRecentSearch(ctx.userId, ctx.portId, q);
|
||||
// Fire-and-forget — recent search history is non-critical.
|
||||
if (parsed.q.length >= 2) {
|
||||
saveRecentSearch(ctx.userId, ctx.portId, parsed.q);
|
||||
}
|
||||
|
||||
return NextResponse.json(results);
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user