feat(search): Meilisearch message search — indexer + permission-scoped query

New search module: a message index (Meilisearch, MEILI_URL-gated; no-op when unset),
a SearchProjector that indexes on message.sent, and a SearchService whose permission
fence runs server-side — a query only touches the caller's own scope AND the threads
they currently belong to (resolved live from participant rows, so membership changes
reflect immediately). Every conversation kind (chat/mail/sms) is a message with a TEXT
part, so all are searchable through one index. POST /v1/search + /reindex (backfill).
5 fence tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:57:52 +05:30
parent 23c43acf8f
commit 8af25def83
10 changed files with 386 additions and 0 deletions
@@ -0,0 +1,39 @@
/** One indexed message document. `at` is epoch-ms so Meilisearch can sort recency-first. */
export interface SearchDoc {
id: string; // interactionId
scopeId: string;
threadId: string;
text: string;
subject: string | null;
source: string | null; // opaque app source tag (e.g. crm-messenger / crm-mail) — drives the surface
actorId: string | null;
at: number;
}
/** A search hit with a highlighted snippet. */
export interface SearchHit {
id: string;
threadId: string;
text: string;
subject: string | null;
source: string | null;
at: number;
/** The match with `<em>…</em>` around the query terms (from the engine's highlighter). */
snippet: string;
}
/**
* The message-search seam. IIOS owns egress-style search: a message index + a permission-scoped
* query. The concrete engine (Meilisearch) sits behind this; an unconfigured deployment binds a
* no-op so search degrades to empty rather than erroring.
*/
export interface MessageSearchPort {
/** True only when a real engine is configured (else index/search are inert). */
ready(): boolean;
index(docs: SearchDoc[]): Promise<void>;
remove(ids: string[]): Promise<void>;
/** Search WITHIN the given scope and thread set only (the permission fence — enforced here). */
search(scopeId: string, threadIds: string[], query: string, limit: number): Promise<SearchHit[]>;
}
export const MESSAGE_SEARCH_PORT = Symbol('MESSAGE_SEARCH_PORT');