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:
@@ -1,12 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { search } from '@/lib/services/search.service';
|
||||
import { search, buildPrefixTsquery, normalizePhoneQuery } from '@/lib/services/search.service';
|
||||
import { db } from '@/lib/db';
|
||||
import { yachts, companies } from '@/lib/db/schema';
|
||||
|
||||
import { makePort, makeClient, makeYacht, makeCompany } from '../../helpers/factories';
|
||||
|
||||
// Default opts — super admin so every bucket runs without per-resource
|
||||
// permission gating getting in the way of the assertions.
|
||||
const ADMIN_OPTS = { permissions: null, isSuperAdmin: true } as const;
|
||||
|
||||
// ─── Yachts ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('search.service — yachts', () => {
|
||||
@@ -26,7 +30,7 @@ describe('search.service — yachts', () => {
|
||||
name: 'Wind Dancer',
|
||||
});
|
||||
|
||||
const results = await search(port.id, 'BREEZE');
|
||||
const results = await search(port.id, 'BREEZE', ADMIN_OPTS);
|
||||
expect(results.yachts.some((y) => y.name === 'Sea Breeze')).toBe(true);
|
||||
expect(results.yachts.some((y) => y.name === 'Wind Dancer')).toBe(false);
|
||||
});
|
||||
@@ -42,7 +46,7 @@ describe('search.service — yachts', () => {
|
||||
hullNumber: 'HULL-XYZ-999',
|
||||
});
|
||||
|
||||
const results = await search(port.id, 'hull-xyz');
|
||||
const results = await search(port.id, 'hull-xyz', ADMIN_OPTS);
|
||||
expect(results.yachts.some((y) => y.hullNumber === 'HULL-XYZ-999')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -57,7 +61,7 @@ describe('search.service — yachts', () => {
|
||||
registration: 'REG-ABC-123',
|
||||
});
|
||||
|
||||
const results = await search(port.id, 'reg-abc');
|
||||
const results = await search(port.id, 'reg-abc', ADMIN_OPTS);
|
||||
expect(results.yachts.some((y) => y.registration === 'REG-ABC-123')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -72,7 +76,7 @@ describe('search.service — yachts', () => {
|
||||
});
|
||||
await db.update(yachts).set({ archivedAt: new Date() }).where(eq(yachts.id, yacht.id));
|
||||
|
||||
const results = await search(port.id, 'ghost ship');
|
||||
const results = await search(port.id, 'ghost ship', ADMIN_OPTS);
|
||||
expect(results.yachts.some((y) => y.id === yacht.id)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -94,7 +98,7 @@ describe('search.service — yachts', () => {
|
||||
name: 'UniqueYachtNameB',
|
||||
});
|
||||
|
||||
const resultsA = await search(portA.id, 'UniqueYachtName');
|
||||
const resultsA = await search(portA.id, 'UniqueYachtName', ADMIN_OPTS);
|
||||
expect(resultsA.yachts.some((y) => y.name === 'UniqueYachtNameA')).toBe(true);
|
||||
expect(resultsA.yachts.some((y) => y.name === 'UniqueYachtNameB')).toBe(false);
|
||||
});
|
||||
@@ -108,7 +112,7 @@ describe('search.service — companies', () => {
|
||||
await makeCompany({ portId: port.id, overrides: { name: 'Poseidon Maritime Ltd' } });
|
||||
await makeCompany({ portId: port.id, overrides: { name: 'Neptune Holdings' } });
|
||||
|
||||
const results = await search(port.id, 'poseidon');
|
||||
const results = await search(port.id, 'poseidon', ADMIN_OPTS);
|
||||
expect(results.companies.some((c) => c.name === 'Poseidon Maritime Ltd')).toBe(true);
|
||||
expect(results.companies.some((c) => c.name === 'Neptune Holdings')).toBe(false);
|
||||
});
|
||||
@@ -120,7 +124,7 @@ describe('search.service — companies', () => {
|
||||
overrides: { name: 'AcmeShort', legalName: 'Acme Legal Holdings Inc.' },
|
||||
});
|
||||
|
||||
const results = await search(port.id, 'Acme Legal');
|
||||
const results = await search(port.id, 'Acme Legal', ADMIN_OPTS);
|
||||
expect(results.companies.some((c) => c.legalName === 'Acme Legal Holdings Inc.')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -131,7 +135,7 @@ describe('search.service — companies', () => {
|
||||
overrides: { name: 'TaxyCo', taxId: 'VAT-112233445' },
|
||||
});
|
||||
|
||||
const results = await search(port.id, 'vat-112233');
|
||||
const results = await search(port.id, 'vat-112233', ADMIN_OPTS);
|
||||
expect(results.companies.some((c) => c.taxId === 'VAT-112233445')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -143,7 +147,7 @@ describe('search.service — companies', () => {
|
||||
});
|
||||
await db.update(companies).set({ archivedAt: new Date() }).where(eq(companies.id, company.id));
|
||||
|
||||
const results = await search(port.id, 'ArchivedCompanyXyz');
|
||||
const results = await search(port.id, 'ArchivedCompanyXyz', ADMIN_OPTS);
|
||||
expect(results.companies.some((c) => c.id === company.id)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -153,7 +157,7 @@ describe('search.service — companies', () => {
|
||||
await makeCompany({ portId: portA.id, overrides: { name: 'UniqueCompanyA' } });
|
||||
await makeCompany({ portId: portB.id, overrides: { name: 'UniqueCompanyB' } });
|
||||
|
||||
const resultsA = await search(portA.id, 'UniqueCompany');
|
||||
const resultsA = await search(portA.id, 'UniqueCompany', ADMIN_OPTS);
|
||||
expect(resultsA.companies.some((c) => c.name === 'UniqueCompanyA')).toBe(true);
|
||||
expect(resultsA.companies.some((c) => c.name === 'UniqueCompanyB')).toBe(false);
|
||||
});
|
||||
@@ -176,21 +180,96 @@ describe('search.service — combined', () => {
|
||||
});
|
||||
await makeCompany({ portId: port.id, overrides: { name: 'Alpha Holdings' } });
|
||||
|
||||
const results = await search(port.id, 'alpha');
|
||||
const results = await search(port.id, 'alpha', ADMIN_OPTS);
|
||||
expect(results.clients.some((c) => c.fullName === 'Alpha Person')).toBe(true);
|
||||
expect(results.yachts.some((y) => y.name === 'Alpha Yacht')).toBe(true);
|
||||
expect(results.companies.some((c) => c.name === 'Alpha Holdings')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns all result keys even when no matches exist', async () => {
|
||||
it('returns every bucket as an empty array when no matches exist', async () => {
|
||||
const port = await makePort();
|
||||
const results = await search(port.id, 'zzz-nothing-matches-zzz');
|
||||
expect(results).toEqual({
|
||||
clients: [],
|
||||
interests: [],
|
||||
berths: [],
|
||||
yachts: [],
|
||||
companies: [],
|
||||
});
|
||||
const results = await search(port.id, 'zzz-nothing-matches-zzz', ADMIN_OPTS);
|
||||
// Shape check (no rows in any bucket)
|
||||
expect(results.clients).toEqual([]);
|
||||
expect(results.residentialClients).toEqual([]);
|
||||
expect(results.yachts).toEqual([]);
|
||||
expect(results.companies).toEqual([]);
|
||||
expect(results.interests).toEqual([]);
|
||||
expect(results.residentialInterests).toEqual([]);
|
||||
expect(results.berths).toEqual([]);
|
||||
expect(results.invoices).toEqual([]);
|
||||
expect(results.expenses).toEqual([]);
|
||||
expect(results.documents).toEqual([]);
|
||||
expect(results.files).toEqual([]);
|
||||
expect(results.reminders).toEqual([]);
|
||||
expect(results.brochures).toEqual([]);
|
||||
expect(results.tags).toEqual([]);
|
||||
expect(results.navigation).toEqual([]);
|
||||
// Totals are present and zero across the board.
|
||||
expect(Object.values(results.totals).every((n) => n === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Helper purity tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe('buildPrefixTsquery', () => {
|
||||
it('builds a tokenized prefix tsquery', () => {
|
||||
expect(buildPrefixTsquery('joh smi')).toBe('joh:* & smi:*');
|
||||
});
|
||||
it('strips regex / tsquery meta-characters', () => {
|
||||
expect(buildPrefixTsquery('a&b!c')).toBe('abc:*');
|
||||
});
|
||||
it('returns null for empty / whitespace-only input', () => {
|
||||
expect(buildPrefixTsquery('')).toBeNull();
|
||||
expect(buildPrefixTsquery(' ')).toBeNull();
|
||||
});
|
||||
it('returns null when every token reduces to nothing after sanitization', () => {
|
||||
expect(buildPrefixTsquery('!@#$ %^&*')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePhoneQuery', () => {
|
||||
it('strips non-digit / non-plus characters', () => {
|
||||
expect(normalizePhoneQuery('+44 7700 900 123')).toBe('+447700900123');
|
||||
});
|
||||
it('preserves leading +', () => {
|
||||
expect(normalizePhoneQuery('+1 (555) 123-4567')).toBe('+15551234567');
|
||||
});
|
||||
it('returns null when fewer than 3 digits remain (too short to be unique)', () => {
|
||||
expect(normalizePhoneQuery('a-b')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Partial name matching ───────────────────────────────────────────────────
|
||||
|
||||
describe('search.service — partial name matching', () => {
|
||||
it('matches "joh smi" against "John Smith" via tokenized prefix tsquery', async () => {
|
||||
const port = await makePort();
|
||||
await makeClient({ portId: port.id, overrides: { fullName: 'John Smith' } });
|
||||
await makeClient({ portId: port.id, overrides: { fullName: 'Joanna Smithers' } });
|
||||
await makeClient({ portId: port.id, overrides: { fullName: 'Bob Jones' } });
|
||||
|
||||
const results = await search(port.id, 'joh smi', ADMIN_OPTS);
|
||||
expect(results.clients.some((c) => c.fullName === 'John Smith')).toBe(true);
|
||||
expect(results.clients.some((c) => c.fullName === 'Bob Jones')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches a single name fragment ("joh" → "John …")', async () => {
|
||||
const port = await makePort();
|
||||
await makeClient({ portId: port.id, overrides: { fullName: 'Johnathan Doe' } });
|
||||
|
||||
const results = await search(port.id, 'joh', ADMIN_OPTS);
|
||||
expect(results.clients.some((c) => c.fullName === 'Johnathan Doe')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search.service — bucket totals', () => {
|
||||
it('emits per-bucket totals so the UI can render "show more" links', async () => {
|
||||
const port = await makePort();
|
||||
await makeClient({ portId: port.id, overrides: { fullName: 'TotalsCheck One' } });
|
||||
|
||||
const results = await search(port.id, 'TotalsCheck', ADMIN_OPTS);
|
||||
expect(results.totals.clients).toBeGreaterThanOrEqual(1);
|
||||
expect(typeof results.totals.invoices).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user