/** * Outbox-driven Meilisearch indexing for the rows-engine. * * `RowsApi.create / update / del` invokes one of `emitRowSearchUpsert` / * `emitRowSearchDelete` AFTER the canonical DB write so the * `search.index.*.upsert | .delete` outbox event is published as a * post-write projection update. The outbox event-router (#48) maps * `search.index.*` to the `outbox.search-index` BullMQ queue where the * search-indexer worker (apps/worker/src/jobs/search-indexer.ts) picks * the event up and upserts/deletes the Meilisearch document. * * CRM contacts are stored as rows with `entityName = "contact"` — * routing here therefore covers both the CRM-contacts and the generic * Rows hot-paths in a single producer location. * * Resilience: outbox publish failures are caught and logged rather * than re-thrown. A row CUD must NOT roll back because the downstream * indexing event could not be queued — the failure is observable in * logs and can be corrected with a targeted reindex. Moving these emits * into the surrounding row mutation transactions is a separate hardening * slice. */ import { crmContactsIndex, publishSearchDeleteEvent, publishSearchUpsertEvent, rowsIndex, type SearchIndexSchema, } from "@toolctrl/search"; import type { Prisma, Row } from "@toolctrl/db/prisma-browser"; import { publishOutboxEvent, type OutboxPublisherTransaction } from "@toolctrl/db/outbox"; import { db } from "~/utils/db.server"; import RowValueHelper from "~/utils/helpers/RowValueHelper"; import { PropertyType } from "~/application/enums/entities/PropertyType"; import type { EntityWithDetails } from "~/utils/db/entities/entities.db.server"; import type { RowValueWithDetails } from "~/utils/db/entities/rows.db.server"; /** * Minimum shape the search-outbox emitters need from a row. Both * `RowWithDetails` (apps/web) and `LegacyRowWithDetails` (@toolctrl/db) * conform to it — we widen here so the helper can be called from either * code-path without re-shaping the row. */ export type RowForSearch = Row & { values: RowValueWithDetails[]; }; export interface RowSearchLogger { warn?: (record: Record, message?: string) => void; } export interface EmitRowSearchUpsertInput { tenantId: string | null | undefined; entity: EntityWithDetails; row: RowForSearch; logger?: RowSearchLogger; } export interface EmitRowSearchDeleteInput { tenantId: string | null | undefined; entity: { name: string }; rowId: string; logger?: RowSearchLogger; } /** * Returns the search-index schemas that should receive a search event * for the given entity. `contact` rows publish to BOTH `crm-contacts` * (typed schema with structured fields) and `rows` (generic searchable * blob) so either route can serve the contact list. All other entities * publish only to `rows`. */ function indexSchemasForEntity(entityName: string): readonly SearchIndexSchema[] { if (entityName === "contact") return [crmContactsIndex, rowsIndex]; return [rowsIndex]; } function buildSearchableBlob(entity: EntityWithDetails, row: RowForSearch): string { const parts: string[] = []; for (const property of entity.properties) { if (property.type !== PropertyType.TEXT && property.type !== PropertyType.SELECT) continue; try { const text = RowValueHelper.getText({ entity, row, name: property.name }); if (typeof text === "string" && text.trim().length > 0) parts.push(text); } catch { // ignore — defensive, the row may not carry every property value } } return parts.join(" \n "); } function rowToContactDocument( entity: EntityWithDetails, row: RowForSearch, tenantId: string ): Record & { id: string; tenantId: string } { const get = (name: string): string => { try { return RowValueHelper.getText({ entity, row, name }) ?? ""; } catch { return ""; } }; return { id: row.id, tenantId, firstName: get("firstName"), lastName: get("lastName"), email: get("email"), company: get("company"), jobTitle: get("jobTitle"), status: get("status"), ownerId: row.createdByUserId ?? "", createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt), updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt), }; } function rowToRowsDocument( entity: EntityWithDetails, row: RowForSearch, tenantId: string ): Record & { id: string; tenantId: string } { return { id: row.id, tenantId, entityId: row.entityId, entityName: entity.name, folio: typeof row.folio === "number" ? row.folio : 0, searchable: buildSearchableBlob(entity, row), createdById: row.createdByUserId ?? "", createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt), updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt), }; } function buildDocumentForSchema( schema: SearchIndexSchema, entity: EntityWithDetails, row: RowForSearch, tenantId: string ): Record & { id: string; tenantId: string } { if (schema.name === "crm-contacts") return rowToContactDocument(entity, row, tenantId); return rowToRowsDocument(entity, row, tenantId); } export async function emitRowSearchUpsert(input: EmitRowSearchUpsertInput): Promise { if (!input.tenantId) return; // non-tenant rows are not indexed const schemas = indexSchemasForEntity(input.entity.name); try { await db.$transaction(async (tx: unknown) => { for (const schema of schemas) { const document = buildDocumentForSchema(schema, input.entity, input.row, input.tenantId!); await publishSearchUpsertEvent({ publish: async (event) => publishOutboxEvent(tx as OutboxPublisherTransaction, { tenantId: event.tenantId, type: event.type, payload: event.payload as Prisma.InputJsonValue, dispatchKey: event.dispatchKey ?? undefined, }), schema, tenantId: input.tenantId!, entityName: schema.name === "crm-contacts" ? "crmcontact" : "row", document, }); } }); } catch (error) { input.logger?.warn?.( { component: "row-search-outbox", rowId: input.row.id, entityName: input.entity.name, error: error instanceof Error ? error.message : "unknown", }, "row search upsert outbox publish failed" ); } } export async function emitRowSearchDelete(input: EmitRowSearchDeleteInput): Promise { if (!input.tenantId) return; const schemas = indexSchemasForEntity(input.entity.name); try { await db.$transaction(async (tx: unknown) => { for (const schema of schemas) { await publishSearchDeleteEvent({ publish: async (event) => publishOutboxEvent(tx as OutboxPublisherTransaction, { tenantId: event.tenantId, type: event.type, payload: event.payload as Prisma.InputJsonValue, dispatchKey: event.dispatchKey ?? undefined, }), schema, tenantId: input.tenantId!, entityName: schema.name === "crm-contacts" ? "crmcontact" : "row", documentId: input.rowId, }); } }); } catch (error) { input.logger?.warn?.( { component: "row-search-outbox", rowId: input.rowId, entityName: input.entity.name, error: error instanceof Error ? error.message : "unknown", }, "row search delete outbox publish failed" ); } }