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

164 lines
4.9 KiB
TypeScript

/**
* CRM-contacts public search — hot-path migration to Meilisearch.
*
* Wraps `searchWithFallback({ index: 'crm-contacts', tenantId, query,
* fallback })` so consumers of the contact list / contact filter can
* adopt the typed client without further refactors. When MEILI_URL is
* unset, or SEARCH_BACKEND=sql is forced, or Meilisearch raises a
* connectivity error, the helper transparently falls back to a
* SQL ILIKE-against-RowValue scan over the contact entity.
*
* Contacts are stored as rows with `entityName = "contact"`; the SQL
* fallback therefore reuses the legacy row-search code-path so the
* shape returned from both backends is the same set of `Row` IDs that
* the caller can hydrate via the existing rows.db.server helpers.
*/
import {
createSearchClient,
crmContactsIndex,
searchWithFallback,
type SearchClient,
type SearchFallback,
type SearchHits,
} from "@toolctrl/search";
import { db } from "~/utils/db.server";
let cachedClient: SearchClient | undefined;
let cachedUrl: string | undefined;
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 SearchCrmContactsInput {
/** Tenant scope — REQUIRED for the Meilisearch path. The SQL
* fallback also scopes by tenantId. */
readonly tenantId: string;
/** User-supplied query string. Empty string returns the first page. */
readonly query: string;
/** Optional cap on result count. Defaults to 50. */
readonly limit?: number;
}
export interface CrmContactSearchHit {
readonly id: string;
readonly firstName: string;
readonly lastName: string;
readonly email: string;
readonly company: string;
readonly jobTitle: string;
}
export interface CrmContactSearchResult {
readonly hits: readonly CrmContactSearchHit[];
readonly total: number;
readonly fellBack: boolean;
}
/**
* SQL fallback — uses RowValue text-search joined to the `contact`
* entity. Returns the same hit-shape Meilisearch returns so the caller
* doesn't have to branch.
*/
async function sqlFallback(input: SearchCrmContactsInput): Promise<SearchHits> {
const limit = input.limit ?? 50;
const q = input.query.trim();
const where: Record<string, unknown> = {
tenantId: input.tenantId,
deletedAt: null,
entity: { name: "contact" },
};
if (q.length > 0) {
where.values = {
some: {
textValue: { contains: q, mode: "insensitive" },
},
};
}
const rows = await db.row.findMany({
where: where as never,
take: limit,
orderBy: [{ createdAt: "desc" }],
select: {
id: true,
values: {
select: {
textValue: true,
property: { select: { name: true } },
},
},
},
});
return {
hits: rows.map((row) => {
const get = (name: string): string => {
const v = row.values.find((vv) => vv.property?.name === name);
return v?.textValue ?? "";
};
return {
document: {
id: row.id,
tenantId: input.tenantId,
firstName: get("firstName"),
lastName: get("lastName"),
email: get("email"),
company: get("company"),
jobTitle: get("jobTitle"),
} as Record<string, unknown> & { id: string; tenantId: string },
};
}),
estimatedTotalHits: rows.length,
processingTimeMs: 0,
};
}
export async function searchCrmContacts(input: SearchCrmContactsInput): Promise<CrmContactSearchResult> {
const fallback: SearchFallback = async () => sqlFallback(input);
const client = getSearchClient();
if (!client) {
const sql = await sqlFallback(input);
return {
hits: sql.hits.map((hit) => toContactHit(hit.document)),
total: sql.estimatedTotalHits ?? sql.hits.length,
fellBack: true,
};
}
let fellBack = false;
const hits = await searchWithFallback({
client,
schema: crmContactsIndex,
tenantId: input.tenantId,
query: input.query,
fallback,
onFallback: () => {
fellBack = true;
},
options: { limit: input.limit ?? 50 },
});
return {
hits: hits.hits.map((hit) => toContactHit(hit.document)),
total: hits.estimatedTotalHits ?? hits.hits.length,
fellBack,
};
}
function toContactHit(document: Record<string, unknown>): CrmContactSearchHit {
return {
id: String(document.id ?? ""),
firstName: typeof document.firstName === "string" ? document.firstName : "",
lastName: typeof document.lastName === "string" ? document.lastName : "",
email: typeof document.email === "string" ? document.email : "",
company: typeof document.company === "string" ? document.company : "",
jobTitle: typeof document.jobTitle === "string" ? document.jobTitle : "",
};
}