/** * 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 { 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; }