/** * 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'], }, // The granular settings cards below redirect to the `/admin/` routes // that actually exist — the catalog previously listed `/settings/` // paths that have never had route folders. We keep the keyword aliases // so the cmd-K search still finds them under the right destination. { href: '/:portSlug/admin/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/admin/branding', label: 'Branding (per-port logo, colors, copy)', category: 'settings', keywords: ['logo', 'theme', 'colors', 'tenant brand', 'white-label'], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/templates', label: 'Document templates', category: 'settings', keywords: ['eoi', 'documenso', 'pdf templates', 'template merge fields'], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/storage', label: 'File storage backend', category: 'settings', keywords: ['s3', 'minio', 'filesystem', 'storage'], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/settings', label: 'Berth recommender weights', category: 'settings', keywords: ['ranking', 'tier ladder', 'heat', 'fallthrough', 'recommend'], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/tags', label: 'Tags', category: 'settings', keywords: ['labels', 'categories', 'classification'], }, { href: '/:portSlug/settings', label: 'Notification preferences', category: 'settings', keywords: ['alerts', 'email digest', 'in-app', 'push', 'reminders digest'], }, { href: '/:portSlug/settings', label: 'My profile & preferences', category: 'settings', keywords: [ 'profile', 'avatar', 'display name', 'full name', 'phone', 'timezone', 'locale', 'country', 'dark mode', 'theme', 'password', 'change email', 'security', 'account', 'me', ], }, { href: '/:portSlug/dashboard?customize=1', label: 'Customize dashboard widgets', category: 'settings', keywords: ['widgets', 'tiles', 'dashboard layout', 'kpi', 'reorder widgets'], }, // ─── 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', 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/errors', label: 'Platform errors', category: 'admin', keywords: ['errors', 'exceptions', 'incidents', 'failures'], superAdminOnly: true, }, // ─── Admin → granular section cards (the AdminSectionsBrowser groups) ──── // These deep-link to specific admin sub-pages. Each one's `keywords` // mirrors the corresponding entry in src/components/admin/ // admin-sections-browser.tsx — so typing a setting key in the topbar // global search finds the same card the in-admin search would. { href: '/:portSlug/admin/settings', label: 'System Settings', category: 'admin', keywords: [ 'client portal', 'client portal enabled', 'ai interest scoring', 'ai email drafts', 'invoice net10 discount', 'net-10', 'pipeline weights', 'pipeline stage weights', 'forecast', 'berth rules', 'berth status rules', 'inquiry contact email', 'inquiry notification recipients', 'residential notification recipients', 'eoi signers', 'developer', 'approver', 'countersign', 'recommender max oversize', 'recommender top n', 'recommender default count', 'fallthrough policy', 'fallthrough cooldown', 'heat weight recency', 'heat weight furthest stage', 'heat weight interest count', 'heat weight eoi count', 'tier ladder', 'hide late stage', 'documents show expired tab', 'expired tab', 'berths default currency', 'default currency', ], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/branding', label: 'Branding', category: 'admin', keywords: ['logo', 'app name', 'theme', 'colors', 'email header', 'white-label'], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/email', label: 'Email settings', category: 'admin', keywords: ['smtp', 'imap', 'mail', 'from address', 'signature', 'mail server'], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/documenso', label: 'EOI signing service', category: 'admin', keywords: ['documenso', 'signing', 'eoi', 'api credentials', 'webhook secret'], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/reminders', label: 'Reminder settings', category: 'admin', keywords: ['reminders', 'daily digest', 'delivery window'], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/webhooks', label: 'Webhooks', category: 'admin', keywords: ['webhook', 'outgoing', 'callback', 'delivery log'], requires: 'admin.manage_webhooks', }, { href: '/:portSlug/admin/forms', label: 'Forms', category: 'admin', keywords: ['form templates', 'inquiry', 'intake', 'public form'], requires: 'admin.manage_forms', }, { href: '/:portSlug/admin/templates', label: 'Document templates', category: 'admin', keywords: ['pdf templates', 'email templates', 'merge fields', 'eoi template'], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/email-templates', label: 'Email templates', category: 'admin', keywords: ['transactional emails', 'subject lines', 'portal email', 'inquiry email'], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/tags', label: 'Tags', category: 'admin', keywords: ['labels', 'color-coded', 'classification'], requires: 'admin.manage_tags', }, { href: '/:portSlug/admin/vocabularies', label: 'Vocabularies', category: 'admin', keywords: [ 'pick lists', 'interest temperatures', 'status reasons', 'tenure types', 'expense categories', 'document types', ], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/custom-fields', label: 'Custom fields', category: 'admin', keywords: ['custom fields', 'tenant fields', 'extra fields'], requires: 'admin.manage_custom_fields', }, { href: '/:portSlug/admin/duplicates', label: 'Duplicates queue', category: 'admin', keywords: ['dedup', 'duplicate clients', 'merge', 'review queue'], }, { href: '/:portSlug/admin/import', label: 'Bulk import', category: 'admin', keywords: ['csv', 'import', 'bulk upload'], }, { href: '/:portSlug/admin/sends', label: 'Send log', category: 'admin', keywords: ['email sends', 'brochures', 'send failures', 'retries'], }, { href: '/:portSlug/admin/monitoring', label: 'Queue monitoring', category: 'admin', keywords: ['bullmq', 'queue', 'jobs', 'throughput', 'retries'], requires: 'admin.system_backup', }, { href: '/:portSlug/admin/backup', label: 'Backup & restore', category: 'admin', keywords: ['backup', 'restore', 'retention', 'disaster recovery'], requires: 'admin.system_backup', }, { href: '/:portSlug/admin/storage', label: 'Storage backend', category: 'admin', keywords: ['s3', 'minio', 'filesystem', 'storage backend', 'object store'], requires: 'admin.system_backup', }, { href: '/:portSlug/admin/ports', label: 'Ports', category: 'admin', keywords: ['marinas', 'tenancy', 'port management', 'multi-port'], superAdminOnly: true, }, { href: '/:portSlug/admin/ai', label: 'AI configuration', category: 'admin', keywords: ['openai', 'anthropic', 'gpt', 'claude', 'llm', 'api key', 'embeddings'], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/ocr', label: 'Receipt OCR', category: 'admin', keywords: ['receipt', 'scan', 'tesseract', 'expense scanner', 'confidence'], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/website-analytics', label: 'Website analytics (Umami)', category: 'admin', keywords: ['umami', 'analytics', 'traffic', 'visitors', 'marketing', 'pageviews'], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/residential-stages', label: 'Residential pipeline stages', category: 'admin', keywords: ['residential stages', 'pipeline', 'residential funnel'], requires: 'admin.manage_settings', }, { href: '/:portSlug/admin/roles', label: 'Roles & permissions', category: 'admin', keywords: ['roles', 'permissions', 'access control', 'rbac'], requires: 'admin.manage_users', }, { href: '/:portSlug/admin/invitations', label: 'Invitations', category: 'admin', keywords: ['invite', 'pending invites', 'onboarding'], requires: 'admin.manage_users', }, ]; /** 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 { const q = query.trim().toLowerCase(); if (q.length === 0) return []; const limit = opts.limit ?? 5; const out: Array = []; 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)[part]; } return cur === true; }