refactor(search): ts-pattern for exhaustive type dispatch + fix missing 'notes' bucket

Phase 5 — converts the two switches in search.service.ts from `switch`
to ts-pattern's `match().with().exhaustive()`. The conversion exposed
a real bug: the single-bucket dispatch handled 15 of 16 SearchResults
buckets and silently dropped `type=notes` to the default empty-results
fall-through. `searchNotes()` has existed since the federated-notes
audit but was never wired into the runSingleBucket() dispatch. Calling
/api/v1/search?type=notes returned empty even with seeded note data.

The .exhaustive() switch now requires every SearchResults bucket. New
buckets fail the build until they get a dispatch case — same guarantee
the Documenso webhook conversion gives.

Notes:
  - labelForSource (4 trivial label cases) — converted to ts-pattern
    for visual consistency with the larger switch in the same file.
  - The 3 other switches the audit flagged (client-restore.service.ts,
    recently-viewed/route.ts, custom-fields/[entityId]/route.ts) operate
    on tagged-union internal types where TypeScript already enforces
    exhaustiveness via control-flow narrowing — converting them adds
    noise without changing safety. Documented in docs/BACKLOG.md as
    "TS-narrowing already exhaustive; deferred indefinitely."

1298/1298 vitest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 21:30:07 +02:00
parent 63220ad072
commit ba921d3865

View File

@@ -41,6 +41,7 @@
*/
import { sql } from 'drizzle-orm';
import { match } from 'ts-pattern';
import { db } from '@/lib/db';
import { redis } from '@/lib/redis';
@@ -1215,16 +1216,12 @@ async function searchNotes(portId: string, query: string, limit: number): Promis
}
function labelForSource(source: 'client' | 'interest' | 'yacht' | 'company'): string {
switch (source) {
case 'client':
return 'Client';
case 'interest':
return 'Interest';
case 'yacht':
return 'Yacht';
case 'company':
return 'Company';
}
return match(source)
.with('client', () => 'Client')
.with('interest', () => 'Interest')
.with('yacht', () => 'Yacht')
.with('company', () => 'Company')
.exhaustive();
}
// ─── Cross-port (super admin) ────────────────────────────────────────────────
@@ -2005,8 +2002,12 @@ async function runSingleBucket(
opts: SearchOptions,
): Promise<SearchResults> {
const empty = emptyResults();
switch (opts.type) {
case 'clients':
// Defensive: callers always pass a defined type here (the dispatch above
// narrows it), but the SearchOptions type leaves it optional so we
// explicitly handle undefined for the type-checker.
if (!opts.type) return empty;
return match(opts.type)
.with('clients', async () => {
if (!can(opts, 'clients.view')) return empty;
empty.clients = applyAffinity(
await searchClients(portId, query, limit),
@@ -2014,7 +2015,8 @@ async function runSingleBucket(
);
empty.totals.clients = empty.clients.length;
return empty;
case 'residentialClients':
})
.with('residentialClients', async () => {
if (!can(opts, 'residential_clients.view')) return empty;
empty.residentialClients = applyAffinity(
await searchResidentialClients(portId, query, limit),
@@ -2022,7 +2024,8 @@ async function runSingleBucket(
);
empty.totals.residentialClients = empty.residentialClients.length;
return empty;
case 'yachts':
})
.with('yachts', async () => {
if (!can(opts, 'yachts.view')) return empty;
empty.yachts = applyAffinity(
await searchYachts(portId, query, limit),
@@ -2030,7 +2033,8 @@ async function runSingleBucket(
);
empty.totals.yachts = empty.yachts.length;
return empty;
case 'companies':
})
.with('companies', async () => {
if (!can(opts, 'companies.view')) return empty;
empty.companies = applyAffinity(
await searchCompanies(portId, query, limit),
@@ -2038,7 +2042,8 @@ async function runSingleBucket(
);
empty.totals.companies = empty.companies.length;
return empty;
case 'interests':
})
.with('interests', async () => {
if (!can(opts, 'interests.view')) return empty;
empty.interests = applyAffinity(
await searchInterests(portId, query, limit),
@@ -2046,7 +2051,8 @@ async function runSingleBucket(
);
empty.totals.interests = empty.interests.length;
return empty;
case 'residentialInterests':
})
.with('residentialInterests', async () => {
if (!can(opts, 'residential_clients.view')) return empty;
empty.residentialInterests = applyAffinity(
await searchResidentialInterests(portId, query, limit),
@@ -2054,7 +2060,8 @@ async function runSingleBucket(
);
empty.totals.residentialInterests = empty.residentialInterests.length;
return empty;
case 'berths':
})
.with('berths', async () => {
if (!can(opts, 'berths.view')) return empty;
empty.berths = applyAffinity(
await searchBerths(portId, query, limit),
@@ -2062,7 +2069,8 @@ async function runSingleBucket(
);
empty.totals.berths = empty.berths.length;
return empty;
case 'invoices':
})
.with('invoices', async () => {
if (!can(opts, 'invoices.view')) return empty;
empty.invoices = applyAffinity(
await searchInvoices(portId, query, limit),
@@ -2070,7 +2078,8 @@ async function runSingleBucket(
);
empty.totals.invoices = empty.invoices.length;
return empty;
case 'expenses':
})
.with('expenses', async () => {
if (!can(opts, 'expenses.view')) return empty;
empty.expenses = applyAffinity(
await searchExpenses(portId, query, limit),
@@ -2078,7 +2087,8 @@ async function runSingleBucket(
);
empty.totals.expenses = empty.expenses.length;
return empty;
case 'documents':
})
.with('documents', async () => {
if (!can(opts, 'documents.view')) return empty;
empty.documents = applyAffinity(
await searchDocuments(portId, query, limit),
@@ -2086,28 +2096,33 @@ async function runSingleBucket(
);
empty.totals.documents = empty.documents.length;
return empty;
case 'files':
})
.with('files', async () => {
if (!can(opts, 'files.view') && !can(opts, 'documents.view')) return empty;
empty.files = applyAffinity(await searchFiles(portId, query, limit), opts.recentlyTouchedIds);
empty.totals.files = empty.files.length;
return empty;
case 'reminders':
})
.with('reminders', async () => {
empty.reminders = applyAffinity(
await searchReminders(portId, query, limit),
opts.recentlyTouchedIds,
);
empty.totals.reminders = empty.reminders.length;
return empty;
case 'brochures':
})
.with('brochures', async () => {
if (!can(opts, 'admin.manage_settings')) return empty;
empty.brochures = await searchBrochures(portId, query, limit);
empty.totals.brochures = empty.brochures.length;
return empty;
case 'tags':
})
.with('tags', async () => {
empty.tags = await searchTags(portId, query, limit);
empty.totals.tags = empty.tags.length;
return empty;
case 'navigation': {
})
.with('navigation', async () => {
const { searchNavCatalog } = await import('@/lib/services/search-nav-catalog');
empty.navigation = searchNavCatalog(query, {
isSuperAdmin: opts.isSuperAdmin,
@@ -2116,10 +2131,16 @@ async function runSingleBucket(
}).map((e) => ({ id: e.href, href: e.href, label: e.label, category: e.category }));
empty.totals.navigation = empty.navigation.length;
return empty;
}
default:
})
.with('notes', async () => {
// Previously this case silently fell through to the default (which
// returned empty). The exhaustive-match conversion surfaced the
// missing dispatch: searchNotes() already exists, so wire it up.
empty.notes = applyAffinity(await searchNotes(portId, query, limit), opts.recentlyTouchedIds);
empty.totals.notes = empty.notes.length;
return empty;
}
})
.exhaustive();
}
function emptyResults(): SearchResults {