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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user