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

474 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'],
},
// The granular settings cards below redirect to the `/admin/<x>` routes
// that actually exist — the catalog previously listed `/settings/<x>`
// 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'],
},
{
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
href: '/:portSlug/settings',
label: 'Notification preferences',
category: 'settings',
keywords: ['alerts', 'email digest', 'in-app', 'push', 'reminders digest'],
},
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
{
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
href: '/:portSlug/settings',
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
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,
},
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;
}