216 lines
7.5 KiB
TypeScript
216 lines
7.5 KiB
TypeScript
/**
|
|
* 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<string, unknown>, 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<string, unknown> & { 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<string, unknown> & { 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<string, unknown> & { 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<void> {
|
|
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<void> {
|
|
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"
|
|
);
|
|
}
|
|
}
|