feat(search): full-platform search overhaul + view tracking + notes bucket
Service rewrite covers 14 entity buckets (clients, residential clients, yachts, companies, interests, residential interests, berths, invoices, expenses, documents, files, reminders, brochures, tags, notes, navigation) with prefix tsquery + trigram fallback, phone-digit normalization, and JOINs to client_contacts for email matching. New `notes` bucket searches across the four note tables (client, interest, yacht, company) via UNION + parent-entity label resolution (berth mooring for interests, name for yachts/companies). Renders at the bottom of the dropdown so broad-content matches don't crowd entity-specific hits — per the user's "low-noise" preference. Recently-viewed tracking persists last 20 entity views per user in Redis sorted set; CommandSearch surfaces them as the dropdown's default state and applies affinity ranking when the user types. ID-resolve endpoint accepts pasted UUIDs (or invoice numbers like `INV-2025-001`) and routes the rep straight to the entity, skipping the normal search bucket. Audit search service gains `entityIds[]` array filter for the new loadClientActivityAggregated() path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
* `audit_logs.search_text`.
|
||||
*/
|
||||
|
||||
import { and, desc, eq, gte, lte, sql, type SQL } from 'drizzle-orm';
|
||||
import { and, desc, eq, gte, inArray, lte, sql, type SQL } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { auditLogs, type AuditLog } from '@/lib/db/schema/system';
|
||||
@@ -22,6 +22,10 @@ export interface AuditSearchOptions {
|
||||
entityType?: string;
|
||||
/** Filter by exact entity id (e.g. paste a uuid into search). */
|
||||
entityId?: string;
|
||||
/** Filter by an explicit list of entity ids (e.g. aggregated activity
|
||||
* for a client across all their interests). Overrides `entityId`
|
||||
* when both are supplied. Empty array short-circuits to zero rows. */
|
||||
entityIds?: string[];
|
||||
/** Filter by severity ('info' | 'warning' | 'error' | 'critical'). */
|
||||
severity?: string;
|
||||
/** Filter by source ('user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job'). */
|
||||
@@ -45,7 +49,15 @@ export async function searchAuditLogs(options: AuditSearchOptions = {}): Promise
|
||||
if (options.userId) conds.push(eq(auditLogs.userId, options.userId));
|
||||
if (options.action) conds.push(eq(auditLogs.action, options.action));
|
||||
if (options.entityType) conds.push(eq(auditLogs.entityType, options.entityType));
|
||||
if (options.entityId) conds.push(eq(auditLogs.entityId, options.entityId));
|
||||
if (options.entityIds) {
|
||||
if (options.entityIds.length === 0) {
|
||||
// Short-circuit: caller passed an empty list → no possible match.
|
||||
return { rows: [], nextCursor: null };
|
||||
}
|
||||
conds.push(inArray(auditLogs.entityId, options.entityIds));
|
||||
} else if (options.entityId) {
|
||||
conds.push(eq(auditLogs.entityId, options.entityId));
|
||||
}
|
||||
if (options.severity) conds.push(eq(auditLogs.severity, options.severity));
|
||||
if (options.source) conds.push(eq(auditLogs.source, options.source));
|
||||
if (options.from) conds.push(gte(auditLogs.createdAt, options.from));
|
||||
|
||||
120
src/lib/services/recently-viewed.service.ts
Normal file
120
src/lib/services/recently-viewed.service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Tracks the entities each user has recently opened so the global search
|
||||
* dropdown can surface "Recently viewed" suggestions before the user types.
|
||||
*
|
||||
* Storage: Redis sorted set per (user, port). Key is
|
||||
* `recent-views:<userId>:<portId>`, score is the unix epoch of the view,
|
||||
* member is `<entityType>:<entityId>`. We trim the set to RECENT_VIEW_MAX
|
||||
* after every write so stale ids age out.
|
||||
*
|
||||
* The companion API route hydrates the typed/labelled rows on read by
|
||||
* looking up the underlying tables; this service only deals in (type, id)
|
||||
* pairs to stay schema-free and cheap.
|
||||
*/
|
||||
|
||||
import { redis } from '@/lib/redis';
|
||||
|
||||
export type RecentlyViewedType =
|
||||
| 'client'
|
||||
| 'residential-client'
|
||||
| 'yacht'
|
||||
| 'company'
|
||||
| 'interest'
|
||||
| 'residential-interest'
|
||||
| 'berth'
|
||||
| 'invoice'
|
||||
| 'expense'
|
||||
| 'document';
|
||||
|
||||
export interface RecentlyViewedEntry {
|
||||
type: RecentlyViewedType;
|
||||
id: string;
|
||||
/** Unix milliseconds of the most recent view. */
|
||||
viewedAt: number;
|
||||
}
|
||||
|
||||
const RECENT_VIEW_TTL = 60 * 60 * 24 * 30; // 30 days
|
||||
const RECENT_VIEW_MAX = 20;
|
||||
|
||||
function key(userId: string, portId: string): string {
|
||||
return `recent-views:${userId}:${portId}`;
|
||||
}
|
||||
|
||||
function encode(type: RecentlyViewedType, id: string): string {
|
||||
return `${type}:${id}`;
|
||||
}
|
||||
|
||||
function decode(member: string): { type: RecentlyViewedType; id: string } | null {
|
||||
const colon = member.indexOf(':');
|
||||
if (colon < 0) return null;
|
||||
return {
|
||||
type: member.slice(0, colon) as RecentlyViewedType,
|
||||
id: member.slice(colon + 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an entity view. Fire-and-forget — caller should NOT await this
|
||||
* in the hot path; the redis op is logged-and-swallowed on failure since
|
||||
* a missed view never breaks the user experience.
|
||||
*/
|
||||
export function trackView(
|
||||
userId: string,
|
||||
portId: string,
|
||||
type: RecentlyViewedType,
|
||||
id: string,
|
||||
): void {
|
||||
if (!userId || !portId || !id) return;
|
||||
|
||||
const k = key(userId, portId);
|
||||
const member = encode(type, id);
|
||||
const now = Date.now();
|
||||
|
||||
redis
|
||||
.zadd(k, now, member)
|
||||
.then(() => redis.zremrangebyrank(k, 0, -(RECENT_VIEW_MAX + 1)))
|
||||
.then(() => redis.expire(k, RECENT_VIEW_TTL))
|
||||
.catch(() => {
|
||||
// intentionally swallowed
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user's recently-viewed entities, newest first. Limit is
|
||||
* defensively capped so a misbehaving caller can't pull thousands of rows.
|
||||
*/
|
||||
export async function getRecentlyViewed(
|
||||
userId: string,
|
||||
portId: string,
|
||||
limit = 10,
|
||||
): Promise<RecentlyViewedEntry[]> {
|
||||
const k = key(userId, portId);
|
||||
const cap = Math.min(Math.max(limit, 1), RECENT_VIEW_MAX);
|
||||
|
||||
// ZREVRANGE WITHSCORES → flat array [member, score, member, score, …]
|
||||
const raw = await redis.zrevrange(k, 0, cap - 1, 'WITHSCORES');
|
||||
|
||||
const out: RecentlyViewedEntry[] = [];
|
||||
for (let i = 0; i < raw.length; i += 2) {
|
||||
const member = raw[i];
|
||||
const score = raw[i + 1];
|
||||
if (!member || !score) continue;
|
||||
const decoded = decode(member);
|
||||
if (!decoded) continue;
|
||||
out.push({ ...decoded, viewedAt: Number(score) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a single entity from a user's recent-views (e.g. after the
|
||||
* entity is hard-deleted). Best-effort.
|
||||
*/
|
||||
export function forgetView(
|
||||
userId: string,
|
||||
portId: string,
|
||||
type: RecentlyViewedType,
|
||||
id: string,
|
||||
): void {
|
||||
redis.zrem(key(userId, portId), encode(type, id)).catch(() => {});
|
||||
}
|
||||
222
src/lib/services/search-nav-catalog.ts
Normal file
222
src/lib/services/search-nav-catalog.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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<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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,56 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const BUCKET_TYPES = [
|
||||
'clients',
|
||||
'residentialClients',
|
||||
'yachts',
|
||||
'companies',
|
||||
'interests',
|
||||
'residentialInterests',
|
||||
'berths',
|
||||
'invoices',
|
||||
'expenses',
|
||||
'documents',
|
||||
'files',
|
||||
'reminders',
|
||||
'brochures',
|
||||
'tags',
|
||||
'navigation',
|
||||
] as const;
|
||||
|
||||
export const searchQuerySchema = z.object({
|
||||
// 2-char minimum keeps `to_tsquery('a:*')` from returning every word
|
||||
// starting with "a" — short queries return overwhelming match sets.
|
||||
q: z.string().min(2).max(200),
|
||||
/** Restrict the result set to a single bucket. */
|
||||
type: z.enum(BUCKET_TYPES).optional(),
|
||||
/** Per-bucket cap. Defaults to 5 (dropdown). 25 is the typical /search-page value. */
|
||||
limit: z.coerce.number().int().min(1).max(50).optional(),
|
||||
/** Super-admin only — search ports beyond the current one. */
|
||||
includeOtherPorts: z
|
||||
.union([z.literal('true'), z.literal('1'), z.literal('false'), z.literal('0')])
|
||||
.transform((v) => v === 'true' || v === '1')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type SearchQuery = z.infer<typeof searchQuerySchema>;
|
||||
|
||||
const RECENTLY_VIEWED_TYPES = [
|
||||
'client',
|
||||
'residential-client',
|
||||
'yacht',
|
||||
'company',
|
||||
'interest',
|
||||
'residential-interest',
|
||||
'berth',
|
||||
'invoice',
|
||||
'expense',
|
||||
'document',
|
||||
] as const;
|
||||
|
||||
export const trackViewSchema = z.object({
|
||||
type: z.enum(RECENTLY_VIEWED_TYPES),
|
||||
id: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
export type TrackViewPayload = z.infer<typeof trackViewSchema>;
|
||||
|
||||
Reference in New Issue
Block a user