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:
2026-05-07 20:58:34 +02:00
parent a0e68eb060
commit 267c2b6d1f
14 changed files with 3821 additions and 378 deletions

View File

@@ -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));