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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user