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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user