111 lines
4.0 KiB
TypeScript
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);
|
|
}
|