8af25def83
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>
102 lines
3.6 KiB
TypeScript
102 lines
3.6 KiB
TypeScript
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);
|
|
}
|