Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/knowledgeBase/services/kbArticleSearch.server.ts
T

111 lines
4.0 KiB
TypeScript

/**
* 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<KnowledgeBaseArticleWithDetails[]> {
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<string, unknown> & { 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);
}