Files
pn-new-crm/src/lib/services/search-nav-catalog.ts

470 lines
14 KiB
TypeScript
Raw Normal View History

/**
* 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'],
},
feat(admin+search): user-mgmt polish, role labels, search keyword index Admin search now matches against per-card keyword lists so typing "client portal", "smtp", "tier ladder" lands on the System Settings card (which hosts those flags). The same keyword list extends the topbar global search (NAV_CATALOG) so any setting key resolves from the cmd-K input — settings results sort to the bottom of the dropdown beneath entity hits. User management: - Third action button (Power/PowerOff) enables/disables sign-in from the desktop list; mobile card dropdown gains the same item. Backed by the existing userProfiles.isActive flag — withAuth already refuses disabled sessions with 403. - UserForm collects first + last name (canonical) alongside displayName, with admin email-change behind a confirmation modal. On confirm we send the OLD address an automated "your admin changed your sign-in email" notice (new template at admin-email-change.ts) and rewrite the Better Auth user row. - Phone field swaps the bare tel input for the shared PhoneInput (country combobox + AsYouType formatting + E.164 storage). - "Manage permissions" link points to /admin/roles?focusUser=… as a stepping stone for the future fine-tuned-permissions UI. Role names normalize through a new ROLE_LABELS + formatRole() helper in constants.ts. Replaces the ad-hoc humanizeRole in sidebar and the prettifyRoleName in role-list; user-list and user-card now render "Sales Agent" instead of "sales_agent". Custom roles pass through unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:14:12 +02:00
{
href: '/:portSlug/user-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-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,
},
feat(admin+search): user-mgmt polish, role labels, search keyword index Admin search now matches against per-card keyword lists so typing "client portal", "smtp", "tier ladder" lands on the System Settings card (which hosts those flags). The same keyword list extends the topbar global search (NAV_CATALOG) so any setting key resolves from the cmd-K input — settings results sort to the bottom of the dropdown beneath entity hits. User management: - Third action button (Power/PowerOff) enables/disables sign-in from the desktop list; mobile card dropdown gains the same item. Backed by the existing userProfiles.isActive flag — withAuth already refuses disabled sessions with 403. - UserForm collects first + last name (canonical) alongside displayName, with admin email-change behind a confirmation modal. On confirm we send the OLD address an automated "your admin changed your sign-in email" notice (new template at admin-email-change.ts) and rewrite the Better Auth user row. - Phone field swaps the bare tel input for the shared PhoneInput (country combobox + AsYouType formatting + E.164 storage). - "Manage permissions" link points to /admin/roles?focusUser=… as a stepping stone for the future fine-tuned-permissions UI. Role names normalize through a new ROLE_LABELS + formatRole() helper in constants.ts. Replaces the ad-hoc humanizeRole in sidebar and the prettifyRoleName in role-list; user-list and user-card now render "Sales Agent" instead of "sales_agent". Custom roles pass through unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:14:12 +02:00
// ─── 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<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;
}