/** * KB-article public search — hot-path migration to Meilisearch. * * The function exposes the same return shape as the legacy * `findKnowledgeBaseArticles` SQL implementation so the consuming * KnowledgeBaseService.search code path can adopt it without further * refactors. When MEILI_URL is unset (local dev that did not enable * Meilisearch), or SEARCH_BACKEND=sql is forced, we transparently fall * back to the legacy LIKE-based query. The same SQL path also engages * automatically when Meilisearch raises a connectivity error — that is * what `searchWithFallback` is for. */ import { createSearchClient, kbArticlesIndex, searchWithFallback, type SearchClient, type SearchFallback, } from "@toolctrl/search"; import { findKnowledgeBaseArticlesByIds, findKnowledgeBaseArticles, type KnowledgeBaseArticleWithDetails, } from "../db/kbArticles.db.server"; let cachedClient: SearchClient | undefined; let cachedUrl: string | undefined; // Escape values interpolated into a Meilisearch filter expression so a crafted // `language` (sourced from the optional `($lang)` URL segment) cannot break out // of the quoted string and inject filter operators. Mirrors rowSearch.server.ts. function escapeFilterValue(value: string): string { return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"'); } function getSearchClient(): SearchClient | undefined { const url = process.env.MEILI_URL; if (!url || process.env.SEARCH_BACKEND === "sql") return undefined; if (cachedClient && cachedUrl === url) return cachedClient; cachedClient = createSearchClient({ url, apiKey: process.env.MEILI_API_KEY ?? undefined }); cachedUrl = url; return cachedClient; } export interface SearchKnowledgeBaseArticlesInput { readonly knowledgeBaseSlug: string; readonly language: string | undefined; readonly query: string; /** * Tenant scope. Required when the Meilisearch path is engaged; the SQL * fallback ignores it because the (knowledgeBaseSlug, language) filter * already isolates results within a KB. */ readonly tenantId: string | undefined; } export async function searchKnowledgeBaseArticles( input: SearchKnowledgeBaseArticlesInput ): Promise { const sqlFallback: SearchFallback = async () => { const rows = await findKnowledgeBaseArticles({ knowledgeBaseSlug: input.knowledgeBaseSlug, language: input.language, query: input.query, }); return { hits: rows.map((row) => ({ document: { ...row, id: row.id, tenantId: input.tenantId ?? "" } as Record & { id: string | number } })), estimatedTotalHits: rows.length, processingTimeMs: 0, }; }; const client = getSearchClient(); if (!client || !input.tenantId) { // No Meilisearch backend configured or no tenantId in scope — // the SQL path is the authoritative source. const rows = await findKnowledgeBaseArticles({ knowledgeBaseSlug: input.knowledgeBaseSlug, language: input.language, query: input.query, }); return rows; } const hits = await searchWithFallback({ client, schema: kbArticlesIndex, tenantId: input.tenantId, query: input.query, fallback: sqlFallback, options: input.language ? { extraFilters: `language = "${escapeFilterValue(input.language)}" AND isPublished = true` } : { extraFilters: "isPublished = true" }, }); // The Meilisearch hit-stream doesn't carry the full ORM relation graph // the route expects (category/section/related-articles); look the // resolved IDs back up against the SQL store so the consumer keeps // its existing typed return shape. const ids = hits.hits.map((hit) => String(hit.document.id)); if (ids.length === 0) return []; const rows = await findKnowledgeBaseArticlesByIds({ knowledgeBaseSlug: input.knowledgeBaseSlug, language: input.language, ids, }); const byId = new Map(rows.map((row) => [row.id, row])); return ids.map((id) => byId.get(id)).filter((row): row is KnowledgeBaseArticleWithDetails => row !== undefined); }