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,101 @@
import { Logger } from '@nestjs/common';
import { MeiliSearch } from 'meilisearch';
import type { MessageSearchPort, SearchDoc, SearchHit } from './message-search.port';
const INDEX = 'iios_messages';
/** A no-op search port — bound when Meilisearch isn't configured, so search degrades to empty. */
export const noopMessageSearch: MessageSearchPort = {
ready: () => false,
index: async () => undefined,
remove: async () => undefined,
search: async () => [],
};
/**
* Meilisearch-backed message search. Configured via MEILI_URL (+ optional MEILI_KEY). The index is
* created + configured on first use (searchable text/subject; filterable scopeId/threadId/source;
* sortable at). Filtering by scopeId AND threadId is the permission fence — the caller only ever
* passes thread ids they belong to.
*/
export class MeiliMessageSearch implements MessageSearchPort {
private readonly client: MeiliSearch;
private readonly logger = new Logger(MeiliMessageSearch.name);
private settingsReady?: Promise<void>;
constructor(host: string, apiKey?: string) {
this.client = new MeiliSearch({ host, ...(apiKey ? { apiKey } : {}) });
}
ready(): boolean {
return true;
}
/** Idempotently ensure the index + its attribute settings exist (runs once, memoised). */
private ensure(): Promise<void> {
if (!this.settingsReady) {
this.settingsReady = (async () => {
await this.client.createIndex(INDEX, { primaryKey: 'id' }).catch(() => undefined);
await this.client.index(INDEX).updateSettings({
searchableAttributes: ['text', 'subject'],
filterableAttributes: ['scopeId', 'threadId', 'source'],
sortableAttributes: ['at'],
});
})().catch((err) => {
this.settingsReady = undefined; // let a later call retry
throw err;
});
}
return this.settingsReady;
}
async index(docs: SearchDoc[]): Promise<void> {
if (docs.length === 0) return;
await this.ensure();
await this.client.index(INDEX).addDocuments(docs, { primaryKey: 'id' });
}
async remove(ids: string[]): Promise<void> {
if (ids.length === 0) return;
await this.client.index(INDEX).deleteDocuments(ids);
}
async search(scopeId: string, threadIds: string[], query: string, limit: number): Promise<SearchHit[]> {
if (threadIds.length === 0 || !query.trim()) return [];
await this.ensure();
const quoted = threadIds.map((t) => JSON.stringify(t)).join(', ');
const res = await this.client.index(INDEX).search(query, {
filter: `scopeId = ${JSON.stringify(scopeId)} AND threadId IN [${quoted}]`,
limit,
sort: ['at:desc'],
attributesToHighlight: ['text'],
highlightPreTag: '<em>',
highlightPostTag: '</em>',
attributesToCrop: ['text'],
cropLength: 30,
});
return res.hits.map((h) => {
const doc = h as unknown as SearchDoc & { _formatted?: { text?: string } };
return {
id: doc.id,
threadId: doc.threadId,
text: doc.text,
subject: doc.subject ?? null,
source: doc.source ?? null,
at: doc.at,
snippet: doc._formatted?.text ?? doc.text,
};
});
}
}
/** Build the search port from env: MEILI_URL set → Meilisearch; else the no-op. */
export function messageSearchFromEnv(env: NodeJS.ProcessEnv = process.env): MessageSearchPort {
const host = env.MEILI_URL?.trim();
if (!host) {
new Logger('MessageSearch').log('MEILI_URL unset — message search disabled (no-op)');
return noopMessageSearch;
}
new Logger('MessageSearch').log(`using Meilisearch @ ${host}`);
return new MeiliMessageSearch(host, env.MEILI_KEY?.trim() || undefined);
}