import 'server-only'; import { run, sql, safe } from '@/lib/db'; import { normalizeLabel } from '@cancerindex/shared'; import type { SearchHit } from '@/components/layout/command-palette'; import { matchFromTier, sortHits } from '@/lib/search-util'; /** * Entity search (§294, §312). Ordering: exact canonical name > exact alias/identifier > prefix > * trigram similarity. Each entity type is queried separately, then merged by tier and score. */ export async function searchEntities(qRaw: string, limit = 20): Promise { const q = qRaw.trim(); if (q.length < 2) return []; const norm = normalizeLabel(q); const lower = q.toLowerCase(); const prefix = `${lower}%`; const upper = q.toUpperCase(); type Row = { type: SearchHit['type']; id: string; title: string; subtitle: string | null; href: string; tier: number; score: number }; const rows = await safe( () => run(sql` WITH cand AS ( -- cancers: name / alias / codes SELECT 'cancer'::text AS type, c.id, c.canonical_name AS title, c.entity_type || coalesce(' · ' || c.primary_oncotree_code, '') AS subtitle, '/cancer/' || c.slug AS href, CASE WHEN lower(c.canonical_name) = ${lower} THEN 0 WHEN EXISTS (SELECT 1 FROM cancer_aliases a WHERE a.cancer_id = c.id AND a.normalized = ${norm}) THEN 1 WHEN EXISTS (SELECT 1 FROM cancer_codes k WHERE k.cancer_id = c.id AND upper(k.code) = ${upper}) OR c.id = ${upper} THEN 1 WHEN lower(c.canonical_name) LIKE ${prefix} THEN 2 WHEN EXISTS (SELECT 1 FROM cancer_aliases a WHERE a.cancer_id = c.id AND a.normalized LIKE ${norm + '%'}) THEN 2 ELSE 3 END AS tier, greatest(similarity(c.canonical_name, ${q}), (SELECT coalesce(max(similarity(a.alias, ${q})), 0) FROM cancer_aliases a WHERE a.cancer_id = c.id)) AS score FROM cancers c WHERE c.status = 'active' AND ( c.canonical_name ILIKE ${'%' + q + '%'} OR c.canonical_name % ${q} OR c.id = ${upper} OR EXISTS (SELECT 1 FROM cancer_aliases a WHERE a.cancer_id = c.id AND (a.normalized LIKE ${norm + '%'} OR a.alias % ${q})) OR EXISTS (SELECT 1 FROM cancer_codes k WHERE k.cancer_id = c.id AND upper(k.code) = ${upper})) UNION ALL SELECT 'gene', g.id, g.symbol, g.name, '/gene/' || g.symbol, CASE WHEN upper(g.symbol) = ${upper} THEN 0 WHEN ${upper} = ANY(g.alias_symbols) OR ${upper} = ANY(g.prev_symbols) OR g.hgnc_id = ${upper} THEN 1 WHEN upper(g.symbol) LIKE ${upper + '%'} THEN 2 ELSE 3 END, similarity(g.symbol, ${q}) FROM genes g WHERE upper(g.symbol) LIKE ${upper + '%'} OR ${upper} = ANY(g.alias_symbols) OR ${upper} = ANY(g.prev_symbols) OR g.hgnc_id = ${upper} OR g.name ILIKE ${'%' + q + '%'} UNION ALL SELECT 'variant', v.id, coalesce(v.gene_symbol || ' ', '') || v.name, v.variant_type, '/variant/' || v.slug, CASE WHEN lower(coalesce(v.gene_symbol || ' ', '') || v.name) = ${lower} OR lower(v.name) = ${lower} THEN 0 WHEN v.hgvs_p = ${q} OR v.hgvs_c = ${q} OR v.clinvar_variation_id = ${q} OR ${q} = ANY(v.dbsnp_ids) THEN 1 WHEN lower(coalesce(v.gene_symbol || ' ', '') || v.name) LIKE ${prefix} THEN 2 ELSE 3 END, similarity(coalesce(v.gene_symbol || ' ', '') || v.name, ${q}) FROM variants v WHERE (coalesce(v.gene_symbol || ' ', '') || v.name) ILIKE ${'%' + q + '%'} OR v.hgvs_p = ${q} OR v.hgvs_c = ${q} OR v.clinvar_variation_id = ${q} OR ${q} = ANY(v.dbsnp_ids) UNION ALL SELECT 'drug', d.id, d.name, d.kind, '/drug/' || d.slug, CASE WHEN lower(d.name) = ${lower} THEN 0 WHEN EXISTS (SELECT 1 FROM drug_aliases a WHERE a.drug_id = d.id AND a.normalized = ${norm}) THEN 1 WHEN lower(d.name) LIKE ${prefix} THEN 2 ELSE 3 END, greatest(similarity(d.name, ${q}), (SELECT coalesce(max(similarity(a.alias, ${q})), 0) FROM drug_aliases a WHERE a.drug_id = d.id)) FROM drugs d WHERE d.name ILIKE ${'%' + q + '%'} OR d.name % ${q} OR EXISTS (SELECT 1 FROM drug_aliases a WHERE a.drug_id = d.id AND (a.normalized LIKE ${norm + '%'} OR a.alias % ${q})) UNION ALL SELECT 'trial', t.id, t.nct_id || ' — ' || t.brief_title, t.overall_status, '/trial/' || t.nct_id, CASE WHEN upper(t.nct_id) = ${upper} THEN 0 WHEN upper(t.acronym) = ${upper} THEN 1 WHEN upper(t.nct_id) LIKE ${upper + '%'} THEN 2 ELSE 3 END, similarity(t.brief_title, ${q}) FROM clinical_trials t WHERE upper(t.nct_id) LIKE ${upper + '%'} OR upper(t.acronym) = ${upper} OR (length(${q}) >= 4 AND t.brief_title ILIKE ${'%' + q + '%'}) UNION ALL SELECT 'publication', p.id, p.title, coalesce(p.journal_iso, p.journal) || coalesce(' · ' || p.pub_year::text, ''), '/publication/' || p.pmid, CASE WHEN p.pmid = ${q} OR lower(p.doi) = ${lower} THEN 0 ELSE 3 END, similarity(p.title, ${q}) FROM publications p WHERE p.pmid IS NOT NULL AND (p.pmid = ${q} OR lower(p.doi) = ${lower} OR (length(${q}) >= 5 AND p.title ILIKE ${'%' + q + '%'})) UNION ALL SELECT 'source', s.id, s.name, s.category, '/source/' || s.slug, CASE WHEN lower(s.name) = ${lower} OR s.slug = ${lower} THEN 0 WHEN lower(s.name) LIKE ${prefix} THEN 2 ELSE 3 END, similarity(s.name, ${q}) FROM sources s WHERE s.name ILIKE ${'%' + q + '%'} OR s.slug ILIKE ${'%' + lower + '%'} ) SELECT * FROM cand ORDER BY tier, score DESC, title LIMIT ${limit}`), [] as Row[], ); // The SQL ORDER BY already applies the tier/score/title order; sortHits re-applies the same pure // comparator so the ordering contract is one function (unit-tested) rather than two copies. return sortHits(rows.map((r) => ({ ...r, score: Number(r.score) }))).map((r) => ({ type: r.type, id: r.id, title: r.title, subtitle: r.subtitle, href: r.href, match: matchFromTier(r.tier, r.type) })); }