Files
pn-new-crm/src/lib/services/search-nav-catalog.ts
Matt 6b28459c45 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

474 lines
14 KiB
TypeScript

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