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 { withAuth } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
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';
|
import { searchQuerySchema } from '@/lib/validators/search';
|
||||||
|
|
||||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||||
try {
|
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) {
|
// Only super admins can opt into cross-port search; silently ignored
|
||||||
return NextResponse.json({ clients: [], interests: [], berths: [] });
|
// 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)
|
// Re-run affinity sort with the now-resolved set. The service can
|
||||||
searchQuerySchema.parse({ q });
|
// 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 — recent search history is non-critical.
|
||||||
|
if (parsed.q.length >= 2) {
|
||||||
// Fire-and-forget - do not await
|
saveRecentSearch(ctx.userId, ctx.portId, parsed.q);
|
||||||
saveRecentSearch(ctx.userId, ctx.portId, q);
|
}
|
||||||
|
|
||||||
return NextResponse.json(results);
|
return NextResponse.json(results);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
54
src/components/search/highlight-match.tsx
Normal file
54
src/components/search/highlight-match.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Fragment, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap occurrences of `query` inside `text` with `<mark>` so the user
|
||||||
|
* can see why a result matched. Case-insensitive, escapes regex meta-
|
||||||
|
* chars in the query so a paste of "INV-2025" doesn't blow up.
|
||||||
|
*
|
||||||
|
* Tokenized matching — splits the query on whitespace and highlights
|
||||||
|
* each token independently, so "joh smi" highlights both "Joh" and
|
||||||
|
* "Smi" in "John Smith". Mirrors the prefix-tsquery the server uses.
|
||||||
|
*/
|
||||||
|
export function HighlightMatch({
|
||||||
|
text,
|
||||||
|
query,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
text: string | null | undefined;
|
||||||
|
query: string;
|
||||||
|
className?: string;
|
||||||
|
}): ReactNode {
|
||||||
|
if (!text) return null;
|
||||||
|
const tokens = query
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((t) => t.length > 0)
|
||||||
|
.map(escapeRegex);
|
||||||
|
if (tokens.length === 0) return text;
|
||||||
|
|
||||||
|
const re = new RegExp(`(${tokens.join('|')})`, 'gi');
|
||||||
|
const parts = text.split(re);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={className}>
|
||||||
|
{parts.map((part, i) => {
|
||||||
|
if (i % 2 === 1) {
|
||||||
|
// The capture group lands on odd indices.
|
||||||
|
return (
|
||||||
|
<mark
|
||||||
|
key={i}
|
||||||
|
className="bg-brand/15 text-foreground rounded-[2px] px-[1px] font-medium"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Fragment key={i}>{part}</Fragment>;
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(input: string): string {
|
||||||
|
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
33
src/components/search/track-entity-view.tsx
Normal file
33
src/components/search/track-entity-view.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTrackEntityView } from '@/hooks/use-track-entity-view';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render-only client component that records "the user opened this
|
||||||
|
* entity" via the global-search recently-viewed Redis log. Drop into a
|
||||||
|
* server-rendered detail page as `<TrackEntityView type="client" id={…} />`
|
||||||
|
* — the component renders nothing.
|
||||||
|
*
|
||||||
|
* Centralises the tracking call so future changes (debounce, schema,
|
||||||
|
* batching) only need to touch one file rather than every detail page.
|
||||||
|
*/
|
||||||
|
export function TrackEntityView({
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
type:
|
||||||
|
| 'client'
|
||||||
|
| 'residential-client'
|
||||||
|
| 'yacht'
|
||||||
|
| 'company'
|
||||||
|
| 'interest'
|
||||||
|
| 'residential-interest'
|
||||||
|
| 'berth'
|
||||||
|
| 'invoice'
|
||||||
|
| 'expense'
|
||||||
|
| 'document';
|
||||||
|
id: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
useTrackEntityView(type, id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,57 +1,260 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { useDebounce } from '@/hooks/use-debounce';
|
import { useDebounce } from '@/hooks/use-debounce';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types — mirror SearchResults from search.service.ts ─────────────────────
|
||||||
|
|
||||||
interface SearchResults {
|
export type BucketType =
|
||||||
clients: Array<{ id: string; fullName: string }>;
|
| 'clients'
|
||||||
interests: Array<{
|
| 'residentialClients'
|
||||||
|
| 'yachts'
|
||||||
|
| 'companies'
|
||||||
|
| 'interests'
|
||||||
|
| 'residentialInterests'
|
||||||
|
| 'berths'
|
||||||
|
| 'invoices'
|
||||||
|
| 'expenses'
|
||||||
|
| 'documents'
|
||||||
|
| 'files'
|
||||||
|
| 'reminders'
|
||||||
|
| 'brochures'
|
||||||
|
| 'tags'
|
||||||
|
| 'navigation'
|
||||||
|
| 'notes';
|
||||||
|
|
||||||
|
export interface ClientResult {
|
||||||
id: string;
|
id: string;
|
||||||
clientName: string;
|
fullName: string;
|
||||||
berthMooringNumber: string | null;
|
matchedContact: string | null;
|
||||||
pipelineStage: string;
|
matchedContactChannel: 'email' | 'phone' | 'whatsapp' | null;
|
||||||
}>;
|
archivedAt: string | null;
|
||||||
berths: Array<{ id: string; mooringNumber: string; area: string | null; status: string }>;
|
}
|
||||||
yachts: Array<{
|
export interface ResidentialClientResult {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
status: string;
|
||||||
|
archivedAt: string | null;
|
||||||
|
}
|
||||||
|
export interface YachtResult {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
hullNumber: string | null;
|
hullNumber: string | null;
|
||||||
registration: string | null;
|
registration: string | null;
|
||||||
}>;
|
archivedAt: string | null;
|
||||||
companies: Array<{
|
}
|
||||||
|
export interface CompanyResult {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
legalName: string | null;
|
legalName: string | null;
|
||||||
taxId: string | null;
|
taxId: string | null;
|
||||||
}>;
|
matchedField: 'name' | 'legalName' | 'taxId' | 'billingEmail' | 'registrationNumber' | null;
|
||||||
|
archivedAt: string | null;
|
||||||
|
}
|
||||||
|
export interface InterestResult {
|
||||||
|
id: string;
|
||||||
|
clientName: string;
|
||||||
|
berthMooringNumber: string | null;
|
||||||
|
pipelineStage: string;
|
||||||
|
outcome: string | null;
|
||||||
|
}
|
||||||
|
export interface ResidentialInterestResult {
|
||||||
|
id: string;
|
||||||
|
clientName: string;
|
||||||
|
pipelineStage: string;
|
||||||
|
}
|
||||||
|
export interface BerthResult {
|
||||||
|
id: string;
|
||||||
|
mooringNumber: string;
|
||||||
|
area: string | null;
|
||||||
|
status: string;
|
||||||
|
linkedInterestCount: number;
|
||||||
|
}
|
||||||
|
export interface InvoiceResult {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
clientName: string;
|
||||||
|
status: string;
|
||||||
|
paymentStatus: string | null;
|
||||||
|
totalAmount: string | null;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
export interface ExpenseResult {
|
||||||
|
id: string;
|
||||||
|
description: string | null;
|
||||||
|
vendor: string | null;
|
||||||
|
tripLabel: string | null;
|
||||||
|
amount: string;
|
||||||
|
currency: string;
|
||||||
|
paymentStatus: string | null;
|
||||||
|
}
|
||||||
|
export interface DocumentResult {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
documentType: string;
|
||||||
|
status: string;
|
||||||
|
matchedSignerName: string | null;
|
||||||
|
}
|
||||||
|
export interface FileResult {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
category: string | null;
|
||||||
|
ownerLabel: string | null;
|
||||||
|
}
|
||||||
|
export interface ReminderResult {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
dueAt: string;
|
||||||
|
priority: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
export interface BrochureResult {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
archivedAt: string | null;
|
||||||
|
}
|
||||||
|
export interface TagResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
export interface NavResult {
|
||||||
|
id: string;
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
category: 'settings' | 'admin' | 'dashboard';
|
||||||
|
}
|
||||||
|
export interface OtherPortResult {
|
||||||
|
portId: string;
|
||||||
|
portSlug: string;
|
||||||
|
portName: string;
|
||||||
|
type: 'client' | 'yacht' | 'company' | 'berth' | 'interest';
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
sub: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
export interface SearchResults {
|
||||||
|
clients: ClientResult[];
|
||||||
|
residentialClients: ResidentialClientResult[];
|
||||||
|
yachts: YachtResult[];
|
||||||
|
companies: CompanyResult[];
|
||||||
|
interests: InterestResult[];
|
||||||
|
residentialInterests: ResidentialInterestResult[];
|
||||||
|
berths: BerthResult[];
|
||||||
|
invoices: InvoiceResult[];
|
||||||
|
expenses: ExpenseResult[];
|
||||||
|
documents: DocumentResult[];
|
||||||
|
files: FileResult[];
|
||||||
|
reminders: ReminderResult[];
|
||||||
|
brochures: BrochureResult[];
|
||||||
|
tags: TagResult[];
|
||||||
|
navigation: NavResult[];
|
||||||
|
notes: NoteResult[];
|
||||||
|
totals: Record<BucketType, number>;
|
||||||
|
otherPorts?: OtherPortResult[];
|
||||||
|
}
|
||||||
|
|
||||||
export function useSearch(query: string) {
|
export interface NoteResult {
|
||||||
const debouncedQuery = useDebounce(query, 300);
|
id: string;
|
||||||
|
snippet: string;
|
||||||
|
source: 'client' | 'interest' | 'yacht' | 'company';
|
||||||
|
sourceId: string;
|
||||||
|
sourceLabel: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentlyViewedItem {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
sub: string | null;
|
||||||
|
href: string;
|
||||||
|
viewedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hooks ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface UseSearchOptions {
|
||||||
|
/** When set, narrows the result set to a single bucket. */
|
||||||
|
type?: BucketType;
|
||||||
|
/** Per-bucket cap. Default 5 (dropdown); use 25 for the /search page. */
|
||||||
|
limit?: number;
|
||||||
|
/** Super-admin opt-in for cross-port matches. Silently ignored otherwise. */
|
||||||
|
includeOtherPorts?: boolean;
|
||||||
|
/** Override the 300ms input debounce. */
|
||||||
|
debounceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearch(query: string, opts: UseSearchOptions = {}) {
|
||||||
|
const debouncedQuery = useDebounce(query, opts.debounceMs ?? 300);
|
||||||
|
const enabled = debouncedQuery.length >= 2;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('q', debouncedQuery);
|
||||||
|
if (opts.type) params.set('type', opts.type);
|
||||||
|
if (opts.limit) params.set('limit', String(opts.limit));
|
||||||
|
if (opts.includeOtherPorts) params.set('includeOtherPorts', 'true');
|
||||||
|
|
||||||
const searchQuery = useQuery<SearchResults>({
|
const searchQuery = useQuery<SearchResults>({
|
||||||
queryKey: ['search', debouncedQuery],
|
queryKey: [
|
||||||
queryFn: () =>
|
'search',
|
||||||
apiFetch<SearchResults>(`/api/v1/search?q=${encodeURIComponent(debouncedQuery)}`),
|
debouncedQuery,
|
||||||
enabled: debouncedQuery.length >= 2,
|
opts.type ?? 'all',
|
||||||
|
opts.limit ?? 5,
|
||||||
|
opts.includeOtherPorts ?? false,
|
||||||
|
],
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiFetch<SearchResults>(`/api/v1/search?${params.toString()}`, { signal }),
|
||||||
|
enabled,
|
||||||
|
// Keep previous results visible while the next debounced query loads
|
||||||
|
// — eliminates the dropdown flicker when the user is typing fast.
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const recentQuery = useQuery<{ searches: string[] }>({
|
const recentSearchQuery = useQuery<{ searches: string[] }>({
|
||||||
queryKey: ['search', 'recent'],
|
queryKey: ['search', 'recent-terms'],
|
||||||
queryFn: () => apiFetch<{ searches: string[] }>('/api/v1/search/recent'),
|
queryFn: ({ signal }) => apiFetch<{ searches: string[] }>('/api/v1/search/recent', { signal }),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const recentlyViewedQuery = useQuery<{ items: RecentlyViewedItem[] }>({
|
||||||
|
queryKey: ['search', 'recently-viewed'],
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiFetch<{ items: RecentlyViewedItem[] }>('/api/v1/search/recently-viewed', { signal }),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
results: searchQuery.data,
|
results: searchQuery.data,
|
||||||
isLoading: searchQuery.isLoading,
|
isLoading: searchQuery.isLoading,
|
||||||
recentSearches: recentQuery.data?.searches ?? [],
|
isFetching: searchQuery.isFetching,
|
||||||
|
enabled,
|
||||||
|
recentSearches: recentSearchQuery.data?.searches ?? [],
|
||||||
|
recentlyViewed: recentlyViewedQuery.data?.items ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track that the current user just opened an entity. Call once on mount
|
||||||
|
* from each entity detail page (via `useTrackEntityView`); the resulting
|
||||||
|
* (type, id) pair is used by the search dropdown's "Recently viewed"
|
||||||
|
* section to surface the user's working set.
|
||||||
|
*/
|
||||||
|
export async function trackEntityView(type: string, id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/v1/search/recently-viewed', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { type, id },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Tracking is non-critical — never bubble up to the user.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
37
src/hooks/use-track-entity-view.ts
Normal file
37
src/hooks/use-track-entity-view.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { trackEntityView } from '@/hooks/use-search';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records that the user opened the given entity detail page so the
|
||||||
|
* global search dropdown can surface it under "Recently viewed". Skips
|
||||||
|
* the call when `id` is falsy (e.g. during a transitional render before
|
||||||
|
* the data has loaded).
|
||||||
|
*
|
||||||
|
* Uses a JSON-stringified deps array so re-renders with the same
|
||||||
|
* (type, id) don't re-fire the network call. The fire-and-forget
|
||||||
|
* tracking endpoint debounces server-side too (Redis ZADD upserts the
|
||||||
|
* same member with a fresh score), but skipping the redundant fetch
|
||||||
|
* keeps the network panel tidy.
|
||||||
|
*/
|
||||||
|
export function useTrackEntityView(
|
||||||
|
type:
|
||||||
|
| 'client'
|
||||||
|
| 'residential-client'
|
||||||
|
| 'yacht'
|
||||||
|
| 'company'
|
||||||
|
| 'interest'
|
||||||
|
| 'residential-interest'
|
||||||
|
| 'berth'
|
||||||
|
| 'invoice'
|
||||||
|
| 'expense'
|
||||||
|
| 'document',
|
||||||
|
id: string | null | undefined,
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
void trackEntityView(type, id);
|
||||||
|
}, [type, id]);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* `audit_logs.search_text`.
|
* `audit_logs.search_text`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { and, desc, eq, gte, lte, sql, type SQL } from 'drizzle-orm';
|
import { and, desc, eq, gte, inArray, lte, sql, type SQL } from 'drizzle-orm';
|
||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { auditLogs, type AuditLog } from '@/lib/db/schema/system';
|
import { auditLogs, type AuditLog } from '@/lib/db/schema/system';
|
||||||
@@ -22,6 +22,10 @@ export interface AuditSearchOptions {
|
|||||||
entityType?: string;
|
entityType?: string;
|
||||||
/** Filter by exact entity id (e.g. paste a uuid into search). */
|
/** Filter by exact entity id (e.g. paste a uuid into search). */
|
||||||
entityId?: string;
|
entityId?: string;
|
||||||
|
/** Filter by an explicit list of entity ids (e.g. aggregated activity
|
||||||
|
* for a client across all their interests). Overrides `entityId`
|
||||||
|
* when both are supplied. Empty array short-circuits to zero rows. */
|
||||||
|
entityIds?: string[];
|
||||||
/** Filter by severity ('info' | 'warning' | 'error' | 'critical'). */
|
/** Filter by severity ('info' | 'warning' | 'error' | 'critical'). */
|
||||||
severity?: string;
|
severity?: string;
|
||||||
/** Filter by source ('user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job'). */
|
/** Filter by source ('user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job'). */
|
||||||
@@ -45,7 +49,15 @@ export async function searchAuditLogs(options: AuditSearchOptions = {}): Promise
|
|||||||
if (options.userId) conds.push(eq(auditLogs.userId, options.userId));
|
if (options.userId) conds.push(eq(auditLogs.userId, options.userId));
|
||||||
if (options.action) conds.push(eq(auditLogs.action, options.action));
|
if (options.action) conds.push(eq(auditLogs.action, options.action));
|
||||||
if (options.entityType) conds.push(eq(auditLogs.entityType, options.entityType));
|
if (options.entityType) conds.push(eq(auditLogs.entityType, options.entityType));
|
||||||
if (options.entityId) conds.push(eq(auditLogs.entityId, options.entityId));
|
if (options.entityIds) {
|
||||||
|
if (options.entityIds.length === 0) {
|
||||||
|
// Short-circuit: caller passed an empty list → no possible match.
|
||||||
|
return { rows: [], nextCursor: null };
|
||||||
|
}
|
||||||
|
conds.push(inArray(auditLogs.entityId, options.entityIds));
|
||||||
|
} else if (options.entityId) {
|
||||||
|
conds.push(eq(auditLogs.entityId, options.entityId));
|
||||||
|
}
|
||||||
if (options.severity) conds.push(eq(auditLogs.severity, options.severity));
|
if (options.severity) conds.push(eq(auditLogs.severity, options.severity));
|
||||||
if (options.source) conds.push(eq(auditLogs.source, options.source));
|
if (options.source) conds.push(eq(auditLogs.source, options.source));
|
||||||
if (options.from) conds.push(gte(auditLogs.createdAt, options.from));
|
if (options.from) conds.push(gte(auditLogs.createdAt, options.from));
|
||||||
|
|||||||
120
src/lib/services/recently-viewed.service.ts
Normal file
120
src/lib/services/recently-viewed.service.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Tracks the entities each user has recently opened so the global search
|
||||||
|
* dropdown can surface "Recently viewed" suggestions before the user types.
|
||||||
|
*
|
||||||
|
* Storage: Redis sorted set per (user, port). Key is
|
||||||
|
* `recent-views:<userId>:<portId>`, score is the unix epoch of the view,
|
||||||
|
* member is `<entityType>:<entityId>`. We trim the set to RECENT_VIEW_MAX
|
||||||
|
* after every write so stale ids age out.
|
||||||
|
*
|
||||||
|
* The companion API route hydrates the typed/labelled rows on read by
|
||||||
|
* looking up the underlying tables; this service only deals in (type, id)
|
||||||
|
* pairs to stay schema-free and cheap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { redis } from '@/lib/redis';
|
||||||
|
|
||||||
|
export type RecentlyViewedType =
|
||||||
|
| 'client'
|
||||||
|
| 'residential-client'
|
||||||
|
| 'yacht'
|
||||||
|
| 'company'
|
||||||
|
| 'interest'
|
||||||
|
| 'residential-interest'
|
||||||
|
| 'berth'
|
||||||
|
| 'invoice'
|
||||||
|
| 'expense'
|
||||||
|
| 'document';
|
||||||
|
|
||||||
|
export interface RecentlyViewedEntry {
|
||||||
|
type: RecentlyViewedType;
|
||||||
|
id: string;
|
||||||
|
/** Unix milliseconds of the most recent view. */
|
||||||
|
viewedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECENT_VIEW_TTL = 60 * 60 * 24 * 30; // 30 days
|
||||||
|
const RECENT_VIEW_MAX = 20;
|
||||||
|
|
||||||
|
function key(userId: string, portId: string): string {
|
||||||
|
return `recent-views:${userId}:${portId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encode(type: RecentlyViewedType, id: string): string {
|
||||||
|
return `${type}:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode(member: string): { type: RecentlyViewedType; id: string } | null {
|
||||||
|
const colon = member.indexOf(':');
|
||||||
|
if (colon < 0) return null;
|
||||||
|
return {
|
||||||
|
type: member.slice(0, colon) as RecentlyViewedType,
|
||||||
|
id: member.slice(colon + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records an entity view. Fire-and-forget — caller should NOT await this
|
||||||
|
* in the hot path; the redis op is logged-and-swallowed on failure since
|
||||||
|
* a missed view never breaks the user experience.
|
||||||
|
*/
|
||||||
|
export function trackView(
|
||||||
|
userId: string,
|
||||||
|
portId: string,
|
||||||
|
type: RecentlyViewedType,
|
||||||
|
id: string,
|
||||||
|
): void {
|
||||||
|
if (!userId || !portId || !id) return;
|
||||||
|
|
||||||
|
const k = key(userId, portId);
|
||||||
|
const member = encode(type, id);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
redis
|
||||||
|
.zadd(k, now, member)
|
||||||
|
.then(() => redis.zremrangebyrank(k, 0, -(RECENT_VIEW_MAX + 1)))
|
||||||
|
.then(() => redis.expire(k, RECENT_VIEW_TTL))
|
||||||
|
.catch(() => {
|
||||||
|
// intentionally swallowed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the user's recently-viewed entities, newest first. Limit is
|
||||||
|
* defensively capped so a misbehaving caller can't pull thousands of rows.
|
||||||
|
*/
|
||||||
|
export async function getRecentlyViewed(
|
||||||
|
userId: string,
|
||||||
|
portId: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<RecentlyViewedEntry[]> {
|
||||||
|
const k = key(userId, portId);
|
||||||
|
const cap = Math.min(Math.max(limit, 1), RECENT_VIEW_MAX);
|
||||||
|
|
||||||
|
// ZREVRANGE WITHSCORES → flat array [member, score, member, score, …]
|
||||||
|
const raw = await redis.zrevrange(k, 0, cap - 1, 'WITHSCORES');
|
||||||
|
|
||||||
|
const out: RecentlyViewedEntry[] = [];
|
||||||
|
for (let i = 0; i < raw.length; i += 2) {
|
||||||
|
const member = raw[i];
|
||||||
|
const score = raw[i + 1];
|
||||||
|
if (!member || !score) continue;
|
||||||
|
const decoded = decode(member);
|
||||||
|
if (!decoded) continue;
|
||||||
|
out.push({ ...decoded, viewedAt: Number(score) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a single entity from a user's recent-views (e.g. after the
|
||||||
|
* entity is hard-deleted). Best-effort.
|
||||||
|
*/
|
||||||
|
export function forgetView(
|
||||||
|
userId: string,
|
||||||
|
portId: string,
|
||||||
|
type: RecentlyViewedType,
|
||||||
|
id: string,
|
||||||
|
): void {
|
||||||
|
redis.zrem(key(userId, portId), encode(type, id)).catch(() => {});
|
||||||
|
}
|
||||||
222
src/lib/services/search-nav-catalog.ts
Normal file
222
src/lib/services/search-nav-catalog.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* Static catalog of navigation destinations the global search bar can jump
|
||||||
|
* to: settings pages, admin panels, and top-level dashboards.
|
||||||
|
*
|
||||||
|
* Each entry has an `href` template (run through `resolveHref` with the
|
||||||
|
* current portSlug), a human label, and a list of keyword aliases. The
|
||||||
|
* search service substring-matches the query against the label + every
|
||||||
|
* keyword, so `smtp` lands on the email settings page even though the
|
||||||
|
* label reads "Email accounts".
|
||||||
|
*
|
||||||
|
* Why hardcoded vs introspecting routes? The catalog is curated — only
|
||||||
|
* pages worth jumping to from a global search appear here, and each
|
||||||
|
* entry has hand-picked keyword synonyms that route inference can't
|
||||||
|
* derive. Adding a route to the catalog is cheap; misfiring routes are
|
||||||
|
* expensive.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RolePermissions } from '@/lib/db/schema/users';
|
||||||
|
|
||||||
|
export type NavCatalogCategory = 'settings' | 'admin' | 'dashboard';
|
||||||
|
|
||||||
|
export interface NavCatalogEntry {
|
||||||
|
/** Path template — `:portSlug` is substituted at lookup time. */
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
category: NavCatalogCategory;
|
||||||
|
/** Lowercase aliases — query is matched against label + these. */
|
||||||
|
keywords: string[];
|
||||||
|
/**
|
||||||
|
* Permission gate; only shown to users whose `RolePermissions` resolves
|
||||||
|
* truthy at the given dot-path (e.g. `'admin.manage_users'`). Super
|
||||||
|
* admins bypass the gate.
|
||||||
|
*/
|
||||||
|
requires?: string;
|
||||||
|
/** When set, only super admins see the entry. */
|
||||||
|
superAdminOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NAV_CATALOG: NavCatalogEntry[] = [
|
||||||
|
// ─── Dashboards ─────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
href: '/:portSlug/dashboard',
|
||||||
|
label: 'Dashboard',
|
||||||
|
category: 'dashboard',
|
||||||
|
keywords: ['home', 'overview', 'kpis', 'metrics'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/:portSlug/website-analytics',
|
||||||
|
label: 'Website analytics',
|
||||||
|
category: 'dashboard',
|
||||||
|
keywords: ['umami', 'traffic', 'visitors', 'pageviews', 'marketing'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Settings ───────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
href: '/:portSlug/settings',
|
||||||
|
label: 'Settings',
|
||||||
|
category: 'settings',
|
||||||
|
keywords: ['preferences', 'configuration', 'config'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/:portSlug/settings/email',
|
||||||
|
label: 'Email accounts (SMTP / IMAP)',
|
||||||
|
category: 'settings',
|
||||||
|
keywords: [
|
||||||
|
'smtp',
|
||||||
|
'imap',
|
||||||
|
'mail',
|
||||||
|
'mail server',
|
||||||
|
'email credentials',
|
||||||
|
'send-from',
|
||||||
|
'inbox',
|
||||||
|
'bounces',
|
||||||
|
],
|
||||||
|
requires: 'admin.manage_settings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/:portSlug/settings/branding',
|
||||||
|
label: 'Branding (per-port logo, colors, copy)',
|
||||||
|
category: 'settings',
|
||||||
|
keywords: ['logo', 'theme', 'colors', 'tenant brand', 'white-label'],
|
||||||
|
requires: 'admin.manage_settings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/:portSlug/settings/templates',
|
||||||
|
label: 'Document templates',
|
||||||
|
category: 'settings',
|
||||||
|
keywords: ['eoi', 'documenso', 'pdf templates', 'template merge fields'],
|
||||||
|
requires: 'admin.manage_settings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/:portSlug/settings/storage',
|
||||||
|
label: 'File storage backend',
|
||||||
|
category: 'settings',
|
||||||
|
keywords: ['s3', 'minio', 'filesystem', 'storage'],
|
||||||
|
requires: 'admin.manage_settings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/:portSlug/settings/recommender',
|
||||||
|
label: 'Berth recommender weights',
|
||||||
|
category: 'settings',
|
||||||
|
keywords: ['ranking', 'tier ladder', 'heat', 'fallthrough', 'recommend'],
|
||||||
|
requires: 'admin.manage_settings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/:portSlug/settings/tags',
|
||||||
|
label: 'Tags',
|
||||||
|
category: 'settings',
|
||||||
|
keywords: ['labels', 'categories', 'classification'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/:portSlug/settings/notifications',
|
||||||
|
label: 'Notification preferences',
|
||||||
|
category: 'settings',
|
||||||
|
keywords: ['alerts', 'email digest', 'in-app', 'push'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Admin ──────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
href: '/:portSlug/admin',
|
||||||
|
label: 'Administration',
|
||||||
|
category: 'admin',
|
||||||
|
keywords: ['admin'],
|
||||||
|
requires: 'admin.manage_users',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/:portSlug/admin/users',
|
||||||
|
label: 'Users & roles',
|
||||||
|
category: 'admin',
|
||||||
|
keywords: ['accounts', 'permissions', 'invites', 'team', 'staff', 'roles'],
|
||||||
|
requires: 'admin.manage_users',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/:portSlug/admin/audit-log',
|
||||||
|
label: 'Audit log',
|
||||||
|
category: 'admin',
|
||||||
|
keywords: ['activity', 'history', 'events', 'who did what', 'compliance'],
|
||||||
|
requires: 'admin.view_audit_log',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/:portSlug/admin/inquiries',
|
||||||
|
label: 'Website inquiries inbox',
|
||||||
|
category: 'admin',
|
||||||
|
keywords: ['enquiries', 'leads', 'contact form', 'eoi requests', 'website'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/:portSlug/admin/error-events',
|
||||||
|
label: 'Platform errors',
|
||||||
|
category: 'admin',
|
||||||
|
keywords: ['errors', 'exceptions', 'incidents', 'failures'],
|
||||||
|
superAdminOnly: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Substitute `:portSlug` placeholder for the current port. */
|
||||||
|
export function resolveHref(href: string, portSlug: string): string {
|
||||||
|
return href.replace(':portSlug', portSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns nav catalog entries matching the query, filtered by what the
|
||||||
|
* current user is allowed to see. Match is a substring check against
|
||||||
|
* label + each keyword; ranking favors label hits over keyword hits and
|
||||||
|
* prefix hits over mid-string hits.
|
||||||
|
*
|
||||||
|
* Pure / sync — runs in-process. The catalog is ~15 entries today, so
|
||||||
|
* the linear scan is irrelevant cost-wise.
|
||||||
|
*/
|
||||||
|
export function searchNavCatalog(
|
||||||
|
query: string,
|
||||||
|
opts: { isSuperAdmin: boolean; permissions: RolePermissions | null; limit?: number },
|
||||||
|
): Array<NavCatalogEntry & { score: number }> {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (q.length === 0) return [];
|
||||||
|
|
||||||
|
const limit = opts.limit ?? 5;
|
||||||
|
const out: Array<NavCatalogEntry & { score: number }> = [];
|
||||||
|
|
||||||
|
for (const entry of NAV_CATALOG) {
|
||||||
|
if (entry.superAdminOnly && !opts.isSuperAdmin) continue;
|
||||||
|
if (entry.requires && !opts.isSuperAdmin && !hasPermission(opts.permissions, entry.requires)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = scoreEntry(q, entry);
|
||||||
|
if (score > 0) out.push({ ...entry, score });
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort((a, b) => b.score - a.score);
|
||||||
|
return out.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreEntry(q: string, entry: NavCatalogEntry): number {
|
||||||
|
const label = entry.label.toLowerCase();
|
||||||
|
|
||||||
|
// Strongest signals first.
|
||||||
|
if (label === q) return 100;
|
||||||
|
if (label.startsWith(q)) return 80;
|
||||||
|
if (label.includes(q)) return 60;
|
||||||
|
|
||||||
|
// Keyword hits — strongest if the keyword exactly equals the query
|
||||||
|
// (e.g. user types "smtp"), then prefix, then substring.
|
||||||
|
for (const kw of entry.keywords) {
|
||||||
|
const k = kw.toLowerCase();
|
||||||
|
if (k === q) return 50;
|
||||||
|
if (k.startsWith(q)) return 35;
|
||||||
|
if (k.includes(q)) return 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPermission(perms: RolePermissions | null, dotPath: string): boolean {
|
||||||
|
if (!perms) return false;
|
||||||
|
const parts = dotPath.split('.');
|
||||||
|
let cur: unknown = perms;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (typeof cur !== 'object' || cur === null) return false;
|
||||||
|
cur = (cur as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
return cur === true;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,56 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const BUCKET_TYPES = [
|
||||||
|
'clients',
|
||||||
|
'residentialClients',
|
||||||
|
'yachts',
|
||||||
|
'companies',
|
||||||
|
'interests',
|
||||||
|
'residentialInterests',
|
||||||
|
'berths',
|
||||||
|
'invoices',
|
||||||
|
'expenses',
|
||||||
|
'documents',
|
||||||
|
'files',
|
||||||
|
'reminders',
|
||||||
|
'brochures',
|
||||||
|
'tags',
|
||||||
|
'navigation',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const searchQuerySchema = z.object({
|
export const searchQuerySchema = z.object({
|
||||||
|
// 2-char minimum keeps `to_tsquery('a:*')` from returning every word
|
||||||
|
// starting with "a" — short queries return overwhelming match sets.
|
||||||
q: z.string().min(2).max(200),
|
q: z.string().min(2).max(200),
|
||||||
|
/** Restrict the result set to a single bucket. */
|
||||||
|
type: z.enum(BUCKET_TYPES).optional(),
|
||||||
|
/** Per-bucket cap. Defaults to 5 (dropdown). 25 is the typical /search-page value. */
|
||||||
|
limit: z.coerce.number().int().min(1).max(50).optional(),
|
||||||
|
/** Super-admin only — search ports beyond the current one. */
|
||||||
|
includeOtherPorts: z
|
||||||
|
.union([z.literal('true'), z.literal('1'), z.literal('false'), z.literal('0')])
|
||||||
|
.transform((v) => v === 'true' || v === '1')
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SearchQuery = z.infer<typeof searchQuerySchema>;
|
export type SearchQuery = z.infer<typeof searchQuerySchema>;
|
||||||
|
|
||||||
|
const RECENTLY_VIEWED_TYPES = [
|
||||||
|
'client',
|
||||||
|
'residential-client',
|
||||||
|
'yacht',
|
||||||
|
'company',
|
||||||
|
'interest',
|
||||||
|
'residential-interest',
|
||||||
|
'berth',
|
||||||
|
'invoice',
|
||||||
|
'expense',
|
||||||
|
'document',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const trackViewSchema = z.object({
|
||||||
|
type: z.enum(RECENTLY_VIEWED_TYPES),
|
||||||
|
id: z.string().min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TrackViewPayload = z.infer<typeof trackViewSchema>;
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
import { search } from '@/lib/services/search.service';
|
import { search, buildPrefixTsquery, normalizePhoneQuery } from '@/lib/services/search.service';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { yachts, companies } from '@/lib/db/schema';
|
import { yachts, companies } from '@/lib/db/schema';
|
||||||
|
|
||||||
import { makePort, makeClient, makeYacht, makeCompany } from '../../helpers/factories';
|
import { makePort, makeClient, makeYacht, makeCompany } from '../../helpers/factories';
|
||||||
|
|
||||||
|
// Default opts — super admin so every bucket runs without per-resource
|
||||||
|
// permission gating getting in the way of the assertions.
|
||||||
|
const ADMIN_OPTS = { permissions: null, isSuperAdmin: true } as const;
|
||||||
|
|
||||||
// ─── Yachts ──────────────────────────────────────────────────────────────────
|
// ─── Yachts ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('search.service — yachts', () => {
|
describe('search.service — yachts', () => {
|
||||||
@@ -26,7 +30,7 @@ describe('search.service — yachts', () => {
|
|||||||
name: 'Wind Dancer',
|
name: 'Wind Dancer',
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await search(port.id, 'BREEZE');
|
const results = await search(port.id, 'BREEZE', ADMIN_OPTS);
|
||||||
expect(results.yachts.some((y) => y.name === 'Sea Breeze')).toBe(true);
|
expect(results.yachts.some((y) => y.name === 'Sea Breeze')).toBe(true);
|
||||||
expect(results.yachts.some((y) => y.name === 'Wind Dancer')).toBe(false);
|
expect(results.yachts.some((y) => y.name === 'Wind Dancer')).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -42,7 +46,7 @@ describe('search.service — yachts', () => {
|
|||||||
hullNumber: 'HULL-XYZ-999',
|
hullNumber: 'HULL-XYZ-999',
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await search(port.id, 'hull-xyz');
|
const results = await search(port.id, 'hull-xyz', ADMIN_OPTS);
|
||||||
expect(results.yachts.some((y) => y.hullNumber === 'HULL-XYZ-999')).toBe(true);
|
expect(results.yachts.some((y) => y.hullNumber === 'HULL-XYZ-999')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,7 +61,7 @@ describe('search.service — yachts', () => {
|
|||||||
registration: 'REG-ABC-123',
|
registration: 'REG-ABC-123',
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await search(port.id, 'reg-abc');
|
const results = await search(port.id, 'reg-abc', ADMIN_OPTS);
|
||||||
expect(results.yachts.some((y) => y.registration === 'REG-ABC-123')).toBe(true);
|
expect(results.yachts.some((y) => y.registration === 'REG-ABC-123')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,7 +76,7 @@ describe('search.service — yachts', () => {
|
|||||||
});
|
});
|
||||||
await db.update(yachts).set({ archivedAt: new Date() }).where(eq(yachts.id, yacht.id));
|
await db.update(yachts).set({ archivedAt: new Date() }).where(eq(yachts.id, yacht.id));
|
||||||
|
|
||||||
const results = await search(port.id, 'ghost ship');
|
const results = await search(port.id, 'ghost ship', ADMIN_OPTS);
|
||||||
expect(results.yachts.some((y) => y.id === yacht.id)).toBe(false);
|
expect(results.yachts.some((y) => y.id === yacht.id)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,7 +98,7 @@ describe('search.service — yachts', () => {
|
|||||||
name: 'UniqueYachtNameB',
|
name: 'UniqueYachtNameB',
|
||||||
});
|
});
|
||||||
|
|
||||||
const resultsA = await search(portA.id, 'UniqueYachtName');
|
const resultsA = await search(portA.id, 'UniqueYachtName', ADMIN_OPTS);
|
||||||
expect(resultsA.yachts.some((y) => y.name === 'UniqueYachtNameA')).toBe(true);
|
expect(resultsA.yachts.some((y) => y.name === 'UniqueYachtNameA')).toBe(true);
|
||||||
expect(resultsA.yachts.some((y) => y.name === 'UniqueYachtNameB')).toBe(false);
|
expect(resultsA.yachts.some((y) => y.name === 'UniqueYachtNameB')).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -108,7 +112,7 @@ describe('search.service — companies', () => {
|
|||||||
await makeCompany({ portId: port.id, overrides: { name: 'Poseidon Maritime Ltd' } });
|
await makeCompany({ portId: port.id, overrides: { name: 'Poseidon Maritime Ltd' } });
|
||||||
await makeCompany({ portId: port.id, overrides: { name: 'Neptune Holdings' } });
|
await makeCompany({ portId: port.id, overrides: { name: 'Neptune Holdings' } });
|
||||||
|
|
||||||
const results = await search(port.id, 'poseidon');
|
const results = await search(port.id, 'poseidon', ADMIN_OPTS);
|
||||||
expect(results.companies.some((c) => c.name === 'Poseidon Maritime Ltd')).toBe(true);
|
expect(results.companies.some((c) => c.name === 'Poseidon Maritime Ltd')).toBe(true);
|
||||||
expect(results.companies.some((c) => c.name === 'Neptune Holdings')).toBe(false);
|
expect(results.companies.some((c) => c.name === 'Neptune Holdings')).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -120,7 +124,7 @@ describe('search.service — companies', () => {
|
|||||||
overrides: { name: 'AcmeShort', legalName: 'Acme Legal Holdings Inc.' },
|
overrides: { name: 'AcmeShort', legalName: 'Acme Legal Holdings Inc.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await search(port.id, 'Acme Legal');
|
const results = await search(port.id, 'Acme Legal', ADMIN_OPTS);
|
||||||
expect(results.companies.some((c) => c.legalName === 'Acme Legal Holdings Inc.')).toBe(true);
|
expect(results.companies.some((c) => c.legalName === 'Acme Legal Holdings Inc.')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,7 +135,7 @@ describe('search.service — companies', () => {
|
|||||||
overrides: { name: 'TaxyCo', taxId: 'VAT-112233445' },
|
overrides: { name: 'TaxyCo', taxId: 'VAT-112233445' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await search(port.id, 'vat-112233');
|
const results = await search(port.id, 'vat-112233', ADMIN_OPTS);
|
||||||
expect(results.companies.some((c) => c.taxId === 'VAT-112233445')).toBe(true);
|
expect(results.companies.some((c) => c.taxId === 'VAT-112233445')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,7 +147,7 @@ describe('search.service — companies', () => {
|
|||||||
});
|
});
|
||||||
await db.update(companies).set({ archivedAt: new Date() }).where(eq(companies.id, company.id));
|
await db.update(companies).set({ archivedAt: new Date() }).where(eq(companies.id, company.id));
|
||||||
|
|
||||||
const results = await search(port.id, 'ArchivedCompanyXyz');
|
const results = await search(port.id, 'ArchivedCompanyXyz', ADMIN_OPTS);
|
||||||
expect(results.companies.some((c) => c.id === company.id)).toBe(false);
|
expect(results.companies.some((c) => c.id === company.id)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,7 +157,7 @@ describe('search.service — companies', () => {
|
|||||||
await makeCompany({ portId: portA.id, overrides: { name: 'UniqueCompanyA' } });
|
await makeCompany({ portId: portA.id, overrides: { name: 'UniqueCompanyA' } });
|
||||||
await makeCompany({ portId: portB.id, overrides: { name: 'UniqueCompanyB' } });
|
await makeCompany({ portId: portB.id, overrides: { name: 'UniqueCompanyB' } });
|
||||||
|
|
||||||
const resultsA = await search(portA.id, 'UniqueCompany');
|
const resultsA = await search(portA.id, 'UniqueCompany', ADMIN_OPTS);
|
||||||
expect(resultsA.companies.some((c) => c.name === 'UniqueCompanyA')).toBe(true);
|
expect(resultsA.companies.some((c) => c.name === 'UniqueCompanyA')).toBe(true);
|
||||||
expect(resultsA.companies.some((c) => c.name === 'UniqueCompanyB')).toBe(false);
|
expect(resultsA.companies.some((c) => c.name === 'UniqueCompanyB')).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -176,21 +180,96 @@ describe('search.service — combined', () => {
|
|||||||
});
|
});
|
||||||
await makeCompany({ portId: port.id, overrides: { name: 'Alpha Holdings' } });
|
await makeCompany({ portId: port.id, overrides: { name: 'Alpha Holdings' } });
|
||||||
|
|
||||||
const results = await search(port.id, 'alpha');
|
const results = await search(port.id, 'alpha', ADMIN_OPTS);
|
||||||
expect(results.clients.some((c) => c.fullName === 'Alpha Person')).toBe(true);
|
expect(results.clients.some((c) => c.fullName === 'Alpha Person')).toBe(true);
|
||||||
expect(results.yachts.some((y) => y.name === 'Alpha Yacht')).toBe(true);
|
expect(results.yachts.some((y) => y.name === 'Alpha Yacht')).toBe(true);
|
||||||
expect(results.companies.some((c) => c.name === 'Alpha Holdings')).toBe(true);
|
expect(results.companies.some((c) => c.name === 'Alpha Holdings')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns all result keys even when no matches exist', async () => {
|
it('returns every bucket as an empty array when no matches exist', async () => {
|
||||||
const port = await makePort();
|
const port = await makePort();
|
||||||
const results = await search(port.id, 'zzz-nothing-matches-zzz');
|
const results = await search(port.id, 'zzz-nothing-matches-zzz', ADMIN_OPTS);
|
||||||
expect(results).toEqual({
|
// Shape check (no rows in any bucket)
|
||||||
clients: [],
|
expect(results.clients).toEqual([]);
|
||||||
interests: [],
|
expect(results.residentialClients).toEqual([]);
|
||||||
berths: [],
|
expect(results.yachts).toEqual([]);
|
||||||
yachts: [],
|
expect(results.companies).toEqual([]);
|
||||||
companies: [],
|
expect(results.interests).toEqual([]);
|
||||||
|
expect(results.residentialInterests).toEqual([]);
|
||||||
|
expect(results.berths).toEqual([]);
|
||||||
|
expect(results.invoices).toEqual([]);
|
||||||
|
expect(results.expenses).toEqual([]);
|
||||||
|
expect(results.documents).toEqual([]);
|
||||||
|
expect(results.files).toEqual([]);
|
||||||
|
expect(results.reminders).toEqual([]);
|
||||||
|
expect(results.brochures).toEqual([]);
|
||||||
|
expect(results.tags).toEqual([]);
|
||||||
|
expect(results.navigation).toEqual([]);
|
||||||
|
// Totals are present and zero across the board.
|
||||||
|
expect(Object.values(results.totals).every((n) => n === 0)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Helper purity tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('buildPrefixTsquery', () => {
|
||||||
|
it('builds a tokenized prefix tsquery', () => {
|
||||||
|
expect(buildPrefixTsquery('joh smi')).toBe('joh:* & smi:*');
|
||||||
|
});
|
||||||
|
it('strips regex / tsquery meta-characters', () => {
|
||||||
|
expect(buildPrefixTsquery('a&b!c')).toBe('abc:*');
|
||||||
|
});
|
||||||
|
it('returns null for empty / whitespace-only input', () => {
|
||||||
|
expect(buildPrefixTsquery('')).toBeNull();
|
||||||
|
expect(buildPrefixTsquery(' ')).toBeNull();
|
||||||
|
});
|
||||||
|
it('returns null when every token reduces to nothing after sanitization', () => {
|
||||||
|
expect(buildPrefixTsquery('!@#$ %^&*')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizePhoneQuery', () => {
|
||||||
|
it('strips non-digit / non-plus characters', () => {
|
||||||
|
expect(normalizePhoneQuery('+44 7700 900 123')).toBe('+447700900123');
|
||||||
|
});
|
||||||
|
it('preserves leading +', () => {
|
||||||
|
expect(normalizePhoneQuery('+1 (555) 123-4567')).toBe('+15551234567');
|
||||||
|
});
|
||||||
|
it('returns null when fewer than 3 digits remain (too short to be unique)', () => {
|
||||||
|
expect(normalizePhoneQuery('a-b')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Partial name matching ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('search.service — partial name matching', () => {
|
||||||
|
it('matches "joh smi" against "John Smith" via tokenized prefix tsquery', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await makeClient({ portId: port.id, overrides: { fullName: 'John Smith' } });
|
||||||
|
await makeClient({ portId: port.id, overrides: { fullName: 'Joanna Smithers' } });
|
||||||
|
await makeClient({ portId: port.id, overrides: { fullName: 'Bob Jones' } });
|
||||||
|
|
||||||
|
const results = await search(port.id, 'joh smi', ADMIN_OPTS);
|
||||||
|
expect(results.clients.some((c) => c.fullName === 'John Smith')).toBe(true);
|
||||||
|
expect(results.clients.some((c) => c.fullName === 'Bob Jones')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a single name fragment ("joh" → "John …")', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await makeClient({ portId: port.id, overrides: { fullName: 'Johnathan Doe' } });
|
||||||
|
|
||||||
|
const results = await search(port.id, 'joh', ADMIN_OPTS);
|
||||||
|
expect(results.clients.some((c) => c.fullName === 'Johnathan Doe')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('search.service — bucket totals', () => {
|
||||||
|
it('emits per-bucket totals so the UI can render "show more" links', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await makeClient({ portId: port.id, overrides: { fullName: 'TotalsCheck One' } });
|
||||||
|
|
||||||
|
const results = await search(port.id, 'TotalsCheck', ADMIN_OPTS);
|
||||||
|
expect(results.totals.clients).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(typeof results.totals.invoices).toBe('number');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user